From 90a7340efd87f1174018093dc671f07bff02e733 Mon Sep 17 00:00:00 2001 From: Ooi Yee Fei Date: Sat, 1 Jun 2024 15:09:02 +0800 Subject: [PATCH 001/225] 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 002/225] 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 003/225] 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 004/225] 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 005/225] 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 006/225] 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 007/225] 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 008/225] 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 009/225] 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 010/225] 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 011/225] 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 012/225] 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 013/225] 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 014/225] 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 015/225] 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 64e7e9409f6bc84fed0241adca226d09ac396561 Mon Sep 17 00:00:00 2001 From: ElaineDeMattosSilvaB Date: Thu, 27 Jun 2024 21:44:08 +0200 Subject: [PATCH 016/225] feat: add oauth token option for fetch action Signed-off-by: ElaineDeMattosSilvaB --- .../urlReader/lib/GitlabUrlReader.ts | 26 +++++++-------- packages/integration/src/gitlab/core.test.ts | 32 ++++++++++++++++++- packages/integration/src/gitlab/core.ts | 17 +++++++--- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/urlReader/lib/GitlabUrlReader.ts b/packages/backend-defaults/src/entrypoints/urlReader/lib/GitlabUrlReader.ts index 615eeb20a2..35123da851 100644 --- a/packages/backend-defaults/src/entrypoints/urlReader/lib/GitlabUrlReader.ts +++ b/packages/backend-defaults/src/entrypoints/urlReader/lib/GitlabUrlReader.ts @@ -23,21 +23,21 @@ import { UrlReaderServiceSearchOptions, UrlReaderServiceSearchResponse, } from '@backstage/backend-plugin-api'; +import { NotFoundError, NotModifiedError } from '@backstage/errors'; import { + GitLabIntegration, + ScmIntegrations, getGitLabFileFetchUrl, getGitLabIntegrationRelativePath, getGitLabRequestOptions, - GitLabIntegration, - ScmIntegrations, } from '@backstage/integration'; -import fetch, { Response } from 'node-fetch'; import parseGitUrl from 'git-url-parse'; -import { Minimatch } from 'minimatch'; -import { Readable } from 'stream'; -import { NotFoundError, NotModifiedError } from '@backstage/errors'; -import { ReadTreeResponseFactory, ReaderFactory } from './types'; import { trimEnd, trimStart } from 'lodash'; +import { Minimatch } from 'minimatch'; +import fetch, { Response } from 'node-fetch'; +import { Readable } from 'stream'; import { ReadUrlResponseFactory } from './ReadUrlResponseFactory'; +import { ReadTreeResponseFactory, ReaderFactory } from './types'; import { parseLastModified } from './util'; /** @@ -71,14 +71,14 @@ export class GitlabUrlReader implements UrlReaderService { url: string, options?: UrlReaderServiceReadUrlOptions, ): Promise { - const { etag, lastModifiedAfter, signal } = options ?? {}; + const { etag, lastModifiedAfter, signal, token } = options ?? {}; const builtUrl = await this.getGitlabFetchUrl(url); let response: Response; try { response = await fetch(builtUrl, { headers: { - ...getGitLabRequestOptions(this.integration.config).headers, + ...getGitLabRequestOptions(this.integration.config, token).headers, ...(etag && { 'If-None-Match': etag }), ...(lastModifiedAfter && { 'If-Modified-Since': lastModifiedAfter.toUTCString(), @@ -120,7 +120,7 @@ export class GitlabUrlReader implements UrlReaderService { url: string, options?: UrlReaderServiceReadTreeOptions, ): Promise { - const { etag, signal } = options ?? {}; + const { etag, signal, token } = options ?? {}; const { ref, full_name, filepath } = parseGitUrl(url); let repoFullName = full_name; @@ -147,7 +147,7 @@ export class GitlabUrlReader implements UrlReaderService { repoFullName, )}`, ).toString(), - getGitLabRequestOptions(this.integration.config), + getGitLabRequestOptions(this.integration.config, token), ); if (!projectGitlabResponse.ok) { const msg = `Failed to read tree from ${url}, ${projectGitlabResponse.status} ${projectGitlabResponse.statusText}`; @@ -175,7 +175,7 @@ export class GitlabUrlReader implements UrlReaderService { )}/repository/commits?${commitsReqParams.toString()}`, ).toString(), { - ...getGitLabRequestOptions(this.integration.config), + ...getGitLabRequestOptions(this.integration.config, token), // TODO(freben): The signal cast is there because pre-3.x versions of // node-fetch have a very slightly deviating AbortSignal type signature. // The difference does not affect us in practice however. The cast can @@ -209,7 +209,7 @@ export class GitlabUrlReader implements UrlReaderService { repoFullName, )}/repository/archive?${archiveReqParams.toString()}`, { - ...getGitLabRequestOptions(this.integration.config), + ...getGitLabRequestOptions(this.integration.config, token), // TODO(freben): The signal cast is there because pre-3.x versions of // node-fetch have a very slightly deviating AbortSignal type signature. // The difference does not affect us in practice however. The cast can diff --git a/packages/integration/src/gitlab/core.test.ts b/packages/integration/src/gitlab/core.test.ts index 98fd9b7c13..fd374ea56f 100644 --- a/packages/integration/src/gitlab/core.test.ts +++ b/packages/integration/src/gitlab/core.test.ts @@ -17,7 +17,7 @@ import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { GitLabIntegrationConfig } from './config'; -import { getGitLabFileFetchUrl } from './core'; +import { getGitLabFileFetchUrl, getGitLabRequestOptions } from './core'; const worker = setupServer(); @@ -186,4 +186,34 @@ describe('gitlab core', () => { }); }); }); + + describe('getGitLabRequestOptions', () => { + it('should return Authorization header when oauthToken is provided', () => { + const oauthToken = 'mock-oauth-token'; + const result = getGitLabRequestOptions( + configSelfHosteWithRelativePath, + oauthToken, + ); + + expect(result).toEqual({ + headers: { + Authorization: `Bearer ${oauthToken}`, + }, + }); + }); + + it('should return private-token header when oauthToken is undefined', () => { + const oauthToken = undefined; + const result = getGitLabRequestOptions( + configSelfHosteWithRelativePath, + oauthToken, + ); + + expect(result).toEqual({ + headers: { + 'PRIVATE-TOKEN': configSelfHosteWithRelativePath.token, + }, + }); + }); + }); }); diff --git a/packages/integration/src/gitlab/core.ts b/packages/integration/src/gitlab/core.ts index 040ca202d6..e940440ea4 100644 --- a/packages/integration/src/gitlab/core.ts +++ b/packages/integration/src/gitlab/core.ts @@ -14,11 +14,11 @@ * limitations under the License. */ +import fetch from 'cross-fetch'; import { getGitLabIntegrationRelativePath, GitLabIntegrationConfig, } from './config'; -import fetch from 'cross-fetch'; /** * Given a URL pointing to a file on a provider, returns a URL that is suitable @@ -51,9 +51,18 @@ export async function getGitLabFileFetchUrl( * @param config - The relevant provider config * @public */ -export function getGitLabRequestOptions(config: GitLabIntegrationConfig): { - headers: Record; -} { +export function getGitLabRequestOptions( + config: GitLabIntegrationConfig, + oauthToken?: string, +): { headers: Record } { + if (oauthToken) { + return { + headers: { + Authorization: `Bearer ${oauthToken}`, + }, + }; + } + const { token = '' } = config; return { headers: { From 2f7a7df46b557ddb747e2952cc657aa9f46be133 Mon Sep 17 00:00:00 2001 From: ElaineDeMattosSilvaB Date: Thu, 27 Jun 2024 22:10:40 +0200 Subject: [PATCH 017/225] feat: add possibility of a user provided glpat Signed-off-by: ElaineDeMattosSilvaB --- packages/integration/src/gitlab/core.test.ts | 22 ++++++++++++++++---- packages/integration/src/gitlab/core.ts | 18 ++++++++-------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/integration/src/gitlab/core.test.ts b/packages/integration/src/gitlab/core.test.ts index fd374ea56f..c6e7410932 100644 --- a/packages/integration/src/gitlab/core.test.ts +++ b/packages/integration/src/gitlab/core.test.ts @@ -189,15 +189,30 @@ describe('gitlab core', () => { describe('getGitLabRequestOptions', () => { it('should return Authorization header when oauthToken is provided', () => { - const oauthToken = 'mock-oauth-token'; + const token = + 'de6780bc506a0446309bd9362820ba8aed28aa506c71eedbe1c5c4f9dd350e54'; const result = getGitLabRequestOptions( configSelfHosteWithRelativePath, - oauthToken, + token, ); expect(result).toEqual({ headers: { - Authorization: `Bearer ${oauthToken}`, + Authorization: `Bearer ${token}`, + }, + }); + }); + + it('should return private-token header when gl-token is provided', () => { + const token = 'glpat-1234566'; + const result = getGitLabRequestOptions( + configSelfHosteWithRelativePath, + token, + ); + + expect(result).toEqual({ + headers: { + 'PRIVATE-TOKEN': token, }, }); }); @@ -208,7 +223,6 @@ describe('gitlab core', () => { configSelfHosteWithRelativePath, oauthToken, ); - expect(result).toEqual({ headers: { 'PRIVATE-TOKEN': configSelfHosteWithRelativePath.token, diff --git a/packages/integration/src/gitlab/core.ts b/packages/integration/src/gitlab/core.ts index e940440ea4..94f4b65d9f 100644 --- a/packages/integration/src/gitlab/core.ts +++ b/packages/integration/src/gitlab/core.ts @@ -53,21 +53,21 @@ export async function getGitLabFileFetchUrl( */ export function getGitLabRequestOptions( config: GitLabIntegrationConfig, - oauthToken?: string, + token?: string, ): { headers: Record } { - if (oauthToken) { + if (token) { + // If token comes from the user and starts with "gl", it's a private token (see https://docs.gitlab.com/ee/security/token_overview.html#token-prefixes) return { - headers: { - Authorization: `Bearer ${oauthToken}`, - }, + headers: token.startsWith('gl') + ? { 'PRIVATE-TOKEN': token } + : { Authorization: `Bearer ${token}` }, // Otherwise, it's a bearer token }; } - const { token = '' } = config; + // If token not provided, fetch the integration token + const { token: configToken = '' } = config; return { - headers: { - 'PRIVATE-TOKEN': token, - }, + headers: { 'PRIVATE-TOKEN': configToken }, }; } From 78c13290e470387e4e490bece3362fe86f115bc6 Mon Sep 17 00:00:00 2001 From: ElaineDeMattosSilvaB Date: Thu, 27 Jun 2024 22:21:59 +0200 Subject: [PATCH 018/225] chore: add changeset and api-report Signed-off-by: ElaineDeMattosSilvaB --- .changeset/nice-peas-retire.md | 6 ++++++ packages/integration/api-report.md | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 .changeset/nice-peas-retire.md diff --git a/.changeset/nice-peas-retire.md b/.changeset/nice-peas-retire.md new file mode 100644 index 0000000000..7b7b13706d --- /dev/null +++ b/.changeset/nice-peas-retire.md @@ -0,0 +1,6 @@ +--- +'@backstage/integration': minor +'@backstage/backend-defaults': patch +--- + +Updated `GitlabUrlReader.readUrl` and `GitlabUrlReader.readTree` to accept a user-provided token, supporting both bearer and private tokens. diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index 4c8e8accf2..cf4b380ad9 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -508,7 +508,10 @@ export function getGitLabIntegrationRelativePath( ): string; // @public -export function getGitLabRequestOptions(config: GitLabIntegrationConfig): { +export function getGitLabRequestOptions( + config: GitLabIntegrationConfig, + token?: string, +): { headers: Record; }; From 28d3cf8c9db6f72f1d7f14c0c5d34d53a0599d80 Mon Sep 17 00:00:00 2001 From: ElaineDeMattosSilvaB Date: Thu, 27 Jun 2024 22:42:29 +0200 Subject: [PATCH 019/225] fix: change mocked bearer token format Signed-off-by: ElaineDeMattosSilvaB --- packages/integration/src/gitlab/core.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/integration/src/gitlab/core.test.ts b/packages/integration/src/gitlab/core.test.ts index c6e7410932..81adc29701 100644 --- a/packages/integration/src/gitlab/core.test.ts +++ b/packages/integration/src/gitlab/core.test.ts @@ -189,8 +189,7 @@ describe('gitlab core', () => { describe('getGitLabRequestOptions', () => { it('should return Authorization header when oauthToken is provided', () => { - const token = - 'de6780bc506a0446309bd9362820ba8aed28aa506c71eedbe1c5c4f9dd350e54'; + const token = '1234567890'; const result = getGitLabRequestOptions( configSelfHosteWithRelativePath, token, From 92583daf57de6cb64b0ed889deba220c66b933c7 Mon Sep 17 00:00:00 2001 From: Chap Ambrose Date: Thu, 27 Jun 2024 16:43:38 -0500 Subject: [PATCH 020/225] Update heroku deploy docs Signed-off-by: Chap Ambrose --- docs/deployment/heroku.md | 99 ++++++++++++++++++++++++++------------- 1 file changed, 66 insertions(+), 33 deletions(-) diff --git a/docs/deployment/heroku.md b/docs/deployment/heroku.md index 1545c34733..a70ff66ca5 100644 --- a/docs/deployment/heroku.md +++ b/docs/deployment/heroku.md @@ -5,40 +5,40 @@ sidebar_label: Heroku description: How to deploy Backstage to Heroku --- -Heroku is a Platform as a Service (PaaS) designed to handle application -deployment in a hands-off way. Heroku supports container deployment of Docker -images, a natural fit for Backstage. +Heroku is a Platform as a Service (PaaS) designed to simplify application deployment. -## Configuring the CLI +## Create App -First, install the -[heroku-cli](https://devcenter.heroku.com/articles/heroku-cli) and login: +Start with an exisiting Backstage app or follow the [getting started guide](https://backstage.io/docs/getting-started/) to create a new one: ```shell -$ heroku login +$ npx @backstage/create-app@latest + Enter a name ``` -If you have not yet created a project through the Heroku interface, you can create it through the CLI. +Install the +[Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli) and create a new Heroku app: ```shell -$ heroku create +$ cd your-app/ +$ heroku apps:create ``` -You _might_ also need to set your Heroku app's stack to `container`: +## Domain -```bash -$ heroku stack:set container -a +Get Heroku app URL: +```shell +$ heroku domains -a +.herokuapp.com ``` -Configuring your `app-config.yaml`: - +Update `baseUrl` and `port` in `app-config.yaml`. The core [app-backend plugin](https://www.npmjs.com/package/@backstage/plugin-app-backend) allows a single Heroku app to serve the frontend and backend: ```yaml app: - # Should be the same as backend.baseUrl when using the `app-backend` plugin - baseUrl: https://.herokuapp.com + baseUrl: https://.herokuapp.com backend: - baseUrl: https://.herokuapp.com + baseUrl: https://.herokuapp.com listen: port: $env: PORT @@ -46,28 +46,61 @@ backend: # https://devcenter.heroku.com/articles/dynos#web-dynos ``` -> Make sure your file is being copied into your container in the `Dockerfile`. +## Build Script -Before building the Docker image, run the [backstage host build commands](https://backstage.io/docs/deployment/docker#host-build). They must be run whenever you are going to publish a new image. +Add a build script in `package.json` to compile frontend during deployment: +```json +"scripts": { + "build": "yarn build:backend --config ../../app-config.yaml" +``` -Heroku runs a container registry on `registry.heroku.com`. To push Backstage -Docker images, log in to the container registry also: +## Start Command + +Create a [Procfile](https://devcenter.heroku.com/articles/procfile) in the app's root: +```shell +$ echo "web: yarn workspace backend start --config ../../app-config.yaml" > Procfile +``` + +## Database + +Provision a [Heroku Postgres](https://elements.heroku.com/addons/heroku-postgresql) database: +```shell +$ heroku addons:create heroku-postgresql -a +``` + +Update `database` in `app-config.yaml`: +```yaml +backend: + database: + client: pg + pluginDivisionMode: schema + ensureExists: false + ensureSchemaExists: true + connection: ${DATABASE_URL} +``` + +Allow postgres self-signed certificates: +```shell +$ heroku config:set PGSSLMODE=no-verify -a +``` + +## Deployment + +Commit changes and push to Heroku to build and deploy: ```shell -$ heroku container:login +$ git add Procfile && git commit -am "configure heroku" +$ git push heroku main ``` -## Push and deploy a Docker image +View the app in the browser: -Now we can push a Backstage [Docker image](docker.md) to Heroku's container -registry and release it to the `web` worker: - -```bash -$ docker image build . -f packages/backend/Dockerfile --tag registry.heroku.com//web - -$ docker push registry.heroku.com//web - -$ heroku container:release web -a +```shell +$ heroku open -a ``` -Now you should have Backstage up and running! 🎉 +View logs: + +```shell +$ heroku heroku -a +``` From 19d495939d23984987ac1097e61faadb3b0fd2aa Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Thu, 27 Jun 2024 23:20:51 -0400 Subject: [PATCH 021/225] 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 022/225] 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 c59167016378de2b5206737ab283b43d5b77887a Mon Sep 17 00:00:00 2001 From: Calvin Lee Date: Wed, 26 Jun 2024 16:07:06 -0600 Subject: [PATCH 023/225] fix: enable support for account and org level urls Signed-off-by: Calvin Lee --- .changeset/witty-bears-behave.md | 5 ++ packages/integration/src/harness/core.ts | 71 +++++++++++++++++------- 2 files changed, 55 insertions(+), 21 deletions(-) create mode 100644 .changeset/witty-bears-behave.md diff --git a/.changeset/witty-bears-behave.md b/.changeset/witty-bears-behave.md new file mode 100644 index 0000000000..33b4c0b187 --- /dev/null +++ b/.changeset/witty-bears-behave.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Updated function for getHarnessEditContentsUrl, getHarnessFileContentsUrl, getHarnessArchiveUrl, getHarnessLatestCommitUrl and parseHarnessUrl to handle account and org level urls diff --git a/packages/integration/src/harness/core.ts b/packages/integration/src/harness/core.ts index 81281eaa6e..1887a5c0d8 100644 --- a/packages/integration/src/harness/core.ts +++ b/packages/integration/src/harness/core.ts @@ -34,7 +34,12 @@ export function getHarnessEditContentsUrl( url: string, ) { const parsedUrl = parseHarnessUrl(config, url); - return `${parsedUrl.baseUrl}/ng/account/${parsedUrl.accountId}/module/code/orgs/${parsedUrl.orgName}/projects/${parsedUrl.projectName}/repos/${parsedUrl.repoName}/files/${parsedUrl.branch}/~/${parsedUrl.path}`; + + return `${parsedUrl.baseUrl}/ng/account/${parsedUrl.accountId}/module/code${ + parsedUrl.orgName !== '' ? `/orgs/${parsedUrl.orgName}` : '' + }${ + parsedUrl.projectName !== '' ? `/projects/${parsedUrl.projectName}` : '' + }/repos/${parsedUrl.repoName}/files/${parsedUrl.branch}/~/${parsedUrl.path}`; } /** @@ -44,6 +49,7 @@ export function getHarnessEditContentsUrl( * * Converts * from: https://app.harness.io/ng/account/accountId/module/code/orgs/orgName/projects/projName/repos/repoName/files/refMain/~/all-apis.yaml + * https://qa.harness.io/ng/account/bDCAuAjFSJCLFj_0ug3lCg/module/code/orgs/HiteshTest/repos/impoorter/files/main/~/catalog.yaml * to: https://app.harness.io/gateway/code/api/v1/repos/accountId/orgName/projName/repoName/+/content/all-apis.yaml?routingId=accountId&include_commit=false&ref=refMain * * @param url - A URL pointing to a file @@ -55,7 +61,14 @@ export function getHarnessFileContentsUrl( url: string, ) { const parsedUrl = parseHarnessUrl(config, url); - return `${parsedUrl.baseUrl}/gateway/code/api/v1/repos/${parsedUrl.accountId}/${parsedUrl.orgName}/${parsedUrl.projectName}/${parsedUrl.repoName}/+/raw/${parsedUrl.path}?routingId=${parsedUrl.accountId}&git_ref=refs/heads/${parsedUrl.refString}`; + + return `${parsedUrl.baseUrl}/gateway/code/api/v1/repos/${ + parsedUrl.accountId + }/${parsedUrl.orgName}${ + parsedUrl.projectName !== '' ? `/${parsedUrl.projectName}/` : '/' + }${parsedUrl.repoName}/+/raw/${parsedUrl.path}?routingId=${ + parsedUrl.accountId + }&git_ref=refs/heads/${parsedUrl.refString}`; } /** @@ -77,7 +90,13 @@ export function getHarnessArchiveUrl( url: string, ) { const parsedUrl = parseHarnessUrl(config, url); - return `${parsedUrl.baseUrl}/gateway/code/api/v1/repos/${parsedUrl.accountId}/${parsedUrl.orgName}/${parsedUrl.projectName}/${parsedUrl.repoName}/+/archive/${parsedUrl.branch}.zip?routingId=${parsedUrl.accountId}`; + return `${parsedUrl.baseUrl}/gateway/code/api/v1/repos/${ + parsedUrl.accountId + }/${parsedUrl.orgName}${ + parsedUrl.projectName !== '' ? `/${parsedUrl.projectName}/` : '/' + }${parsedUrl.repoName}/+/archive/${parsedUrl.branch}.zip?routingId=${ + parsedUrl.accountId + }`; } /** @@ -99,7 +118,13 @@ export function getHarnessLatestCommitUrl( url: string, ) { const parsedUrl = parseHarnessUrl(config, url); - return `${parsedUrl.baseUrl}/gateway/code/api/v1/repos/${parsedUrl.accountId}/${parsedUrl.orgName}/${parsedUrl.projectName}/${parsedUrl.repoName}/+/content?routingId=${parsedUrl.accountId}&include_commit=true&git_ref=refs/heads/${parsedUrl.branch}`; + return `${parsedUrl.baseUrl}/gateway/code/api/v1/repos/${ + parsedUrl.accountId + }/${parsedUrl.orgName}${ + parsedUrl.projectName !== '' ? `/${parsedUrl.projectName}/` : '/' + }${parsedUrl.repoName}/+/content?routingId=${ + parsedUrl.accountId + }&include_commit=true&git_ref=refs/heads/${parsedUrl.branch}`; } /** @@ -153,27 +178,30 @@ export function parseHarnessUrl( .split('/') .filter(segment => segment !== ''); const urlParts = pathUrl.pathname.split('/'); - const [ - _ng, - _account, - accountId, - _module, - _moduleName, - _org, - orgName, - _projects, - projectName, - _repos, - repoName, - _files, - _ref, - _branch, - ..._path - ] = pathSegments; + + const accountIdIndex = + pathSegments.findIndex(segment => segment === 'account') + 1; + const accountId = pathSegments[accountIdIndex]; + + const orgNameIndex = pathSegments.findIndex(segment => segment === 'orgs'); + const orgName = orgNameIndex !== -1 ? pathSegments[orgNameIndex + 1] : ''; + + const projectNameIndex = pathSegments.findIndex( + segment => segment === 'projects', + ); + + const projectName = + projectNameIndex !== -1 ? pathSegments[projectNameIndex + 1] : ''; + + const repoNameIndex = + pathSegments.findIndex(segment => segment === 'repos') + 1; + const repoName = pathSegments[repoNameIndex]; + const refAndPath = urlParts.slice( urlParts.findIndex(i => i === 'files' || i === 'edit') + 1, ); const refIndex = refAndPath.findIndex(item => item === '~'); + const refString = refAndPath.slice(0, refIndex).join('/'); const pathWithoutSlash = refIndex !== -1 @@ -182,6 +210,7 @@ export function parseHarnessUrl( .join('/') .replace(/^\//, '') : ''; + return { baseUrl: baseUrl, accountId: accountId, From a840355a10f9ad1b47975b65b30e89a4e7181876 Mon Sep 17 00:00:00 2001 From: Calvin Lee Date: Fri, 28 Jun 2024 11:41:43 -0600 Subject: [PATCH 024/225] fix: update changeset Signed-off-by: Calvin Lee --- .changeset/witty-bears-behave.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/witty-bears-behave.md b/.changeset/witty-bears-behave.md index 33b4c0b187..7514759189 100644 --- a/.changeset/witty-bears-behave.md +++ b/.changeset/witty-bears-behave.md @@ -2,4 +2,4 @@ '@backstage/integration': patch --- -Updated function for getHarnessEditContentsUrl, getHarnessFileContentsUrl, getHarnessArchiveUrl, getHarnessLatestCommitUrl and parseHarnessUrl to handle account and org level urls +Updated functions for getHarnessEditContentsUrl, getHarnessFileContentsUrl, getHarnessArchiveUrl, getHarnessLatestCommitUrl and parseHarnessUrl to handle account and org level urls From 1edd6c27ee943747bd58e0bf35c86944902287e6 Mon Sep 17 00:00:00 2001 From: Tim Klever Date: Sat, 29 Jun 2024 11:29:38 -0700 Subject: [PATCH 025/225] feat: update ConfigSources options to match process.env typings Signed-off-by: Tim Klever --- .changeset/neat-bears-divide.md | 28 +++++++++++++++++++ packages/config-loader/api-report.md | 2 +- .../src/sources/ConfigSources.ts | 2 +- 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 .changeset/neat-bears-divide.md diff --git a/.changeset/neat-bears-divide.md b/.changeset/neat-bears-divide.md new file mode 100644 index 0000000000..b40db6c7bd --- /dev/null +++ b/.changeset/neat-bears-divide.md @@ -0,0 +1,28 @@ +--- +'@backstage/config-loader': major +--- + +**BREAKING** The `env` property on the `ConfigSourcesDefaultOptions` interface allows for undefined members. + +This change makes `ConfigSourcesDefaultOptions.env` consistent with other parts of this package and the wider +`node` ecosystem. Specifically, with this update `ConfigSourcesDefaultOptions.env`'s types match: + +#### EnvConfigSource + +- [`EnvConfigSource.constructor`](https://github.com/backstage/backstage/blob/c780320418b7775f18fc0d2cc279ee7db9c7cb25/packages/config-loader/src/sources/EnvConfigSource.ts#L68) +- [`EnvConfigSourceOptions`](https://github.com/backstage/backstage/blob/c780320418b7775f18fc0d2cc279ee7db9c7cb25/packages/config-loader/src/sources/EnvConfigSource.ts#L31) +- [`readEnvConfig`](https://github.com/backstage/backstage/blob/c780320418b7775f18fc0d2cc279ee7db9c7cb25/packages/config-loader/src/sources/EnvConfigSource.ts#L114) + +#### NodeJS process.env + +- [`ProcessEnv`](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/aa121ee3a7a3caa16433b66c1103df5904f612ea/types/node/v18/process.d.ts#L137) + which extends [`Dict`](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/aa121ee3a7a3caa16433b66c1103df5904f612ea/types/node/v18/globals.d.ts#L333-L335) + +While this is a somewhat trivial change, it does loosen typings on a public interface, so by definition it's a breaking +change and a major version bump. + +You may meed to update your backstage instance if you have built an implementation of `ConfigSources` or any other +functionality that relies on `ConfigSourcesDefaultOptions`. This change means you'll have to update that code to account +for the possibility of `undefined` values inside `ConfigSourcesDefaultOptions.env`, although it is very likely already +accounted for as the typings for [`process.env`](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/aa121ee3a7a3caa16433b66c1103df5904f612ea/types/node/v18/process.d.ts#L137) +already include the possibility of [`undefined`](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/aa121ee3a7a3caa16433b66c1103df5904f612ea/types/node/v18/globals.d.ts#L333-L335). diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md index bc8f217bbb..da836ffd21 100644 --- a/packages/config-loader/api-report.md +++ b/packages/config-loader/api-report.md @@ -87,7 +87,7 @@ export interface ConfigSourcesDefaultOptions extends BaseConfigSourcesOptions { // (undocumented) argv?: string[]; // (undocumented) - env?: Record; + env?: Record; } // @public diff --git a/packages/config-loader/src/sources/ConfigSources.ts b/packages/config-loader/src/sources/ConfigSources.ts index ecff39da2d..15eede736e 100644 --- a/packages/config-loader/src/sources/ConfigSources.ts +++ b/packages/config-loader/src/sources/ConfigSources.ts @@ -102,7 +102,7 @@ export interface ConfigSourcesDefaultForTargetsOptions */ export interface ConfigSourcesDefaultOptions extends BaseConfigSourcesOptions { argv?: string[]; - env?: Record; + env?: Record; } /** From b141c20b53082ad16af942c2574040d4b83921c6 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Sun, 30 Jun 2024 00:53:24 -0400 Subject: [PATCH 026/225] 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 027/225] 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 028/225] 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 bc2700cbf962e3f3a4554d664a193943224b3f27 Mon Sep 17 00:00:00 2001 From: Chap Ambrose Date: Mon, 8 Jul 2024 08:26:47 -0500 Subject: [PATCH 029/225] recommend updating config in app-config.production.yaml Co-authored-by: Andre Wanlin <67169551+awanlin@users.noreply.github.com> Signed-off-by: Chap Ambrose --- docs/deployment/heroku.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/deployment/heroku.md b/docs/deployment/heroku.md index a70ff66ca5..23dff683f8 100644 --- a/docs/deployment/heroku.md +++ b/docs/deployment/heroku.md @@ -32,7 +32,7 @@ $ heroku domains -a .herokuapp.com ``` -Update `baseUrl` and `port` in `app-config.yaml`. The core [app-backend plugin](https://www.npmjs.com/package/@backstage/plugin-app-backend) allows a single Heroku app to serve the frontend and backend: +The core [app-backend plugin](https://www.npmjs.com/package/@backstage/plugin-app-backend) allows a single Heroku app to serve the frontend and backend. To make this work you need to update the `baseUrl` and `port` in `app-config.production.yaml`: ```yaml app: baseUrl: https://.herokuapp.com From fb6a5d5f6ba90edb6152c20d4c111b93a9182d16 Mon Sep 17 00:00:00 2001 From: Chap Ambrose Date: Mon, 8 Jul 2024 08:27:20 -0500 Subject: [PATCH 030/225] Typo in docs Co-authored-by: Andre Wanlin <67169551+awanlin@users.noreply.github.com> Signed-off-by: Chap Ambrose --- docs/deployment/heroku.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/deployment/heroku.md b/docs/deployment/heroku.md index 23dff683f8..5e6eb59a82 100644 --- a/docs/deployment/heroku.md +++ b/docs/deployment/heroku.md @@ -9,7 +9,7 @@ Heroku is a Platform as a Service (PaaS) designed to simplify application deploy ## Create App -Start with an exisiting Backstage app or follow the [getting started guide](https://backstage.io/docs/getting-started/) to create a new one: +Starting with an existing Backstage app or follow the [getting started guide](https://backstage.io/docs/getting-started/) to create a new one: ```shell $ npx @backstage/create-app@latest From 6b56a1921925517006e8cbdecffccc3f74814916 Mon Sep 17 00:00:00 2001 From: Chap Ambrose Date: Mon, 8 Jul 2024 08:27:45 -0500 Subject: [PATCH 031/225] add app-config.production to build script Co-authored-by: Andre Wanlin <67169551+awanlin@users.noreply.github.com> Signed-off-by: Chap Ambrose --- docs/deployment/heroku.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/deployment/heroku.md b/docs/deployment/heroku.md index 5e6eb59a82..c5042c732c 100644 --- a/docs/deployment/heroku.md +++ b/docs/deployment/heroku.md @@ -51,7 +51,7 @@ backend: Add a build script in `package.json` to compile frontend during deployment: ```json "scripts": { - "build": "yarn build:backend --config ../../app-config.yaml" + "build": "yarn build:backend --config ../../app-config.yaml --config app-config.production.yaml" ``` ## Start Command From 32b73f957c7ee8379e190f66640a4a9ec7ced9ab Mon Sep 17 00:00:00 2001 From: Chap Ambrose Date: Mon, 8 Jul 2024 08:32:26 -0500 Subject: [PATCH 032/225] remove command to create new backstage app Signed-off-by: Chap Ambrose --- docs/deployment/heroku.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/docs/deployment/heroku.md b/docs/deployment/heroku.md index c5042c732c..9629dbb212 100644 --- a/docs/deployment/heroku.md +++ b/docs/deployment/heroku.md @@ -9,12 +9,7 @@ Heroku is a Platform as a Service (PaaS) designed to simplify application deploy ## Create App -Starting with an existing Backstage app or follow the [getting started guide](https://backstage.io/docs/getting-started/) to create a new one: - -```shell -$ npx @backstage/create-app@latest - Enter a name -``` +Starting with an existing Backstage app or follow the [getting started guide](https://backstage.io/docs/getting-started/) to create a new one. Install the [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli) and create a new Heroku app: From e80a94f5c31aad63840c36bf592a072310d787c2 Mon Sep 17 00:00:00 2001 From: Chap Ambrose Date: Mon, 8 Jul 2024 09:50:17 -0500 Subject: [PATCH 033/225] add docker instructions to heroku.md Signed-off-by: Chap Ambrose --- docs/deployment/heroku.md | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/docs/deployment/heroku.md b/docs/deployment/heroku.md index 9629dbb212..8f173578c5 100644 --- a/docs/deployment/heroku.md +++ b/docs/deployment/heroku.md @@ -46,14 +46,14 @@ backend: Add a build script in `package.json` to compile frontend during deployment: ```json "scripts": { - "build": "yarn build:backend --config ../../app-config.yaml --config app-config.production.yaml" + "build": "yarn build:backend --config ../../app-config.yaml --config ../../app-config.production.yaml" ``` ## Start Command Create a [Procfile](https://devcenter.heroku.com/articles/procfile) in the app's root: ```shell -$ echo "web: yarn workspace backend start --config ../../app-config.yaml" > Procfile +$ echo "web: yarn workspace backend start --config ../../app-config.yaml --config ../../app-config.production.yaml" > Procfile ``` ## Database @@ -63,7 +63,7 @@ Provision a [Heroku Postgres](https://elements.heroku.com/addons/heroku-postgres $ heroku addons:create heroku-postgresql -a ``` -Update `database` in `app-config.yaml`: +Update `database` in `app-config.production.yaml`: ```yaml backend: database: @@ -99,3 +99,33 @@ View logs: ```shell $ heroku heroku -a ``` + +## Docker + +As an alternative to git deploys, Heroku also [supports container images](https://devcenter.heroku.com/articles/container-registry-and-runtime). + +Login to Heroku's container registry: + +```shell +$ heroku container:login +``` + +Configure the Heroku app to run a container image: + +```shell +$ heroku stack:set container -a +``` + +Locally run the [host build command](https://backstage.io/docs/deployment/docker/#host-build): +```shell +$ yarn build:backend --config ../../app-config.yaml --config ../../app-config.production.yaml +``` + +Build, push, and release the container image: +```shell +$ docker image build . -f packages/backend/Dockerfile --tag registry.heroku.com//web + +$ docker push registry.heroku.com//web + +$ heroku container:release web -a +``` From dc41795dd508275e8d95a89b81861dd1ea636500 Mon Sep 17 00:00:00 2001 From: Chap Ambrose Date: Mon, 8 Jul 2024 11:15:28 -0500 Subject: [PATCH 034/225] prettier fixes Signed-off-by: Chap Ambrose --- docs/deployment/heroku.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/deployment/heroku.md b/docs/deployment/heroku.md index 8f173578c5..74732bb431 100644 --- a/docs/deployment/heroku.md +++ b/docs/deployment/heroku.md @@ -22,12 +22,14 @@ $ heroku apps:create ## Domain Get Heroku app URL: + ```shell $ heroku domains -a .herokuapp.com ``` The core [app-backend plugin](https://www.npmjs.com/package/@backstage/plugin-app-backend) allows a single Heroku app to serve the frontend and backend. To make this work you need to update the `baseUrl` and `port` in `app-config.production.yaml`: + ```yaml app: baseUrl: https://.herokuapp.com @@ -44,6 +46,7 @@ backend: ## Build Script Add a build script in `package.json` to compile frontend during deployment: + ```json "scripts": { "build": "yarn build:backend --config ../../app-config.yaml --config ../../app-config.production.yaml" @@ -52,6 +55,7 @@ Add a build script in `package.json` to compile frontend during deployment: ## Start Command Create a [Procfile](https://devcenter.heroku.com/articles/procfile) in the app's root: + ```shell $ echo "web: yarn workspace backend start --config ../../app-config.yaml --config ../../app-config.production.yaml" > Procfile ``` @@ -59,11 +63,13 @@ $ echo "web: yarn workspace backend start --config ../../app-config.yaml --confi ## Database Provision a [Heroku Postgres](https://elements.heroku.com/addons/heroku-postgresql) database: + ```shell $ heroku addons:create heroku-postgresql -a ``` Update `database` in `app-config.production.yaml`: + ```yaml backend: database: @@ -75,6 +81,7 @@ backend: ``` Allow postgres self-signed certificates: + ```shell $ heroku config:set PGSSLMODE=no-verify -a ``` @@ -117,11 +124,13 @@ $ heroku stack:set container -a ``` Locally run the [host build command](https://backstage.io/docs/deployment/docker/#host-build): + ```shell $ yarn build:backend --config ../../app-config.yaml --config ../../app-config.production.yaml ``` Build, push, and release the container image: + ```shell $ docker image build . -f packages/backend/Dockerfile --tag registry.heroku.com//web From 9d9a391cfb92796faf192a0f60fdba01bb7ce5f0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Jul 2024 14:21:33 +0200 Subject: [PATCH 035/225] beps/0009: move to implemented Signed-off-by: Patrik Oldsberg --- beps/0009-plugin-metadata/README.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/beps/0009-plugin-metadata/README.md b/beps/0009-plugin-metadata/README.md index c0ae487daa..59101f121c 100644 --- a/beps/0009-plugin-metadata/README.md +++ b/beps/0009-plugin-metadata/README.md @@ -1,6 +1,6 @@ --- title: Plugin Metadata -status: implementable +status: implemented authors: - '@Rugvip' owners: @@ -10,16 +10,8 @@ project-areas: creation-date: yyyy-mm-dd --- - - # BEP: Plugin Metadata - - [**Discussion Issue**](https://github.com/backstage/backstage/issues/25076) - [Summary](#summary) From bebd569c565516fb411c276bf47b1aef80c5bc9e Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Wed, 10 Jul 2024 20:44:25 -0400 Subject: [PATCH 036/225] 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 b652b6de1fb647cc8fe65af2f6e6baa71574c9ac Mon Sep 17 00:00:00 2001 From: Chap Ambrose Date: Thu, 11 Jul 2024 08:16:21 -0500 Subject: [PATCH 037/225] add deploy buttuon to heroku.md Signed-off-by: Chap Ambrose --- docs/deployment/heroku.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/deployment/heroku.md b/docs/deployment/heroku.md index 74732bb431..faf1c2106e 100644 --- a/docs/deployment/heroku.md +++ b/docs/deployment/heroku.md @@ -7,6 +7,8 @@ description: How to deploy Backstage to Heroku Heroku is a Platform as a Service (PaaS) designed to simplify application deployment. +[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/chap/backstage-button) + ## Create App Starting with an existing Backstage app or follow the [getting started guide](https://backstage.io/docs/getting-started/) to create a new one. From a3449e0767d0ce17bee320341ec1309e6830dc88 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 12 Jul 2024 07:40:53 +0000 Subject: [PATCH 038/225] chore(deps): update snyk/actions digest to 640e317 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/sync_snyk-github-issues.yml | 2 +- .github/workflows/sync_snyk-monitor.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sync_snyk-github-issues.yml b/.github/workflows/sync_snyk-github-issues.yml index fb3456bfb6..1cdc0cb22a 100644 --- a/.github/workflows/sync_snyk-github-issues.yml +++ b/.github/workflows/sync_snyk-github-issues.yml @@ -29,7 +29,7 @@ jobs: cache-prefix: ${{ runner.os }}-v18.x - name: Create Snyk report - uses: snyk/actions/node@d406fd286b663eb8c6f8adcced4f7bcd199c0a3f # master + uses: snyk/actions/node@640e31719aac3e44867d239dc86c20c3e34c8e4f # master continue-on-error: true # Snyk CLI exits with error when vulnerabilities are found with: args: > diff --git a/.github/workflows/sync_snyk-monitor.yml b/.github/workflows/sync_snyk-monitor.yml index a3c72c1d7b..b1e50397ce 100644 --- a/.github/workflows/sync_snyk-monitor.yml +++ b/.github/workflows/sync_snyk-monitor.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Monitor and Synchronize Snyk Policies - uses: snyk/actions/node@d406fd286b663eb8c6f8adcced4f7bcd199c0a3f # master + uses: snyk/actions/node@640e31719aac3e44867d239dc86c20c3e34c8e4f # master with: command: monitor args: > @@ -46,7 +46,7 @@ jobs: # Above we run the `monitor` command, this runs the `test` command which is # the one that generates the SARIF report that we can upload to GitHub. - name: Create Snyk report - uses: snyk/actions/node@d406fd286b663eb8c6f8adcced4f7bcd199c0a3f # master + uses: snyk/actions/node@640e31719aac3e44867d239dc86c20c3e34c8e4f # master continue-on-error: true # To make sure that SARIF upload gets called with: args: > From dae85dfd213b1711fd850e48777361065725b1e4 Mon Sep 17 00:00:00 2001 From: parmar-abhinav Date: Sat, 13 Jul 2024 14:07:56 +0530 Subject: [PATCH 039/225] Add examples for fetch:cookiecutter scaffolder action & improve related tests Signed-off-by: parmar-abhinav --- .changeset/stupid-dots-relate.md | 5 + .../package.json | 1 + .../fetch/cookiecutter.examples.test.ts | 274 ++++++++++++++++++ .../actions/fetch/cookiecutter.examples.ts | 119 ++++++++ .../src/actions/fetch/cookiecutter.ts | 2 + 5 files changed, 401 insertions(+) create mode 100644 .changeset/stupid-dots-relate.md create mode 100644 plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.examples.test.ts create mode 100644 plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.examples.ts diff --git a/.changeset/stupid-dots-relate.md b/.changeset/stupid-dots-relate.md new file mode 100644 index 0000000000..a83777bff9 --- /dev/null +++ b/.changeset/stupid-dots-relate.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-cookiecutter': patch +--- + +Add examples for `fetch:cookiecutter` scaffolder action & improve related tests diff --git a/plugins/scaffolder-backend-module-cookiecutter/package.json b/plugins/scaffolder-backend-module-cookiecutter/package.json index 43a7e348a4..261010f681 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/package.json +++ b/plugins/scaffolder-backend-module-cookiecutter/package.json @@ -53,6 +53,7 @@ "command-exists": "^1.2.9", "fs-extra": "^11.2.0", "winston": "^3.2.1", + "yaml": "^2.0.0", "yn": "^4.0.0" }, "devDependencies": { diff --git a/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.examples.test.ts b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.examples.test.ts new file mode 100644 index 0000000000..ae46ef1bd6 --- /dev/null +++ b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.examples.test.ts @@ -0,0 +1,274 @@ +/* + * Copyright 2021 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 { UrlReader, ContainerRunner } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { JsonObject } from '@backstage/types'; +import { ScmIntegrations } from '@backstage/integration'; +import { createMockDirectory } from '@backstage/backend-test-utils'; +import { createFetchCookiecutterAction } from './cookiecutter'; +import { join } from 'path'; +import type { ActionContext } from '@backstage/plugin-scaffolder-node'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import { examples } from './cookiecutter.examples'; +import yaml from 'yaml'; + +const executeShellCommand = jest.fn(); +const commandExists = jest.fn(); +const fetchContents = jest.fn(); + +jest.mock('@backstage/plugin-scaffolder-node', () => ({ + ...jest.requireActual('@backstage/plugin-scaffolder-node'), + fetchContents: (...args: any[]) => fetchContents(...args), + executeShellCommand: (...args: any[]) => executeShellCommand(...args), +})); + +jest.mock( + 'command-exists', + () => + (...args: any[]) => + commandExists(...args), +); + +describe('fetch:cookiecutter', () => { + const mockDir = createMockDirectory({ mockOsTmpDir: true }); + const integrations = ScmIntegrations.fromConfig( + new ConfigReader({ + integrations: { + azure: [ + { host: 'dev.azure.com', token: 'tokenlols' }, + { host: 'myazurehostnotoken.com' }, + ], + }, + }), + ); + + const mockTmpDir = mockDir.path; + + let mockContext: ActionContext<{ + url: string; + targetPath?: string; + values: JsonObject; + copyWithoutRender?: string[]; + extensions?: string[]; + imageName?: string; + }>; + + const containerRunner: jest.Mocked = { + runContainer: jest.fn(), + }; + + const mockReader: UrlReader = { + readUrl: jest.fn(), + readTree: jest.fn(), + search: jest.fn(), + }; + + const action = createFetchCookiecutterAction({ + integrations, + containerRunner, + reader: mockReader, + }); + + beforeEach(() => { + jest.resetAllMocks(); + + mockContext = createMockActionContext({ + input: { + url: 'https://google.com/cookie/cutter', + targetPath: 'something', + values: { + help: 'me', + }, + }, + templateInfo: { + entityRef: 'template:default/cookiecutter', + baseUrl: 'somebase', + }, + workspacePath: mockTmpDir, + }); + mockDir.setContent({ template: {} }); + + commandExists.mockResolvedValue(null); + + // Mock when run container is called it creates some new files in the mock filesystem + containerRunner.runContainer.mockImplementation(async () => { + mockDir.setContent({ + 'intermediate/testfile.json': '{}', + }); + }); + + // Mock when executeShellCommand is called it creates some new files in the mock filesystem + executeShellCommand.mockImplementation(async () => { + mockDir.setContent({ + 'intermediate/testfile.json': '{}', + }); + }); + }); + + it(`should ${examples[0].description}`, async () => { + const input = yaml.parse(examples[0].example).steps[0].input; + + commandExists.mockResolvedValue(false); + + fetchContents.mockImplementation(() => Promise.resolve()); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(fetchContents).toHaveBeenCalledWith( + expect.objectContaining({ + reader: mockReader, + integrations, + baseUrl: mockContext.templateInfo?.baseUrl, + fetchUrl: mockContext.input.url, + outputPath: join( + mockTmpDir, + 'template', + "{{cookiecutter and 'contents'}}", + ), + }), + ); + expect(containerRunner.runContainer).toHaveBeenCalled(); + }); + + it(`should ${examples[1].description}`, async () => { + const input = yaml.parse(examples[1].example).steps[0].input; + + commandExists.mockResolvedValue(false); + + fetchContents.mockImplementation(() => Promise.resolve()); + + await action.handler({ + ...mockContext, + input: input, + }); + + expect(fetchContents).toHaveBeenCalledWith( + expect.objectContaining({ + reader: mockReader, + integrations, + baseUrl: mockContext.templateInfo?.baseUrl, + fetchUrl: mockContext.input.url, + outputPath: join( + mockTmpDir, + 'template', + "{{cookiecutter and 'contents'}}", + ), + }), + ); + expect(containerRunner.runContainer).toHaveBeenCalled(); + }); + + it(`should ${examples[2].description}`, async () => { + const input = yaml.parse(examples[2].example).steps[0].input; + + commandExists.mockResolvedValue(false); + + fetchContents.mockImplementation(() => Promise.resolve()); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(fetchContents).toHaveBeenCalledWith( + expect.objectContaining({ + reader: mockReader, + integrations, + baseUrl: mockContext.templateInfo?.baseUrl, + fetchUrl: mockContext.input.url, + outputPath: join( + mockTmpDir, + 'template', + "{{cookiecutter and 'contents'}}", + ), + }), + ); + + expect(containerRunner.runContainer).toHaveBeenCalled(); + }); + + it(`should ${examples[3].description}`, async () => { + const input = yaml.parse(examples[3].example).steps[0].input; + + commandExists.mockResolvedValue(false); + + fetchContents.mockImplementation(() => Promise.resolve()); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(fetchContents).toHaveBeenCalledWith( + expect.objectContaining({ + reader: mockReader, + integrations, + baseUrl: mockContext.templateInfo?.baseUrl, + fetchUrl: mockContext.input.url, + outputPath: join( + mockTmpDir, + 'template', + "{{cookiecutter and 'contents'}}", + ), + }), + ); + expect(containerRunner.runContainer).toHaveBeenCalled(); + }); + + it(`should ${examples[4].description}`, async () => { + const input = yaml.parse(examples[4].example).steps[0].input; + + commandExists.mockResolvedValue(false); + + fetchContents.mockImplementation(() => Promise.resolve()); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(fetchContents).toHaveBeenCalledWith( + expect.objectContaining({ + reader: mockReader, + integrations, + baseUrl: mockContext.templateInfo?.baseUrl, + fetchUrl: mockContext.input.url, + outputPath: join( + mockTmpDir, + 'template', + "{{cookiecutter and 'contents'}}", + ), + }), + ); + expect(containerRunner.runContainer).toHaveBeenCalled(); + }); +}); diff --git a/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.examples.ts b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.examples.ts new file mode 100644 index 0000000000..6ebada50f0 --- /dev/null +++ b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.examples.ts @@ -0,0 +1,119 @@ +/* + * Copyright 2021 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 { TemplateExample } from '@backstage/plugin-scaffolder-node'; +import yaml from 'yaml'; + +export const examples: TemplateExample[] = [ + { + description: 'Fetch and template using cookiecutter with minimal options.', + example: yaml.stringify({ + steps: [ + { + id: 'fetchTemplate', + action: 'fetch:cookiecutter', + name: 'Fetch and Template Using Cookiecutter', + input: { + url: 'https://google.com/cookie/cutter', + values: { + help: 'me', + }, + }, + }, + ], + }), + }, + { + description: + 'Fetch and template using cookiecutter with custom target path.', + example: yaml.stringify({ + steps: [ + { + id: 'fetchTemplate', + action: 'fetch:cookiecutter', + name: 'Fetch and Template Using Cookiecutter', + input: { + url: 'https://google.com/cookie/cutter', + targetPath: 'custom-path', + values: { + help: 'me', + }, + }, + }, + ], + }), + }, + { + description: + 'Fetch and template using cookiecutter with copyWithoutRender.', + example: yaml.stringify({ + steps: [ + { + id: 'fetchTemplate', + action: 'fetch:cookiecutter', + name: 'Fetch and Template Using Cookiecutter', + input: { + url: 'https://google.com/cookie/cutter', + values: { + help: 'me', + }, + copyWithoutRender: ['.github/*', '*.md'], + }, + }, + ], + }), + }, + { + description: + 'Fetch and template using cookiecutter with custom extensions.', + example: yaml.stringify({ + steps: [ + { + id: 'fetchTemplate', + action: 'fetch:cookiecutter', + name: 'Fetch and Template Using Cookiecutter', + input: { + url: 'https://google.com/cookie/cutter', + values: { + help: 'me', + }, + extensions: ['jinja2_time.TimeExtension'], + }, + }, + ], + }), + }, + { + description: + 'Fetch and template using cookiecutter with a custom Docker image.', + example: yaml.stringify({ + steps: [ + { + id: 'fetchTemplate', + action: 'fetch:cookiecutter', + name: 'Fetch and Template Using Cookiecutter', + input: { + url: 'https://google.com/cookie/cutter', + values: { + help: 'me', + }, + imageName: 'custom/cookiecutter-image', + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.ts b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.ts index d170acb5d0..efbe0a07cf 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.ts +++ b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.ts @@ -28,6 +28,7 @@ import { fetchContents, executeShellCommand, } from '@backstage/plugin-scaffolder-node'; +import { examples } from './cookiecutter.examples'; export class CookiecutterRunner { private readonly containerRunner?: ContainerRunner; @@ -156,6 +157,7 @@ export function createFetchCookiecutterAction(options: { id: 'fetch:cookiecutter', description: 'Downloads a template from the given URL into the workspace, and runs cookiecutter on it.', + examples, schema: { input: { type: 'object', From 6fc03c7d3ab0b1ff75ea037c113314bf47fecb99 Mon Sep 17 00:00:00 2001 From: parmar-abhinav Date: Sat, 13 Jul 2024 16:06:42 +0530 Subject: [PATCH 040/225] Add examples for notification:send scaffolder action & improve related tests Signed-off-by: parmar-abhinav --- .changeset/cool-insects-remember.md | 5 + .../package.json | 3 +- .../actions/sendNotification.examples.test.ts | 272 ++++++++++++++++++ .../src/actions/sendNotification.examples.ts | 255 ++++++++++++++++ .../src/actions/sendNotification.ts | 2 + 5 files changed, 536 insertions(+), 1 deletion(-) create mode 100644 .changeset/cool-insects-remember.md create mode 100644 plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.examples.test.ts create mode 100644 plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.examples.ts diff --git a/.changeset/cool-insects-remember.md b/.changeset/cool-insects-remember.md new file mode 100644 index 0000000000..ab33015377 --- /dev/null +++ b/.changeset/cool-insects-remember.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-notifications': patch +--- + +Add examples for notification:send scaffolder action & improve related tests diff --git a/plugins/scaffolder-backend-module-notifications/package.json b/plugins/scaffolder-backend-module-notifications/package.json index 7c983c36ce..5e45676f99 100644 --- a/plugins/scaffolder-backend-module-notifications/package.json +++ b/plugins/scaffolder-backend-module-notifications/package.json @@ -38,7 +38,8 @@ "@backstage/plugin-notifications-common": "workspace:^", "@backstage/plugin-notifications-node": "workspace:^", "@backstage/plugin-scaffolder-node": "workspace:^", - "octokit": "^3.0.0" + "octokit": "^3.0.0", + "yaml": "^2.0.0" }, "devDependencies": { "@backstage/cli": "workspace:^", diff --git a/plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.examples.test.ts b/plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.examples.test.ts new file mode 100644 index 0000000000..84473335e0 --- /dev/null +++ b/plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.examples.test.ts @@ -0,0 +1,272 @@ +/* + * 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 { createSendNotificationAction } from './sendNotification'; +import { NotificationService } from '@backstage/plugin-notifications-node'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import { examples } from './sendNotification.examples'; +import yaml from 'yaml'; + +describe('notification:send', () => { + const notificationService: jest.Mocked = { + send: jest.fn(), + }; + + let action: TemplateAction; + + beforeEach(() => { + jest.resetAllMocks(); + action = createSendNotificationAction({ + notifications: notificationService, + }); + }); + + const mockContext = createMockActionContext({ + input: { + recipients: 'broadcast', + title: 'Test notification', + }, + }); + + it(`should ${examples[0].description}`, async () => { + const input = yaml.parse(examples[0].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { + input: input, + }); + await action.handler(ctx); + expect(notificationService.send).toHaveBeenCalledWith({ + recipients: { type: 'broadcast' }, + payload: { + title: 'Test notification', + }, + }); + }); + + it(`should ${examples[1].description}`, async () => { + const input = yaml.parse(examples[1].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { + input: input, + }); + await action.handler(ctx); + expect(notificationService.send).toHaveBeenCalledWith({ + recipients: { + entityRef: ['entity:component:1'], + type: 'entity', + }, + payload: { + description: 'A security update has been applied. Please review.', + title: 'Security Update', + link: 'https://example.com/security/update', + severity: 'high', + scope: 'internal', + }, + }); + }); + + it(`should ${examples[2].description}`, async () => { + const input = yaml.parse(examples[2].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { + input: input, + }); + await action.handler(ctx); + expect(notificationService.send).toHaveBeenCalledWith({ + recipients: { + type: 'entity', + entityRef: ['entity:component:1'], + }, + payload: { + description: 'Here is your weekly update.', + link: undefined, + severity: 'low', + scope: undefined, + title: 'Weekly Update', + }, + }); + }); + + it(`should ${examples[3].description}`, async () => { + const input = yaml.parse(examples[3].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { + input: input, + }); + await action.handler(ctx); + expect(notificationService.send).toHaveBeenCalledWith({ + recipients: { type: 'broadcast' }, + payload: { + description: 'Version 2.0.0 is now available. Upgrade now!', + link: undefined, + severity: 'normal', + scope: 'public', + title: 'New Release Available', + }, + }); + }); + + it(`should ${examples[4].description}`, async () => { + const input = yaml.parse(examples[4].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { + input: input, + }); + await action.handler(ctx); + expect(notificationService.send).toHaveBeenCalledWith({ + recipients: { + type: 'entity', + entityRef: ['entity:component:1'], + }, + payload: { + description: + 'A critical bug has been identified. Immediate action required.', + link: undefined, + scope: 'internal', + severity: 'critical', + title: 'Critical Bug Found', + }, + }); + }); + + it(`should ${examples[5].description}`, async () => { + const input = yaml.parse(examples[5].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { + input: input, + }); + await action.handler(ctx); + expect(notificationService.send).toHaveBeenCalledWith({ + recipients: { + type: 'broadcast', + }, + payload: { + description: 'Server maintenance will occur tonight at 11 PM.', + link: undefined, + scope: 'internal', + severity: 'normal', + title: 'Server Maintenance Scheduled', + }, + }); + }); + + it(`should ${examples[6].description}`, async () => { + const input = yaml.parse(examples[6].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { + input: input, + }); + await action.handler(ctx); + expect(notificationService.send).toHaveBeenCalledWith({ + recipients: { type: 'broadcast' }, + payload: { + description: 'New features have been deployed. Explore them now!', + link: undefined, + scope: undefined, + severity: 'normal', + title: 'New Feature Deployment', + }, + }); + }); + + it(`should ${examples[7].description}`, async () => { + const input = yaml.parse(examples[7].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { + input: input, + }); + await action.handler(ctx); + expect(notificationService.send).toHaveBeenCalledWith({ + recipients: { type: 'broadcast' }, + payload: { + description: undefined, + link: undefined, + scope: 'internal', + severity: 'low', + title: 'Holiday Office Closure', + }, + }); + }); + + it(`should ${examples[8].description}`, async () => { + const input = yaml.parse(examples[8].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { + input: input, + }); + await action.handler(ctx); + expect(notificationService.send).toHaveBeenCalledWith({ + recipients: { type: 'broadcast' }, + payload: { + description: + "Don't forget, the weekly meeting is scheduled for tomorrow.", + link: undefined, + scope: 'internal', + severity: undefined, + title: 'Reminder: Weekly Meeting Tomorrow', + }, + }); + }); + + it(`should ${examples[9].description}`, async () => { + const input = yaml.parse(examples[9].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { + input: input, + }); + await action.handler(ctx); + expect(notificationService.send).toHaveBeenCalledWith({ + recipients: { type: 'broadcast' }, + payload: { + title: 'Important Announcement', + description: + 'Please read the latest announcement regarding the upcoming changes.', + link: undefined, + scope: undefined, + severity: 'high', + }, + }); + }); + + it(`should ${examples[10].description}`, async () => { + const input = yaml.parse(examples[10].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { + input: input, + }); + await action.handler(ctx); + expect(notificationService.send).toHaveBeenCalledWith({ + recipients: { type: 'broadcast' }, + payload: { + title: 'Broadcast Notification', + description: 'This is a broadcast notification', + link: undefined, + scope: undefined, + severity: 'low', + }, + }); + }); + + it(`should ${examples[11].description}`, async () => { + const input = yaml.parse(examples[11].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { + input: input, + }); + await action.handler(ctx); + expect(notificationService.send).toHaveBeenCalledWith({ + recipients: { + type: 'entity', + entityRef: ['entity:service1'], + }, + payload: { + title: 'Entity Notification', + description: 'This is a notification for entity service1', + link: undefined, + scope: undefined, + severity: 'normal', + }, + }); + }); +}); diff --git a/plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.examples.ts b/plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.examples.ts new file mode 100644 index 0000000000..05bbd368f8 --- /dev/null +++ b/plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.examples.ts @@ -0,0 +1,255 @@ +/* + * Copyright 2023 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 { TemplateExample } from '@backstage/plugin-scaffolder-node'; +import yaml from 'yaml'; + +export const examples: TemplateExample[] = [ + { + description: 'Sends a notification with minimal options', + example: yaml.stringify({ + steps: [ + { + id: 'sendNotification', + action: 'notification:send', + name: 'Send Notification', + input: { + recipients: 'broadcast', + title: 'Test notification', + }, + }, + ], + }), + }, + { + description: 'Sends a notification with entity recipients and link', + example: yaml.stringify({ + steps: [ + { + id: 'sendNotification', + action: 'notification:send', + name: 'Send Notification', + input: { + recipients: 'entity', + entityRefs: ['entity:component:1'], + title: 'Security Update', + info: 'A security update has been applied. Please review.', + link: 'https://example.com/security/update', + severity: 'high', + scope: 'internal', + }, + }, + ], + }), + }, + { + description: + 'Sends a notification with entity recipients and optional flag', + example: yaml.stringify({ + steps: [ + { + id: 'sendNotification', + action: 'notification:send', + name: 'Send Notification', + input: { + recipients: 'entity', + entityRefs: ['entity:component:1'], + title: 'Weekly Update', + info: 'Here is your weekly update.', + severity: 'low', + optional: true, + }, + }, + ], + }), + }, + { + description: + 'Sends a notification with broadcast recipients and custom scope', + example: yaml.stringify({ + steps: [ + { + id: 'sendNotification', + action: 'notification:send', + name: 'Send Notification', + input: { + recipients: 'broadcast', + title: 'New Release Available', + info: 'Version 2.0.0 is now available. Upgrade now!', + severity: 'normal', + scope: 'public', + }, + }, + ], + }), + }, + { + description: + 'Sends a notification with entity recipients and custom severity', + example: yaml.stringify({ + steps: [ + { + id: 'sendNotification', + action: 'notification:send', + name: 'Send Notification', + input: { + recipients: 'entity', + entityRefs: ['entity:component:1'], + title: 'Critical Bug Found', + info: 'A critical bug has been identified. Immediate action required.', + severity: 'critical', + scope: 'internal', + }, + }, + ], + }), + }, + { + description: 'Sends a notification with broadcast recipients and no link', + example: yaml.stringify({ + steps: [ + { + id: 'sendNotification', + action: 'notification:send', + name: 'Send Notification', + input: { + recipients: 'broadcast', + title: 'Server Maintenance Scheduled', + info: 'Server maintenance will occur tonight at 11 PM.', + severity: 'normal', + scope: 'internal', + }, + }, + ], + }), + }, + { + description: + 'Sends a notification with broadcast recipients and optional flag', + example: yaml.stringify({ + steps: [ + { + id: 'sendNotification', + action: 'notification:send', + name: 'Send Notification', + input: { + recipients: 'broadcast', + title: 'New Feature Deployment', + info: 'New features have been deployed. Explore them now!', + severity: 'normal', + optional: true, + }, + }, + ], + }), + }, + { + description: + 'Sends a notification with broadcast recipients and no description', + example: yaml.stringify({ + steps: [ + { + id: 'sendNotification', + action: 'notification:send', + name: 'Send Notification', + input: { + recipients: 'broadcast', + title: 'Holiday Office Closure', + severity: 'low', + scope: 'internal', + }, + }, + ], + }), + }, + { + description: + 'Sends a notification with broadcast recipients and no severity', + example: yaml.stringify({ + steps: [ + { + id: 'sendNotification', + action: 'notification:send', + name: 'Send Notification', + input: { + recipients: 'broadcast', + title: 'Reminder: Weekly Meeting Tomorrow', + info: "Don't forget, the weekly meeting is scheduled for tomorrow.", + scope: 'internal', + }, + }, + ], + }), + }, + { + description: 'Sends a notification with broadcast recipients and no scope', + example: yaml.stringify({ + steps: [ + { + id: 'sendNotification', + action: 'notification:send', + name: 'Send Notification', + input: { + recipients: 'broadcast', + title: 'Important Announcement', + info: 'Please read the latest announcement regarding the upcoming changes.', + severity: 'high', + }, + }, + ], + }), + }, + { + description: 'Sends a notification with optional parameters', + example: yaml.stringify({ + steps: [ + { + id: 'sendNotification', + action: 'notification:send', + name: 'Send Notification', + input: { + recipients: 'broadcast', + title: 'Broadcast Notification', + info: 'This is a broadcast notification', + severity: 'low', + optional: true, + }, + }, + ], + }), + }, + { + description: + 'Sends a notification with entity recipients and optional set to false', + example: yaml.stringify({ + steps: [ + { + id: 'sendNotification', + action: 'notification:send', + name: 'Send Notification to Entity', + input: { + recipients: 'entity', + entityRefs: ['entity:service1'], + title: 'Entity Notification', + info: 'This is a notification for entity service1', + severity: 'normal', + optional: false, + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.ts b/plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.ts index f50e178174..9646de0001 100644 --- a/plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.ts +++ b/plugins/scaffolder-backend-module-notifications/src/actions/sendNotification.ts @@ -22,6 +22,7 @@ import { NotificationSeverity, } from '@backstage/plugin-notifications-common'; import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; +import { examples } from './sendNotification.examples'; /** * @public @@ -42,6 +43,7 @@ export function createSendNotificationAction(options: { }>({ id: 'notification:send', description: 'Sends a notification using NotificationService', + examples, schema: { input: { type: 'object', From 449def7c2c3445f234106becf9e8b74291758ddf Mon Sep 17 00:00:00 2001 From: parmar-abhinav Date: Sat, 13 Jul 2024 17:30:14 +0530 Subject: [PATCH 041/225] Add examples for fetch:rails scaffolder action & improve related tests Signed-off-by: parmar-abhinav --- .changeset/friendly-feet-refuse.md | 5 + .../package.json | 3 +- .../fetch/rails/index.examples.test.ts | 292 ++++++++++++++ .../src/actions/fetch/rails/index.examples.ts | 370 ++++++++++++++++++ .../src/actions/fetch/rails/index.ts | 2 + 5 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 .changeset/friendly-feet-refuse.md create mode 100644 plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.examples.test.ts create mode 100644 plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.examples.ts diff --git a/.changeset/friendly-feet-refuse.md b/.changeset/friendly-feet-refuse.md new file mode 100644 index 0000000000..677d21d48a --- /dev/null +++ b/.changeset/friendly-feet-refuse.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-rails': patch +--- + +Add examples for fetch:rails scaffolder action & improve related tests diff --git a/plugins/scaffolder-backend-module-rails/package.json b/plugins/scaffolder-backend-module-rails/package.json index c15815d2fa..fb917f4e25 100644 --- a/plugins/scaffolder-backend-module-rails/package.json +++ b/plugins/scaffolder-backend-module-rails/package.json @@ -51,7 +51,8 @@ "@backstage/plugin-scaffolder-node": "workspace:^", "@backstage/types": "workspace:^", "command-exists": "^1.2.9", - "fs-extra": "^11.0.0" + "fs-extra": "^11.0.0", + "yaml": "^2.0.0" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.examples.test.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.examples.test.ts new file mode 100644 index 0000000000..d35cb3f319 --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.examples.test.ts @@ -0,0 +1,292 @@ +/* + * Copyright 2021 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. + */ + +const mockRailsTemplater = { run: jest.fn() }; +jest.mock('@backstage/plugin-scaffolder-node', () => ({ + ...jest.requireActual('@backstage/plugin-scaffolder-node'), + fetchContents: jest.fn(), +})); +jest.mock('./railsNewRunner', () => { + return { + RailsNewRunner: jest.fn().mockImplementation(() => { + return mockRailsTemplater; + }), + }; +}); + +import { ContainerRunner, UrlReader } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { ScmIntegrations } from '@backstage/integration'; +import { resolve as resolvePath } from 'path'; +import { createFetchRailsAction } from './index'; +import { fetchContents } from '@backstage/plugin-scaffolder-node'; +import { createMockDirectory } from '@backstage/backend-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import { Writable } from 'stream'; +import { examples } from './index.examples'; +import yaml from 'yaml'; + +describe('fetch:rails', () => { + const mockDir = createMockDirectory(); + const integrations = ScmIntegrations.fromConfig( + new ConfigReader({ + integrations: { + azure: [ + { host: 'dev.azure.com', token: 'tokenlols' }, + { host: 'myazurehostnotoken.com' }, + ], + }, + }), + ); + + const mockContext = createMockActionContext({ + input: { + url: 'https://rubyonrails.org/generator', + targetPath: 'something', + values: { + help: 'me', + }, + }, + templateInfo: { + baseUrl: 'somebase', + entityRef: 'template:default/myTemplate', + }, + workspacePath: mockDir.path, + }); + + const mockReader: UrlReader = { + readUrl: jest.fn(), + readTree: jest.fn(), + search: jest.fn(), + }; + const containerRunner: ContainerRunner = { + runContainer: jest.fn(), + }; + + const action = createFetchRailsAction({ + integrations, + reader: mockReader, + containerRunner, + allowedImageNames: ['foo/rails-custom-image'], + }); + + beforeEach(() => { + mockDir.setContent({ + result: '{}', + }); + jest.clearAllMocks(); + }); + + it(`should ${examples[0].description}`, async () => { + const input = yaml.parse(examples[0].example).steps[0].input; + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(fetchContents).toHaveBeenCalledWith({ + reader: mockReader, + integrations, + baseUrl: mockContext.templateInfo?.baseUrl, + fetchUrl: input.url, + outputPath: resolvePath(mockContext.workspacePath), + }); + }); + + it(`should ${examples[1].description}`, async () => { + const input = yaml.parse(examples[1].example).steps[0].input; + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(fetchContents).toHaveBeenCalledWith({ + reader: mockReader, + integrations, + baseUrl: mockContext.templateInfo?.baseUrl, + fetchUrl: input.url, + outputPath: resolvePath(mockContext.workspacePath), + }); + }); + + it(`should ${examples[2].description}`, async () => { + const input = yaml.parse(examples[2].example).steps[0].input; + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(fetchContents).toHaveBeenCalledWith({ + reader: mockReader, + integrations, + baseUrl: mockContext.templateInfo?.baseUrl, + fetchUrl: input.url, + outputPath: resolvePath(mockContext.workspacePath), + }); + }); + + it(`should ${examples[3].description}`, async () => { + const input = yaml.parse(examples[3].example).steps[0].input; + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(fetchContents).toHaveBeenCalledWith({ + reader: mockReader, + integrations, + baseUrl: mockContext.templateInfo?.baseUrl, + fetchUrl: input.url, + outputPath: resolvePath(mockContext.workspacePath), + }); + }); + + it(`should ${examples[4].description}`, async () => { + const input = yaml.parse(examples[4].example).steps[0].input; + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(fetchContents).toHaveBeenCalledWith({ + reader: mockReader, + integrations, + baseUrl: mockContext.templateInfo?.baseUrl, + fetchUrl: input.url, + outputPath: resolvePath(mockContext.workspacePath), + }); + }); + + it(`should ${examples[5].description}`, async () => { + const input = yaml.parse(examples[5].example).steps[0].input; + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(fetchContents).toHaveBeenCalledWith({ + reader: mockReader, + integrations, + baseUrl: mockContext.templateInfo?.baseUrl, + fetchUrl: input.url, + outputPath: resolvePath(mockContext.workspacePath), + }); + }); + + it(`should ${examples[6].description}`, async () => { + const input = yaml.parse(examples[6].example).steps[0].input; + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(fetchContents).toHaveBeenCalledWith({ + reader: mockReader, + integrations, + baseUrl: mockContext.templateInfo?.baseUrl, + fetchUrl: input.url, + outputPath: resolvePath(mockContext.workspacePath), + }); + }); + + it(`should ${examples[7].description}`, async () => { + const input = yaml.parse(examples[7].example).steps[0].input; + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(fetchContents).toHaveBeenCalledWith({ + reader: mockReader, + integrations, + baseUrl: mockContext.templateInfo?.baseUrl, + fetchUrl: input.url, + outputPath: resolvePath(mockContext.workspacePath), + }); + }); + + it(`should ${examples[8].description}`, async () => { + const input = yaml.parse(examples[8].example).steps[0].input; + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(fetchContents).toHaveBeenCalledWith({ + reader: mockReader, + integrations, + baseUrl: mockContext.templateInfo?.baseUrl, + fetchUrl: input.url, + outputPath: resolvePath(mockContext.workspacePath), + }); + }); + + it(`should ${examples[9].description}`, async () => { + const input = yaml.parse(examples[9].example).steps[0].input; + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(fetchContents).toHaveBeenCalledWith({ + reader: mockReader, + integrations, + baseUrl: mockContext.templateInfo?.baseUrl, + fetchUrl: input.url, + outputPath: resolvePath(mockContext.workspacePath), + }); + }); +}); diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.examples.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.examples.ts new file mode 100644 index 0000000000..4c77eb3c1b --- /dev/null +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.examples.ts @@ -0,0 +1,370 @@ +/* + * Copyright 2021 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 { TemplateExample } from '@backstage/plugin-scaffolder-node'; +import yaml from 'yaml'; + +export const examples: TemplateExample[] = [ + { + description: 'Fetch and template a minimal Rails app', + example: yaml.stringify({ + steps: [ + { + id: 'fetchTemplate', + action: 'fetch:rails', + name: 'Fetch and Template Rails App', + input: { + url: 'https://github.com/backstage/backstage', + values: { + railsArguments: { + minimal: true, + skipBundle: true, + skipWebpackInstall: true, + skipTest: true, + skipActionCable: true, + skipActionMailer: true, + skipActionMailbox: true, + skipActiveStorage: true, + skipActionText: true, + skipActiveRecord: true, + force: true, + api: true, + webpacker: 'react', + database: 'postgresql', + railsVersion: 'dev', + }, + }, + }, + }, + ], + }), + }, + { + description: 'Fetch and template a Rails app with custom options', + example: yaml.stringify({ + steps: [ + { + id: 'fetchTemplate', + action: 'fetch:rails', + name: 'Fetch and Template Rails App', + input: { + url: 'https://github.com/backstage/backstage', + targetPath: 'custom-path', + values: { + railsArguments: { + minimal: false, + skipBundle: false, + skipWebpackInstall: false, + skipTest: false, + skipActionCable: false, + skipActionMailer: false, + skipActionMailbox: false, + skipActiveStorage: false, + skipActionText: false, + skipActiveRecord: false, + force: false, + api: false, + webpacker: 'vue', + database: 'mysql', + railsVersion: 'edge', + }, + }, + }, + }, + ], + }), + }, + { + description: 'Fetch and template a Rails app with Docker image', + example: yaml.stringify({ + steps: [ + { + id: 'fetchTemplate', + action: 'fetch:rails', + name: 'Fetch and Template Rails App', + input: { + url: 'https://github.com/backstage/backstage', + values: { + railsArguments: { + minimal: true, + skipBundle: true, + skipWebpackInstall: true, + skipTest: true, + skipActionCable: true, + skipActionMailer: true, + skipActionMailbox: true, + skipActiveStorage: true, + skipActionText: true, + skipActiveRecord: true, + force: true, + api: true, + webpacker: 'react', + database: 'postgresql', + railsVersion: 'dev', + }, + imageName: 'custom/rails-image', + }, + }, + }, + ], + }), + }, + { + description: + 'Fetch and template a Rails app with different database and action mailer settings', + example: yaml.stringify({ + steps: [ + { + id: 'fetchTemplate', + action: 'fetch:rails', + name: 'Fetch and Template Rails App', + input: { + url: 'https://github.com/backstage/backstage', + values: { + railsArguments: { + minimal: false, + skipBundle: false, + skipWebpackInstall: false, + skipTest: false, + skipActionCable: false, + skipActionMailer: false, + skipActionMailbox: true, + skipActiveStorage: false, + skipActionText: false, + skipActiveRecord: false, + force: true, + api: true, + webpacker: 'angular', + database: 'sqlite3', + railsVersion: 'master', + }, + }, + }, + }, + ], + }), + }, + { + description: + 'Fetch and template a Rails app with full options including action mailer and active storage', + example: yaml.stringify({ + steps: [ + { + id: 'fetchTemplate', + action: 'fetch:rails', + name: 'Fetch and Template Rails App', + input: { + url: 'https://github.com/backstage/backstage', + values: { + railsArguments: { + minimal: false, + skipBundle: false, + skipWebpackInstall: false, + skipTest: false, + skipActionCable: false, + skipActionMailer: false, + skipActionMailbox: false, + skipActiveStorage: false, + skipActionText: false, + skipActiveRecord: false, + force: false, + api: false, + webpacker: 'stimulus', + database: 'postgresql', + railsVersion: 'fromImage', + }, + }, + }, + }, + ], + }), + }, + { + description: + 'Fetch and template a Rails app with API-only stack and custom template path', + example: yaml.stringify({ + steps: [ + { + id: 'fetchTemplate', + action: 'fetch:rails', + name: 'Fetch and Template Rails App', + input: { + url: 'https://github.com/backstage/backstage', + values: { + railsArguments: { + minimal: false, + skipBundle: true, + skipWebpackInstall: true, + skipTest: true, + skipActionCable: true, + skipActionMailer: true, + skipActionMailbox: true, + skipActiveStorage: true, + skipActionText: true, + skipActiveRecord: true, + force: true, + api: true, + webpacker: 'elm', + database: 'mysql', + railsVersion: 'dev', + template: 'path/to/custom/template', + }, + }, + }, + }, + ], + }), + }, + { + description: 'Fetch and template a Rails app with custom Webpack setup', + example: yaml.stringify({ + steps: [ + { + id: 'fetchTemplate', + action: 'fetch:rails', + name: 'Fetch and Template Rails App', + input: { + url: 'https://github.com/backstage/backstage', + values: { + railsArguments: { + minimal: false, + skipBundle: false, + skipWebpackInstall: false, + skipTest: false, + skipActionCable: false, + skipActionMailer: false, + skipActionMailbox: false, + skipActiveStorage: false, + skipActionText: false, + skipActiveRecord: false, + force: false, + api: false, + webpacker: 'angular', + database: 'postgresql', + railsVersion: 'dev', + }, + }, + }, + }, + ], + }), + }, + { + description: + 'Fetch and template a Rails app with specific Rails version and Action Mailer enabled', + example: yaml.stringify({ + steps: [ + { + id: 'fetchTemplate', + action: 'fetch:rails', + name: 'Fetch and Template Rails App', + input: { + url: 'https://github.com/backstage/backstage', + values: { + railsArguments: { + minimal: false, + skipBundle: false, + skipWebpackInstall: false, + skipTest: false, + skipActionCable: false, + skipActionMailer: false, + skipActionMailbox: false, + skipActiveStorage: false, + skipActionText: false, + skipActiveRecord: false, + force: true, + api: true, + webpacker: 'react', + database: 'sqlite3', + railsVersion: 'master', + }, + }, + }, + }, + ], + }), + }, + { + description: + 'Fetch and template a Rails app with specific database and Active Storage enabled', + example: yaml.stringify({ + steps: [ + { + id: 'fetchTemplate', + action: 'fetch:rails', + name: 'Fetch and Template Rails App', + input: { + url: 'https://github.com/backstage/backstage', + values: { + railsArguments: { + minimal: false, + skipBundle: false, + skipWebpackInstall: false, + skipTest: false, + skipActionCable: false, + skipActionMailer: false, + skipActionMailbox: false, + skipActiveStorage: false, + skipActionText: false, + skipActiveRecord: false, + force: true, + api: true, + webpacker: 'vue', + database: 'postgresql', + railsVersion: 'fromImage', + }, + }, + }, + }, + ], + }), + }, + { + description: + 'Fetch and template a Rails app with full options including database and skip options', + example: yaml.stringify({ + steps: [ + { + id: 'fetchTemplate', + action: 'fetch:rails', + name: 'Fetch and Template Rails App', + input: { + url: 'https://github.com/backstage/backstage', + values: { + railsArguments: { + minimal: false, + skipBundle: true, + skipWebpackInstall: true, + skipTest: true, + skipActionCable: true, + skipActionMailer: true, + skipActionMailbox: true, + skipActiveStorage: true, + skipActionText: true, + skipActiveRecord: true, + force: false, + api: false, + webpacker: 'stimulus', + database: 'sqlite3', + railsVersion: 'dev', + }, + }, + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts index 394d0fb02c..94a2a3539b 100644 --- a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts @@ -27,6 +27,7 @@ import { import { resolve as resolvePath } from 'path'; import { RailsNewRunner } from './railsNewRunner'; import { PassThrough } from 'stream'; +import { examples } from './index.examples'; /** * Creates the `fetch:rails` Scaffolder action. @@ -56,6 +57,7 @@ export function createFetchRailsAction(options: { id: 'fetch:rails', description: 'Downloads a template from the given `url` into the workspace, and runs a rails new generator on it.', + examples, schema: { input: { type: 'object', From b9e73a0e5a7545bb659f626b02fba9a6d82e7c3e Mon Sep 17 00:00:00 2001 From: parmar-abhinav Date: Sun, 14 Jul 2024 10:45:49 +0530 Subject: [PATCH 042/225] chore: run yarn install to update dependencies Signed-off-by: parmar-abhinav --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index b0bff82017..574792b052 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7055,6 +7055,7 @@ __metadata: command-exists: ^1.2.9 fs-extra: ^11.0.0 jest-when: ^3.1.0 + yaml: ^2.0.0 languageName: unknown linkType: soft From b687beb2dcc16eddc89b85675b425fec7cc02c51 Mon Sep 17 00:00:00 2001 From: parmar-abhinav Date: Sun, 14 Jul 2024 10:52:16 +0530 Subject: [PATCH 043/225] chore: run yarn install to update dependencies Signed-off-by: parmar-abhinav --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index b0bff82017..18f51570ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7032,6 +7032,7 @@ __metadata: "@backstage/plugin-scaffolder-node": "workspace:^" "@backstage/plugin-scaffolder-node-test-utils": "workspace:^" octokit: ^3.0.0 + yaml: ^2.0.0 languageName: unknown linkType: soft From b084597b3299049b92c9cbc3e76c704ec8f41a7e Mon Sep 17 00:00:00 2001 From: parmar-abhinav Date: Sun, 14 Jul 2024 10:55:19 +0530 Subject: [PATCH 044/225] chore: run yarn install to update dependencies Signed-off-by: parmar-abhinav --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index b0bff82017..4dcfba4743 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6917,6 +6917,7 @@ __metadata: command-exists: ^1.2.9 fs-extra: ^11.2.0 winston: ^3.2.1 + yaml: ^2.0.0 yn: ^4.0.0 languageName: unknown linkType: soft From 065353f144c00589e5f4cbb427738ccc6f74deb4 Mon Sep 17 00:00:00 2001 From: parmar-abhinav Date: Sun, 14 Jul 2024 10:58:53 +0530 Subject: [PATCH 045/225] chore: removed unused imports Signed-off-by: parmar-abhinav --- .../src/actions/fetch/rails/index.examples.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.examples.test.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.examples.test.ts index d35cb3f319..a80985321e 100644 --- a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.examples.test.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.examples.test.ts @@ -35,7 +35,6 @@ import { createFetchRailsAction } from './index'; import { fetchContents } from '@backstage/plugin-scaffolder-node'; import { createMockDirectory } from '@backstage/backend-test-utils'; import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; -import { Writable } from 'stream'; import { examples } from './index.examples'; import yaml from 'yaml'; From 0ab868dfd1510b6ef85d1967db2ac3688de496bd Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Sun, 14 Jul 2024 04:00:51 -0400 Subject: [PATCH 046/225] 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 c0a705d44298c5274f1d94b230d642a86cada2c9 Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Fri, 14 Jun 2024 10:49:04 -0500 Subject: [PATCH 047/225] Added the Kubernetes plugin to `create-app` Signed-off-by: Andre Wanlin --- .changeset/blue-pumas-cheer.md | 5 +++++ packages/create-app/src/lib/versions.ts | 4 ++++ .../templates/default-app/app-config.yaml.hbs | 3 +++ .../default-app/packages/app/package.json.hbs | 1 + .../app/src/components/catalog/EntityPage.tsx | 21 +++++++++++++++++++ .../packages/backend/package.json.hbs | 1 + .../default-app/packages/backend/src/index.ts | 3 +++ 7 files changed, 38 insertions(+) create mode 100644 .changeset/blue-pumas-cheer.md diff --git a/.changeset/blue-pumas-cheer.md b/.changeset/blue-pumas-cheer.md new file mode 100644 index 0000000000..f6cbf180ff --- /dev/null +++ b/.changeset/blue-pumas-cheer.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Added the Kubernetes plugin to `create-app` diff --git a/packages/create-app/src/lib/versions.ts b/packages/create-app/src/lib/versions.ts index 25fd7b7c3c..194ffa21d9 100644 --- a/packages/create-app/src/lib/versions.ts +++ b/packages/create-app/src/lib/versions.ts @@ -62,6 +62,8 @@ import { version as pluginCatalogBackendModuleLogs } from '../../../../plugins/c import { version as pluginCatalogBackendModuleScaffolderEntityModel } from '../../../../plugins/catalog-backend-module-scaffolder-entity-model/package.json'; import { version as pluginCatalogGraph } from '../../../../plugins/catalog-graph/package.json'; import { version as pluginCatalogImport } from '../../../../plugins/catalog-import/package.json'; +import { version as pluginKubernetes } from '../../../../plugins/kubernetes/package.json'; +import { version as pluginKubernetesBackend } from '../../../../plugins/kubernetes-backend/package.json'; import { version as pluginOrg } from '../../../../plugins/org/package.json'; import { version as pluginPermissionBackend } from '../../../../plugins/permission-backend/package.json'; import { version as pluginPermissionBackendModulePolicyAllowAll } from '../../../../plugins/permission-backend-module-policy-allow-all/package.json'; @@ -118,6 +120,8 @@ export const packageVersions = { pluginCatalogBackendModuleScaffolderEntityModel, '@backstage/plugin-catalog-graph': pluginCatalogGraph, '@backstage/plugin-catalog-import': pluginCatalogImport, + '@backstage/plugin-kubernetes': pluginKubernetes, + '@backstage/plugin-kubernetes-backend': pluginKubernetesBackend, '@backstage/plugin-org': pluginOrg, '@backstage/plugin-permission-backend': pluginPermissionBackend, '@backstage/plugin-permission-backend-module-allow-all-policy': diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index 248cff137a..a4797d4c41 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -103,3 +103,6 @@ catalog: # target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme-corp.yaml # rules: # - allow: [User, Group] + +kubernetes: + # see https://backstage.io/docs/features/kubernetes/configuration for kubernetes configuration options \ No newline at end of file diff --git a/packages/create-app/templates/default-app/packages/app/package.json.hbs b/packages/create-app/templates/default-app/packages/app/package.json.hbs index d4db9e5885..08a1d58362 100644 --- a/packages/create-app/templates/default-app/packages/app/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/app/package.json.hbs @@ -27,6 +27,7 @@ "@backstage/plugin-catalog-graph": "^{{version '@backstage/plugin-catalog-graph'}}", "@backstage/plugin-catalog-import": "^{{version '@backstage/plugin-catalog-import'}}", "@backstage/plugin-catalog-react": "^{{version '@backstage/plugin-catalog-react'}}", + "@backstage/plugin-kubernetes": "^{{version '@backstage/plugin-kubernetes'}}", "@backstage/plugin-org": "^{{version '@backstage/plugin-org'}}", "@backstage/plugin-permission-react": "^{{version '@backstage/plugin-permission-react'}}", "@backstage/plugin-scaffolder": "^{{version '@backstage/plugin-scaffolder'}}", diff --git a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx index 53b6685c3f..6164c76004 100644 --- a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx @@ -54,6 +54,11 @@ import { import { TechDocsAddons } from '@backstage/plugin-techdocs-react'; import { ReportIssue } from '@backstage/plugin-techdocs-module-addons-contrib'; +import { + EntityKubernetesContent, + isKubernetesAvailable, +} from '@backstage/plugin-kubernetes'; + const techdocsContent = ( @@ -150,6 +155,14 @@ const serviceEntityPage = ( {cicdContent} + + + + @@ -188,6 +201,14 @@ const websiteEntityPage = ( {cicdContent} + + + + diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index e26c788496..8aad8a4b5d 100644 --- a/packages/create-app/templates/default-app/packages/backend/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/backend/package.json.hbs @@ -28,6 +28,7 @@ "@backstage/plugin-catalog-backend": "^{{version '@backstage/plugin-catalog-backend'}}", "@backstage/plugin-catalog-backend-module-logs": "^{{version '@backstage/plugin-catalog-backend-module-logs'}}", "@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "^{{version '@backstage/plugin-catalog-backend-module-scaffolder-entity-model'}}", + "@backstage/plugin-kubernetes-backend": "^{{version '@backstage/plugin-kubernetes-backend'}}", "@backstage/plugin-permission-backend": "^{{version '@backstage/plugin-permission-backend'}}", "@backstage/plugin-permission-backend-module-allow-all-policy": "^{{version '@backstage/plugin-permission-backend-module-allow-all-policy'}}", "@backstage/plugin-permission-common": "^{{version '@backstage/plugin-permission-common'}}", diff --git a/packages/create-app/templates/default-app/packages/backend/src/index.ts b/packages/create-app/templates/default-app/packages/backend/src/index.ts index 239269288e..983202caa6 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/index.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/index.ts @@ -41,4 +41,7 @@ backend.add(import('@backstage/plugin-search-backend/alpha')); backend.add(import('@backstage/plugin-search-backend-module-catalog/alpha')); backend.add(import('@backstage/plugin-search-backend-module-techdocs/alpha')); +// kubernetes +backend.add(import('@backstage/plugin-kubernetes-backend/alpha')); + backend.start(); From dba4a14ee9b4ec8e2b99f94e9271cc9c26c951b8 Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Fri, 14 Jun 2024 11:17:30 -0500 Subject: [PATCH 048/225] Added new line Signed-off-by: Andre Wanlin --- packages/create-app/templates/default-app/app-config.yaml.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index a4797d4c41..f784d5ba88 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -105,4 +105,4 @@ catalog: # - allow: [User, Group] kubernetes: - # see https://backstage.io/docs/features/kubernetes/configuration for kubernetes configuration options \ No newline at end of file + # see https://backstage.io/docs/features/kubernetes/configuration for kubernetes configuration options From f26905d7a88559eb5ca5472a3842afe745e15059 Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Fri, 14 Jun 2024 11:48:09 -0500 Subject: [PATCH 049/225] Fixed test Signed-off-by: Andre Wanlin --- packages/create-app/src/lib/tasks.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/create-app/src/lib/tasks.test.ts b/packages/create-app/src/lib/tasks.test.ts index 1f507b95ed..7b17307721 100644 --- a/packages/create-app/src/lib/tasks.test.ts +++ b/packages/create-app/src/lib/tasks.test.ts @@ -88,6 +88,8 @@ jest.mock('./versions', () => ({ '@backstage/plugin-catalog-graph': '1.0.0', '@backstage/plugin-catalog-import': '1.0.0', '@backstage/plugin-catalog-react': '1.0.0', + '@backstage/plugin-kubernetes': '1.0.0', + '@backstage/plugin-kubernetes-backend': '1.0.0', '@backstage/plugin-org': '1.0.0', '@backstage/plugin-scaffolder': '1.0.0', '@backstage/plugin-permission-react': '1.0.0', From fe6ab86d9207cd1c2586aa016efdfbc007915ade Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Sun, 14 Jul 2024 15:24:38 -0400 Subject: [PATCH 050/225] 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 051/225] 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 e6e293ca30256d8d7641f5c9ff27e29b76f149de Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Mon, 15 Jul 2024 10:22:21 +0200 Subject: [PATCH 052/225] chore: extract BaseRepoUrlPickerProps Signed-off-by: Benjamin Janssens --- .../fields/RepoUrlPicker/AzureRepoPicker.tsx | 15 +++++++-------- .../RepoUrlPicker/BitbucketRepoPicker.tsx | 17 ++++++++--------- .../fields/RepoUrlPicker/GerritRepoPicker.tsx | 8 ++------ .../fields/RepoUrlPicker/GiteaRepoPicker.tsx | 15 +++++++-------- .../fields/RepoUrlPicker/GithubRepoPicker.tsx | 13 ++++++------- .../fields/RepoUrlPicker/GitlabRepoPicker.tsx | 15 +++++++-------- .../components/fields/RepoUrlPicker/types.ts | 6 ++++++ 7 files changed, 43 insertions(+), 46 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/AzureRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/AzureRepoPicker.tsx index 833a0849fb..48fcb4f6af 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/AzureRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/AzureRepoPicker.tsx @@ -19,16 +19,15 @@ import FormControl from '@material-ui/core/FormControl'; import FormHelperText from '@material-ui/core/FormHelperText'; import Input from '@material-ui/core/Input'; import InputLabel from '@material-ui/core/InputLabel'; -import { RepoUrlPickerState } from './types'; +import { BaseRepoUrlPickerProps } from './types'; import { Select, SelectItem } from '@backstage/core-components'; -export const AzureRepoPicker = (props: { - allowedOrganizations?: string[]; - allowedProject?: string[]; - rawErrors: string[]; - state: RepoUrlPickerState; - onChange: (state: RepoUrlPickerState) => void; -}) => { +export const AzureRepoPicker = ( + props: BaseRepoUrlPickerProps<{ + allowedOrganizations?: string[]; + allowedProject?: string[]; + }>, +) => { const { allowedOrganizations = [], allowedProject = [], diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx index 10d4a6e6c3..5d627e9512 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx @@ -17,7 +17,7 @@ import React, { useEffect, useState } from 'react'; import FormControl from '@material-ui/core/FormControl'; import FormHelperText from '@material-ui/core/FormHelperText'; import { Select, SelectItem } from '@backstage/core-components'; -import { RepoUrlPickerState } from './types'; +import { BaseRepoUrlPickerProps } from './types'; import Autocomplete from '@material-ui/lab/Autocomplete'; import TextField from '@material-ui/core/TextField'; import useDebounce from 'react-use/esm/useDebounce'; @@ -33,14 +33,13 @@ import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; * @param allowedProjects - Allowed projects for the Bitbucket cloud repository * */ -export const BitbucketRepoPicker = (props: { - allowedOwners?: string[]; - allowedProjects?: string[]; - onChange: (state: RepoUrlPickerState) => void; - state: RepoUrlPickerState; - rawErrors: string[]; - accessToken?: string; -}) => { +export const BitbucketRepoPicker = ( + props: BaseRepoUrlPickerProps<{ + allowedOwners?: string[]; + allowedProjects?: string[]; + accessToken?: string; + }>, +) => { const { allowedOwners = [], allowedProjects = [], diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GerritRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GerritRepoPicker.tsx index 13af966c25..1b13c5eb29 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GerritRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GerritRepoPicker.tsx @@ -18,13 +18,9 @@ import FormControl from '@material-ui/core/FormControl'; import FormHelperText from '@material-ui/core/FormHelperText'; import Input from '@material-ui/core/Input'; import InputLabel from '@material-ui/core/InputLabel'; -import { RepoUrlPickerState } from './types'; +import { BaseRepoUrlPickerProps } from './types'; -export const GerritRepoPicker = (props: { - onChange: (state: RepoUrlPickerState) => void; - state: RepoUrlPickerState; - rawErrors: string[]; -}) => { +export const GerritRepoPicker = (props: BaseRepoUrlPickerProps) => { const { onChange, rawErrors, state } = props; const { workspace, owner } = state; return ( diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GiteaRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GiteaRepoPicker.tsx index 566a2732e3..9fb3af211f 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GiteaRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GiteaRepoPicker.tsx @@ -19,15 +19,14 @@ import FormHelperText from '@material-ui/core/FormHelperText'; import Input from '@material-ui/core/Input'; import InputLabel from '@material-ui/core/InputLabel'; import { Select, SelectItem } from '@backstage/core-components'; -import { RepoUrlPickerState } from './types'; +import { BaseRepoUrlPickerProps } from './types'; -export const GiteaRepoPicker = (props: { - allowedOwners?: string[]; - allowedRepos?: string[]; - state: RepoUrlPickerState; - onChange: (state: RepoUrlPickerState) => void; - rawErrors: string[]; -}) => { +export const GiteaRepoPicker = ( + props: BaseRepoUrlPickerProps<{ + allowedOwners?: string[]; + allowedRepos?: string[]; + }>, +) => { const { allowedOwners = [], state, onChange, rawErrors } = props; const ownerItems: SelectItem[] = allowedOwners ? allowedOwners.map(i => ({ label: i, value: i })) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.tsx index ec59d89ef8..5d46dc4333 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.tsx @@ -19,14 +19,13 @@ import FormHelperText from '@material-ui/core/FormHelperText'; import Input from '@material-ui/core/Input'; import InputLabel from '@material-ui/core/InputLabel'; import { Select, SelectItem } from '@backstage/core-components'; -import { RepoUrlPickerState } from './types'; +import { BaseRepoUrlPickerProps } from './types'; -export const GithubRepoPicker = (props: { - allowedOwners?: string[]; - rawErrors: string[]; - state: RepoUrlPickerState; - onChange: (state: RepoUrlPickerState) => void; -}) => { +export const GithubRepoPicker = ( + props: BaseRepoUrlPickerProps<{ + allowedOwners?: string[]; + }>, +) => { const { allowedOwners = [], rawErrors, state, onChange } = props; const ownerItems: SelectItem[] = allowedOwners ? allowedOwners.map(i => ({ label: i, value: i })) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx index fbcc13c846..12ae37bf15 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx @@ -19,15 +19,14 @@ import FormHelperText from '@material-ui/core/FormHelperText'; import Input from '@material-ui/core/Input'; import InputLabel from '@material-ui/core/InputLabel'; import { Select, SelectItem } from '@backstage/core-components'; -import { RepoUrlPickerState } from './types'; +import { BaseRepoUrlPickerProps } from './types'; -export const GitlabRepoPicker = (props: { - allowedOwners?: string[]; - allowedRepos?: string[]; - state: RepoUrlPickerState; - onChange: (state: RepoUrlPickerState) => void; - rawErrors: string[]; -}) => { +export const GitlabRepoPicker = ( + props: BaseRepoUrlPickerProps<{ + allowedOwners?: string[]; + allowedRepos?: string[]; + }>, +) => { const { allowedOwners = [], state, onChange, rawErrors } = props; const ownerItems: SelectItem[] = allowedOwners ? allowedOwners.map(i => ({ label: i, value: i })) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/types.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/types.ts index 1430b10b84..9489901d61 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/types.ts +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/types.ts @@ -22,3 +22,9 @@ export interface RepoUrlPickerState { project?: string; availableRepos?: string[]; } + +export type BaseRepoUrlPickerProps = T & { + onChange: (state: RepoUrlPickerState) => void; + state: RepoUrlPickerState; + rawErrors: string[]; +}; From 6b8bab93beaee731a7db18b818cb13af21e848c3 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Mon, 15 Jul 2024 10:40:19 +0200 Subject: [PATCH 053/225] refactor: use useCallback for autocompletion updates in BitbucketRepoPicker Signed-off-by: Benjamin Janssens --- .../RepoUrlPicker/BitbucketRepoPicker.tsx | 153 +++++++++--------- 1 file changed, 75 insertions(+), 78 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx index 5d627e9512..0b7ee49525 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import FormControl from '@material-ui/core/FormControl'; import FormHelperText from '@material-ui/core/FormHelperText'; import { Select, SelectItem } from '@backstage/core-components'; @@ -68,93 +68,90 @@ export const BitbucketRepoPicker = ( const [availableProjects, setAvailableProjects] = useState([]); // Update available workspaces when client is available - useDebounce( - () => { - const updateAvailableWorkspaces = async () => { - if ( - host === 'bitbucket.org' && - accessToken && - scaffolderApi.autocomplete - ) { - const { results } = await scaffolderApi.autocomplete({ - token: accessToken, - resource: 'workspaces', - context: {}, - provider: 'bitbucket-cloud', - }); + const updateAvailableWorkspaces = useCallback(() => { + if ( + !scaffolderApi.autocomplete || + !accessToken || + host !== 'bitbucket.org' + ) { + setAvailableWorkspaces([]); + return; + } - setAvailableWorkspaces(results.map(r => r.title)); - } else { - setAvailableWorkspaces([]); - } - }; + scaffolderApi + .autocomplete({ + token: accessToken, + resource: 'workspaces', + provider: 'bitbucket-cloud', + }) + .then(({ results }) => { + setAvailableWorkspaces(results.map(r => r.title)); + }) + .catch(() => { + setAvailableWorkspaces([]); + }); + }, [scaffolderApi, accessToken, host]); - updateAvailableWorkspaces().catch(() => setAvailableWorkspaces([])); - }, - 500, - [host, accessToken], - ); + useDebounce(updateAvailableWorkspaces, 500, [updateAvailableWorkspaces]); // Update available projects when client is available and workspace changes - useDebounce( - () => { - const updateAvailableProjects = async () => { - if ( - host === 'bitbucket.org' && - accessToken && - workspace && - scaffolderApi.autocomplete - ) { - const { results } = await scaffolderApi.autocomplete({ - token: accessToken, - resource: 'projects', - context: { workspace }, - provider: 'bitbucket-cloud', - }); + const updateAvailableProjects = useCallback(() => { + if ( + !scaffolderApi.autocomplete || + !accessToken || + host !== 'bitbucket.org' || + !workspace + ) { + setAvailableProjects([]); + return; + } - setAvailableProjects(results.map(r => r.title)); - } else { - setAvailableProjects([]); - } - }; + scaffolderApi + .autocomplete({ + token: accessToken, + resource: 'projects', + context: { workspace }, + provider: 'bitbucket-cloud', + }) + .then(({ results }) => { + setAvailableProjects(results.map(r => r.title)); + }) + .catch(() => { + setAvailableProjects([]); + }); + }, [scaffolderApi, accessToken, host, workspace]); - updateAvailableProjects().catch(() => setAvailableProjects([])); - }, - 500, - [host, accessToken, workspace], - ); + useDebounce(updateAvailableProjects, 500, [updateAvailableProjects]); // Update available repositories when client is available and workspace or project changes - useDebounce( - () => { - const updateAvailableRepositories = async () => { - if ( - host === 'bitbucket.org' && - accessToken && - workspace && - project && - scaffolderApi.autocomplete - ) { - const { results } = await scaffolderApi.autocomplete({ - token: accessToken, - resource: 'repositories', - context: { workspace, project }, - provider: 'bitbucket-cloud', - }); + const updateAvailableRepositories = useCallback(() => { + if ( + !scaffolderApi.autocomplete || + !accessToken || + host !== 'bitbucket.org' || + !workspace || + !project + ) { + onChange({ availableRepos: [] }); + return; + } - onChange({ availableRepos: results.map(r => r.title) }); - } else { - onChange({ availableRepos: [] }); - } - }; + scaffolderApi + .autocomplete({ + token: accessToken, + resource: 'repositories', + context: { workspace, project }, + provider: 'bitbucket-cloud', + }) + .then(({ results }) => { + onChange({ availableRepos: results.map(r => r.title) }); + }) + .catch(() => { + onChange({ availableRepos: [] }); + }); + }, [scaffolderApi, accessToken, host, workspace, project, onChange]); - updateAvailableRepositories().catch(() => - onChange({ availableRepos: [] }), - ); - }, - 500, - [host, accessToken, workspace, project], - ); + useDebounce(updateAvailableRepositories, 500, [updateAvailableRepositories]); return ( <> From 7f153f94c1c3d1849e418109e097b03914941113 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Mon, 15 Jul 2024 10:44:22 +0200 Subject: [PATCH 054/225] test: simplify checking for the secret in the document; remove SecretsComponent from test that didn't use it Signed-off-by: Benjamin Janssens --- .../RepoUrlPicker/RepoUrlPicker.test.tsx | 47 ++++++------------- 1 file changed, 15 insertions(+), 32 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx index 002652cfab..f0e47a2504 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx @@ -214,13 +214,14 @@ describe('RepoUrlPicker', () => { describe('requestUserCredentials', () => { it('should call the scmAuthApi with the correct params', async () => { + const secretsKey = 'testKey'; + const SecretsComponent = () => { const { secrets } = useTemplateSecrets(); - return ( -
{JSON.stringify({ secrets })}
- ); + const secret = secrets[secretsKey]; + return secret ?
{secret}
: null; }; - const { getByTestId } = await renderInTestApp( + const { getByText } = await renderInTestApp( { 'ui:field': 'RepoUrlPicker', 'ui:options': { requestUserCredentials: { - secretsKey: 'testKey', + secretsKey, additionalScopes: { github: ['workflow'] }, }, }, @@ -265,21 +266,9 @@ describe('RepoUrlPicker', () => { }, }); - const currentSecrets = JSON.parse( - getByTestId('current-secrets').textContent!, - ); - - expect(currentSecrets).toEqual({ - secrets: { testKey: 'abc123' }, - }); + expect(getByText('abc123')).toBeInTheDocument(); }); it('should call the scmAuthApi with the correct params if workspace is nested', async () => { - const SecretsComponent = () => { - const { secrets } = useTemplateSecrets(); - return ( -
{JSON.stringify({ secrets })}
- ); - }; await renderInTestApp( { RepoUrlPicker: RepoUrlPicker as ScaffolderRJSFField, }} /> - , ); @@ -324,13 +312,14 @@ describe('RepoUrlPicker', () => { }); it('should not call the scmAuthApi if secret is available in the state', async () => { + const secretsKey = 'testKey'; + const SecretsComponent = () => { const { secrets } = useTemplateSecrets(); - return ( -
{JSON.stringify({ secrets })}
- ); + const secret = secrets[secretsKey]; + return secret ?
{secret}
: null; }; - const { getByTestId } = await renderInTestApp( + const { getByText } = await renderInTestApp( { [scaffolderApiRef, mockScaffolderApi], ]} > - +
{ 'ui:field': 'RepoUrlPicker', 'ui:options': { requestUserCredentials: { - secretsKey: 'testKey', + secretsKey, additionalScopes: { github: ['workflow'] }, }, }, @@ -368,13 +357,7 @@ describe('RepoUrlPicker', () => { // as we already have a secret in the state, getCredentials should not be called again. expect(mockScmAuthApi.getCredentials).toHaveBeenCalledTimes(0); - const currentSecrets = JSON.parse( - getByTestId('current-secrets').textContent!, - ); - - expect(currentSecrets).toEqual({ - secrets: { testKey: 'abc123' }, - }); + expect(getByText('abc123')).toBeInTheDocument(); }); }); }); From bbd9f56c764b3fc22c79a279bca48f70d6c83dea Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Mon, 15 Jul 2024 10:49:56 +0200 Subject: [PATCH 055/225] chore: add changeset Signed-off-by: Benjamin Janssens --- .changeset/spotty-planets-accept.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/spotty-planets-accept.md diff --git a/.changeset/spotty-planets-accept.md b/.changeset/spotty-planets-accept.md new file mode 100644 index 0000000000..db262dd8b5 --- /dev/null +++ b/.changeset/spotty-planets-accept.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Cleaned up codebase From 9a5e7f1c36934b6eca39c8c13fbc35bcc43fefb2 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Mon, 15 Jul 2024 10:51:24 +0200 Subject: [PATCH 056/225] chore: update changeset Signed-off-by: Benjamin Janssens --- .changeset/spotty-planets-accept.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/spotty-planets-accept.md b/.changeset/spotty-planets-accept.md index db262dd8b5..7342f89bd1 100644 --- a/.changeset/spotty-planets-accept.md +++ b/.changeset/spotty-planets-accept.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder': patch --- -Cleaned up codebase +Cleaned up codebase of RepoUrlPicker From c4a4a69912e004dbfa622d9f2107410af8fffd34 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Mon, 15 Jul 2024 11:15:21 +0200 Subject: [PATCH 057/225] test: fix BitbucketRepoPicker tests Signed-off-by: Benjamin Janssens --- .../fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx index 2ae89233ff..570c5bd664 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx @@ -27,9 +27,11 @@ import { act } from 'react-dom/test-utils'; describe('BitbucketRepoPicker', () => { const scaffolderApiMock: Partial = { - autocomplete: jest.fn().mockImplementation(opts => ({ - results: [{ title: `${opts.resource}_example` }], - })), + autocomplete: jest.fn().mockImplementation(opts => + Promise.resolve({ + results: [{ title: `${opts.resource}_example` }], + }), + ), }; it('renders a select if there is a list of allowed owners', async () => { From a5bcb9c4d59af4c50bff0160bec566e49753a3a0 Mon Sep 17 00:00:00 2001 From: Niall Thomson Date: Mon, 15 Jul 2024 05:58:02 -0600 Subject: [PATCH 058/225] 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 24a98c940b384efa245d044e07fae5ac37bc2ee7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jul 2024 08:08:47 +0000 Subject: [PATCH 059/225] Bump ws from 8.8.1 to 8.18.0 in /storybook Bumps [ws](https://github.com/websockets/ws) from 8.8.1 to 8.18.0. - [Release notes](https://github.com/websockets/ws/releases) - [Commits](https://github.com/websockets/ws/compare/8.8.1...8.18.0) --- updated-dependencies: - dependency-name: ws dependency-type: indirect ... Signed-off-by: dependabot[bot] --- storybook/yarn.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/storybook/yarn.lock b/storybook/yarn.lock index 1702d6f4cc..28bbe4b144 100644 --- a/storybook/yarn.lock +++ b/storybook/yarn.lock @@ -11912,17 +11912,17 @@ __metadata: linkType: hard "ws@npm:^8.2.3": - version: 8.8.1 - resolution: "ws@npm:8.8.1" + version: 8.18.0 + resolution: "ws@npm:8.18.0" peerDependencies: bufferutil: ^4.0.1 - utf-8-validate: ^5.0.2 + utf-8-validate: ">=5.0.2" peerDependenciesMeta: bufferutil: optional: true utf-8-validate: optional: true - checksum: 2152cf862cae0693f3775bc688a6afb2e989d19d626d215e70f5fcd8eb55b1c3b0d3a6a4052905ec320e2d7734e20aeedbf9744496d62f15a26ad79cf4cf7dae + checksum: 91d4d35bc99ff6df483bdf029b9ea4bfd7af1f16fc91231a96777a63d263e1eabf486e13a2353970efc534f9faa43bdbf9ee76525af22f4752cbc5ebda333975 languageName: node linkType: hard From 392b26a0ed848730e5c332136d391a6726763d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Pi=C4=85tkiewicz?= Date: Tue, 16 Jul 2024 15:17:07 +0200 Subject: [PATCH 060/225] Update writing-custom-actions.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Piotr Piątkiewicz --- docs/features/software-templates/writing-custom-actions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/software-templates/writing-custom-actions.md b/docs/features/software-templates/writing-custom-actions.md index 6c1ea573db..98457d2e8d 100644 --- a/docs/features/software-templates/writing-custom-actions.md +++ b/docs/features/software-templates/writing-custom-actions.md @@ -195,7 +195,7 @@ const scaffolderModuleCustomExtensions = createBackendModule({ const backend = createBackend(); backend.add(import('@backstage/plugin-scaffolder-backend/alpha')); /* highlight-add-next-line */ -backend.add(scaffolderModuleCustomExtensions()); +backend.add(scaffolderModuleCustomExtensions); ``` If your custom action requires core services such as `config` or `cache` they can be imported in the dependencies and passed to the custom action function. From d5e567efff47e2756c6e17a85df9ea52812c4cf0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jul 2024 17:27:24 +0200 Subject: [PATCH 061/225] docs: update threat model and auth docs to mention public entry point Signed-off-by: Patrik Oldsberg --- docs/auth/index.md | 7 +------ docs/overview/threat-model.md | 2 ++ 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/auth/index.md b/docs/auth/index.md index 4a0e56a36e..5188d7e2a4 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -12,12 +12,7 @@ access to external resources. :::note Note -Identity management and the Sign-In page in Backstage is NOT a method for blocking -access for unauthorized users. The identity system only serves to provide a personalized -experience and access to a Backstage Identity Token, which can be passed to backend plugins. -This also means that your Backstage backend APIs are by default unauthenticated. -Thus, if your Backstage instance is exposed to the Internet, anyone can access -information in the Backstage. You can learn more [here](../overview/threat-model.md#integrator-responsibilities). +Identity management and the Sign-In page in Backstage will only block external access when using the new backend system, without setting `backend.auth.dangerouslyDisableDefaultAuthPolicy` in configuration. Even so, the frontend bundle is not protected from external access, protecting it requires the use of the [experimental public entry point](https://backstage.io/docs/tutorials/enable-public-entry/). You can learn more about this in the [Threat Model](../overview/threat-model.md#operator-responsibilities). ::: diff --git a/docs/overview/threat-model.md b/docs/overview/threat-model.md index da97f3fc83..c0e2d61e25 100644 --- a/docs/overview/threat-model.md +++ b/docs/overview/threat-model.md @@ -37,6 +37,8 @@ The operator is ultimately responsible for auditing usage of internal and extern The operator is also responsible for maintaining the resolved NPM dependencies of their Backstage project. This involves ensuring that `yarn.lock` receives updated versions of packages that have vulnerabilities, when those fixed versions are in range of what the Backstage packages request in their respective `package.json` files. This is commonly done by employing automated tooling such as [Dependabot](https://dependabot.com/), [Snyk](https://snyk.io/), and/or [Renovate](https://docs.renovatebot.com/) on your own repository. When fixed versions exist that are _not_ in range of what Backstage packages request, or when larger operations such as switching out an entire dependency for another one is required, maintainers collaborate with contributors to try to address those dependency declarations in the main project as soon as possible. +The built-in protection against unauthorized access does not by default include protection of the frontend bundle. The frontend bundle includes all the code of your frontend plugins and code in minified form, as well as any other frontend resources like images, fonts, etc. If this is a concern, you can use the [experimental public entry point](https://backstage.io/docs/tutorials/enable-public-entry/) to create two separate frontend builds, where authenticated users only have access to the full one. + ## Common Backend Configuration There are many common facilities that are configured centrally and available to all Backstage backend plugins. For example there is a `DatabaseManager` that provides access to a SQL database, `TaskScheduler` for scheduling long-running tasks, `Logger` as a general logging facility, and `UrlReader` for reading content from external sources. These are all configured either directly in code, or within the `backend` block of the static configuration. The appropriate care needs to be taken to ensure that any secrets remain confidential and no malicious configuration is injected. From 7e13b7af04b24105619e34b17ea2b2547b1a6e12 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jul 2024 15:30:50 +0200 Subject: [PATCH 062/225] backend-common: deprecate remaining methods Signed-off-by: Patrik Oldsberg --- .changeset/nine-cherries-decide.md | 14 ++++++++++++++ packages/backend-common/api-report.md | 12 ++++++------ .../src/compat/auth/createLegacyAuthAdapters.ts | 1 + .../src/compat/cache/cacheToPluginCacheManager.ts | 1 + .../backend-common/src/compat/legacy/legacy.ts | 3 +++ .../src/compat/logging/loggerToWinstonLogger.ts | 5 ++++- 6 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 .changeset/nine-cherries-decide.md diff --git a/.changeset/nine-cherries-decide.md b/.changeset/nine-cherries-decide.md new file mode 100644 index 0000000000..b20c24aa85 --- /dev/null +++ b/.changeset/nine-cherries-decide.md @@ -0,0 +1,14 @@ +--- +'@backstage/backend-common': patch +--- + +The remaining exports in the package have now been deprecated: + +- `cacheToPluginCacheManager` +- `createLegacyAuthAdapters` +- `LegacyCreateRouter` +- `legacyPlugin` +- `loggerToWinstonLogger` +- `makeLegacyPlugin` + +Users of these export should fully [migrate to the new backend system](https://backstage.io/docs/backend-system/building-backends/migrating). diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 52b661bcaf..23ed13b62c 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -129,7 +129,7 @@ export class CacheManager extends CacheManager_2 {} // @public @deprecated (undocumented) export type CacheManagerOptions = CacheManagerOptions_2; -// @public +// @public @deprecated export function cacheToPluginCacheManager(cache: CacheService): { getClient(options?: CacheServiceOptions): CacheService; }; @@ -142,7 +142,7 @@ export interface ContainerRunner { runContainer(opts: RunContainerOptions): Promise; } -// @public +// @public @deprecated export function createLegacyAuthAdapters< TOptions extends { auth?: AuthService; @@ -401,10 +401,10 @@ export type KubernetesContainerRunnerOptions = { timeoutMs?: number; }; -// @public (undocumented) +// @public @deprecated (undocumented) export type LegacyCreateRouter = (deps: TEnv) => Promise; -// @public +// @public @deprecated export const legacyPlugin: ( name: string, createRouterImport: Promise<{ @@ -447,13 +447,13 @@ export function loadBackendConfig(options: { watch?: boolean; }): Promise; -// @public (undocumented) +// @public @deprecated (undocumented) export function loggerToWinstonLogger( logger: LoggerService, opts?: TransportStreamOptions, ): Logger; -// @public +// @public @deprecated export function makeLegacyPlugin< TEnv extends Record, TEnvTransforms extends { diff --git a/packages/backend-common/src/compat/auth/createLegacyAuthAdapters.ts b/packages/backend-common/src/compat/auth/createLegacyAuthAdapters.ts index 0f591eac6c..15cd881649 100644 --- a/packages/backend-common/src/compat/auth/createLegacyAuthAdapters.ts +++ b/packages/backend-common/src/compat/auth/createLegacyAuthAdapters.ts @@ -292,6 +292,7 @@ export class UserInfoCompat implements UserInfoService { /** * An adapter that ensures presence of the auth and/or httpAuth services. * @public + * @deprecated Migrate to use the new backend system and auth services instead. */ export function createLegacyAuthAdapters< TOptions extends { diff --git a/packages/backend-common/src/compat/cache/cacheToPluginCacheManager.ts b/packages/backend-common/src/compat/cache/cacheToPluginCacheManager.ts index 2654934804..2ae55b016f 100644 --- a/packages/backend-common/src/compat/cache/cacheToPluginCacheManager.ts +++ b/packages/backend-common/src/compat/cache/cacheToPluginCacheManager.ts @@ -24,6 +24,7 @@ import { * old-backend plugin cache manager. * * @public + * @deprecated Migrate to use the new CacheService instead. */ export function cacheToPluginCacheManager(cache: CacheService): { getClient(options?: CacheServiceOptions): CacheService; diff --git a/packages/backend-common/src/compat/legacy/legacy.ts b/packages/backend-common/src/compat/legacy/legacy.ts index 57220a8430..c779daacd2 100644 --- a/packages/backend-common/src/compat/legacy/legacy.ts +++ b/packages/backend-common/src/compat/legacy/legacy.ts @@ -27,6 +27,7 @@ import { TokenManager } from '../../deprecated'; /** * @public + * @deprecated Fully use the new backend system instead. */ export type LegacyCreateRouter = (deps: TEnv) => Promise; @@ -69,6 +70,7 @@ function wrapTokenManager(tokenManager: TokenManager, auth: AuthService) { * Creates a new custom plugin compatibility wrapper. * * @public + * @deprecated Fully use the new backend system instead. * @remarks * * Usually you can use {@link legacyPlugin} directly instead, but you might @@ -128,6 +130,7 @@ export function makeLegacyPlugin< * register it with the http router based on the plugin id. * * @public + * @deprecated Fully use the new backend system instead. * @remarks * * This is intended to be used by plugin authors to ease the transition to the diff --git a/packages/backend-common/src/compat/logging/loggerToWinstonLogger.ts b/packages/backend-common/src/compat/logging/loggerToWinstonLogger.ts index 4ba88358d2..83871f48e3 100644 --- a/packages/backend-common/src/compat/logging/loggerToWinstonLogger.ts +++ b/packages/backend-common/src/compat/logging/loggerToWinstonLogger.ts @@ -53,7 +53,10 @@ class BackstageLoggerTransport extends Transport { } } -/** @public */ +/** + * @public + * @deprecated Migrate to use the new LoggerService instead. + */ export function loggerToWinstonLogger( logger: LoggerService, opts?: TransportStreamOptions, From b3769f14b3f3fa767546d0d806e0a1967fc0a940 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jul 2024 18:37:01 +0200 Subject: [PATCH 063/225] changesets: enter prerelease Signed-off-by: Patrik Oldsberg --- .changeset/pre.json | 192 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 .changeset/pre.json diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000000..be1b694c9e --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,192 @@ +{ + "mode": "pre", + "tag": "next", + "initialVersions": { + "example-app": "0.2.99", + "@backstage/app-defaults": "1.5.8", + "example-app-next": "0.0.13", + "app-next-example-plugin": "0.0.13", + "example-backend": "0.0.28", + "@backstage/backend-app-api": "0.8.0", + "@backstage/backend-common": "0.23.3", + "@backstage/backend-defaults": "0.4.0", + "@backstage/backend-dev-utils": "0.1.4", + "@backstage/backend-dynamic-feature-service": "0.2.15", + "example-backend-legacy": "0.2.100", + "@backstage/backend-openapi-utils": "0.1.15", + "@backstage/backend-plugin-api": "0.7.0", + "@backstage/backend-tasks": "0.5.27", + "@backstage/backend-test-utils": "0.4.4", + "@backstage/catalog-client": "1.6.5", + "@backstage/catalog-model": "1.5.0", + "@backstage/cli": "0.26.11", + "@backstage/cli-common": "0.1.14", + "@backstage/cli-node": "0.2.7", + "@backstage/codemods": "0.1.49", + "@backstage/config": "1.2.0", + "@backstage/config-loader": "1.8.1", + "@backstage/core-app-api": "1.14.0", + "@backstage/core-compat-api": "0.2.7", + "@backstage/core-components": "0.14.9", + "@backstage/core-plugin-api": "1.9.3", + "@backstage/create-app": "0.5.17", + "@backstage/dev-utils": "1.0.35", + "e2e-test": "0.2.18", + "@backstage/e2e-test-utils": "0.1.1", + "@backstage/errors": "1.2.4", + "@backstage/eslint-plugin": "0.1.8", + "@backstage/frontend-app-api": "0.7.3", + "@backstage/frontend-plugin-api": "0.6.7", + "@backstage/frontend-test-utils": "0.1.10", + "@backstage/integration": "1.13.0", + "@backstage/integration-aws-node": "0.1.12", + "@backstage/integration-react": "1.1.29", + "@backstage/release-manifests": "0.0.11", + "@backstage/repo-tools": "0.9.4", + "@techdocs/cli": "1.8.16", + "techdocs-cli-embedded-app": "0.2.98", + "@backstage/test-utils": "1.5.8", + "@backstage/theme": "0.5.6", + "@backstage/types": "1.1.1", + "@backstage/version-bridge": "1.0.8", + "yarn-plugin-backstage": "0.0.1", + "@backstage/plugin-api-docs": "0.11.7", + "@backstage/plugin-api-docs-module-protoc-gen-doc": "0.1.7", + "@backstage/plugin-app-backend": "0.3.71", + "@backstage/plugin-app-node": "0.1.22", + "@backstage/plugin-app-visualizer": "0.1.8", + "@backstage/plugin-auth-backend": "0.22.9", + "@backstage/plugin-auth-backend-module-atlassian-provider": "0.2.3", + "@backstage/plugin-auth-backend-module-aws-alb-provider": "0.1.14", + "@backstage/plugin-auth-backend-module-azure-easyauth-provider": "0.1.5", + "@backstage/plugin-auth-backend-module-bitbucket-provider": "0.1.5", + "@backstage/plugin-auth-backend-module-cloudflare-access-provider": "0.1.5", + "@backstage/plugin-auth-backend-module-gcp-iap-provider": "0.2.17", + "@backstage/plugin-auth-backend-module-github-provider": "0.1.19", + "@backstage/plugin-auth-backend-module-gitlab-provider": "0.1.19", + "@backstage/plugin-auth-backend-module-google-provider": "0.1.19", + "@backstage/plugin-auth-backend-module-guest-provider": "0.1.8", + "@backstage/plugin-auth-backend-module-microsoft-provider": "0.1.17", + "@backstage/plugin-auth-backend-module-oauth2-provider": "0.2.3", + "@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "0.1.15", + "@backstage/plugin-auth-backend-module-oidc-provider": "0.2.3", + "@backstage/plugin-auth-backend-module-okta-provider": "0.0.15", + "@backstage/plugin-auth-backend-module-onelogin-provider": "0.1.3", + "@backstage/plugin-auth-backend-module-pinniped-provider": "0.1.16", + "@backstage/plugin-auth-backend-module-vmware-cloud-provider": "0.2.3", + "@backstage/plugin-auth-node": "0.4.17", + "@backstage/plugin-auth-react": "0.1.4", + "@backstage/plugin-bitbucket-cloud-common": "0.2.21", + "@backstage/plugin-catalog": "1.21.1", + "@backstage/plugin-catalog-backend": "1.24.0", + "@backstage/plugin-catalog-backend-module-aws": "0.3.17", + "@backstage/plugin-catalog-backend-module-azure": "0.1.42", + "@backstage/plugin-catalog-backend-module-backstage-openapi": "0.2.5", + "@backstage/plugin-catalog-backend-module-bitbucket-cloud": "0.2.9", + "@backstage/plugin-catalog-backend-module-bitbucket-server": "0.1.36", + "@backstage/plugin-catalog-backend-module-gcp": "0.1.23", + "@backstage/plugin-catalog-backend-module-gerrit": "0.1.39", + "@backstage/plugin-catalog-backend-module-github": "0.6.5", + "@backstage/plugin-catalog-backend-module-github-org": "0.1.17", + "@backstage/plugin-catalog-backend-module-gitlab": "0.3.21", + "@backstage/plugin-catalog-backend-module-gitlab-org": "0.0.5", + "@backstage/plugin-catalog-backend-module-incremental-ingestion": "0.4.27", + "@backstage/plugin-catalog-backend-module-ldap": "0.7.0", + "@backstage/plugin-catalog-backend-module-logs": "0.0.1", + "@backstage/plugin-catalog-backend-module-msgraph": "0.5.30", + "@backstage/plugin-catalog-backend-module-openapi": "0.1.40", + "@backstage/plugin-catalog-backend-module-puppetdb": "0.1.28", + "@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "0.1.20", + "@backstage/plugin-catalog-backend-module-unprocessed": "0.4.9", + "@backstage/plugin-catalog-common": "1.0.25", + "@backstage/plugin-catalog-graph": "0.4.7", + "@backstage/plugin-catalog-import": "0.12.1", + "@backstage/plugin-catalog-node": "1.12.4", + "@backstage/plugin-catalog-react": "1.12.2", + "@backstage/plugin-catalog-unprocessed-entities": "0.2.6", + "@backstage/plugin-catalog-unprocessed-entities-common": "0.0.3", + "@backstage/plugin-config-schema": "0.1.57", + "@backstage/plugin-devtools": "0.1.16", + "@backstage/plugin-devtools-backend": "0.3.8", + "@backstage/plugin-devtools-common": "0.1.11", + "@backstage/plugin-events-backend": "0.3.9", + "@backstage/plugin-events-backend-module-aws-sqs": "0.3.8", + "@backstage/plugin-events-backend-module-azure": "0.2.8", + "@backstage/plugin-events-backend-module-bitbucket-cloud": "0.2.8", + "@backstage/plugin-events-backend-module-gerrit": "0.2.8", + "@backstage/plugin-events-backend-module-github": "0.2.8", + "@backstage/plugin-events-backend-module-gitlab": "0.2.8", + "@backstage/plugin-events-backend-test-utils": "0.1.32", + "@backstage/plugin-events-node": "0.3.8", + "@internal/plugin-todo-list": "1.0.29", + "@internal/plugin-todo-list-backend": "1.0.29", + "@internal/plugin-todo-list-common": "1.0.20", + "@backstage/plugin-home": "0.7.7", + "@backstage/plugin-home-react": "0.1.15", + "@backstage/plugin-kubernetes": "0.11.12", + "@backstage/plugin-kubernetes-backend": "0.18.3", + "@backstage/plugin-kubernetes-cluster": "0.0.13", + "@backstage/plugin-kubernetes-common": "0.8.1", + "@backstage/plugin-kubernetes-node": "0.1.16", + "@backstage/plugin-kubernetes-react": "0.4.1", + "@backstage/plugin-notifications": "0.2.3", + "@backstage/plugin-notifications-backend": "0.3.3", + "@backstage/plugin-notifications-backend-module-email": "0.1.3", + "@backstage/plugin-notifications-common": "0.0.5", + "@backstage/plugin-notifications-node": "0.2.3", + "@backstage/plugin-org": "0.6.27", + "@backstage/plugin-org-react": "0.1.26", + "@backstage/plugin-permission-backend": "0.5.46", + "@backstage/plugin-permission-backend-module-allow-all-policy": "0.1.19", + "@backstage/plugin-permission-common": "0.8.0", + "@backstage/plugin-permission-node": "0.8.0", + "@backstage/plugin-permission-react": "0.4.24", + "@backstage/plugin-proxy-backend": "0.5.3", + "@backstage/plugin-scaffolder": "1.23.0", + "@backstage/plugin-scaffolder-backend": "1.23.0", + "@backstage/plugin-scaffolder-backend-module-azure": "0.1.14", + "@backstage/plugin-scaffolder-backend-module-bitbucket": "0.2.12", + "@backstage/plugin-scaffolder-backend-module-bitbucket-cloud": "0.1.12", + "@backstage/plugin-scaffolder-backend-module-bitbucket-server": "0.1.12", + "@backstage/plugin-scaffolder-backend-module-confluence-to-markdown": "0.2.23", + "@backstage/plugin-scaffolder-backend-module-cookiecutter": "0.2.46", + "@backstage/plugin-scaffolder-backend-module-gcp": "0.1.0", + "@backstage/plugin-scaffolder-backend-module-gerrit": "0.1.14", + "@backstage/plugin-scaffolder-backend-module-gitea": "0.1.12", + "@backstage/plugin-scaffolder-backend-module-github": "0.4.0", + "@backstage/plugin-scaffolder-backend-module-gitlab": "0.4.4", + "@backstage/plugin-scaffolder-backend-module-notifications": "0.0.5", + "@backstage/plugin-scaffolder-backend-module-rails": "0.4.39", + "@backstage/plugin-scaffolder-backend-module-sentry": "0.1.30", + "@backstage/plugin-scaffolder-backend-module-yeoman": "0.3.6", + "@backstage/plugin-scaffolder-common": "1.5.4", + "@backstage/plugin-scaffolder-node": "0.4.8", + "@backstage/plugin-scaffolder-node-test-utils": "0.1.9", + "@backstage/plugin-scaffolder-react": "1.10.0", + "@backstage/plugin-search": "1.4.14", + "@backstage/plugin-search-backend": "1.5.14", + "@backstage/plugin-search-backend-module-catalog": "0.1.28", + "@backstage/plugin-search-backend-module-elasticsearch": "1.5.3", + "@backstage/plugin-search-backend-module-explore": "0.1.28", + "@backstage/plugin-search-backend-module-pg": "0.5.32", + "@backstage/plugin-search-backend-module-stack-overflow-collator": "0.1.15", + "@backstage/plugin-search-backend-module-techdocs": "0.1.27", + "@backstage/plugin-search-backend-node": "1.2.27", + "@backstage/plugin-search-common": "1.2.13", + "@backstage/plugin-search-react": "1.7.13", + "@backstage/plugin-signals": "0.0.8", + "@backstage/plugin-signals-backend": "0.1.8", + "@backstage/plugin-signals-node": "0.1.8", + "@backstage/plugin-signals-react": "0.0.4", + "@backstage/plugin-techdocs": "1.10.7", + "@backstage/plugin-techdocs-addons-test-utils": "1.0.35", + "@backstage/plugin-techdocs-backend": "1.10.9", + "@backstage/plugin-techdocs-module-addons-contrib": "1.1.12", + "@backstage/plugin-techdocs-node": "1.12.8", + "@backstage/plugin-techdocs-react": "1.2.6", + "@backstage/plugin-user-settings": "0.8.9", + "@backstage/plugin-user-settings-backend": "0.2.21", + "@backstage/plugin-user-settings-common": "0.0.1" + }, + "changesets": [] +} From 669d742d38359fed15ab03bad494ed73e3e3c7ce Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jul 2024 19:32:32 +0200 Subject: [PATCH 064/225] create-app: remove unnecessary yarn.lock seeds Signed-off-by: Patrik Oldsberg --- packages/create-app/seed-yarn.lock | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/create-app/seed-yarn.lock b/packages/create-app/seed-yarn.lock index b09a0ce1a7..00a86105cc 100644 --- a/packages/create-app/seed-yarn.lock +++ b/packages/create-app/seed-yarn.lock @@ -16,13 +16,3 @@ // package: the name of the package, e.g. @testing-library/react // query: the version query to pin the version for, e.g. ^14.0.0 // version: the version to pin to, must be in range of the query, e.g. 14.11.0 - -ajv@^8.0.0, ajv@^8.10.0, ajv@^8.11.0, ajv@^8.12.0, ajv@^8.14.0, ajv@^8.6.0, ajv@^8.6.3, ajv@^8.8.2, ajv@^8.9.0: - version "8.14.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.14.0.tgz#f514ddfd4756abb200e1704414963620a625ebbb" - integrity sha512-oYs1UUtO97ZO2lJ4bwnWeQW8/zvOIQLGKcvPTsWmvc2SYgBb+upuNS5NxoLaMU4h8Ju3Nbj6Cq8mD2LQoqVKFA== - -"@mui/material@^5.12.2": - version "5.16.1" - resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.16.1.tgz#6fcef9b5709df5864cf0b0bc0ea7b453a9d9e420" - integrity sha512-BGTgJRb0d/hX9tus5CEb6N/Fo8pE4tYA+s9r4/S0PCrtZ3urCLXlTH4qrAvggQbiF1cYRAbHCkVHoQ+4Pdxl+w== From 4670f06f72877aa20faf7f24021a977b22598754 Mon Sep 17 00:00:00 2001 From: Matt Benson Date: Wed, 6 Mar 2024 17:31:38 -0600 Subject: [PATCH 065/225] 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 066/225] 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 7ab4983ed28943636a913eaf7992e437a1e2ade4 Mon Sep 17 00:00:00 2001 From: parmar-abhinav Date: Wed, 17 Jul 2024 15:00:39 +0530 Subject: [PATCH 067/225] Chore:Add error handling for YAML parsing to improve robustness Signed-off-by: parmar-abhinav --- .../fetch/rails/index.examples.test.ts | 70 ++++++++++++++++--- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.examples.test.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.examples.test.ts index a80985321e..37e678ab6a 100644 --- a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.examples.test.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.examples.test.ts @@ -90,7 +90,12 @@ describe('fetch:rails', () => { }); it(`should ${examples[0].description}`, async () => { - const input = yaml.parse(examples[0].example).steps[0].input; + let input; + try { + input = yaml.parse(examples[0].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } await action.handler({ ...mockContext, @@ -110,7 +115,12 @@ describe('fetch:rails', () => { }); it(`should ${examples[1].description}`, async () => { - const input = yaml.parse(examples[1].example).steps[0].input; + let input; + try { + input = yaml.parse(examples[1].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } await action.handler({ ...mockContext, @@ -130,7 +140,12 @@ describe('fetch:rails', () => { }); it(`should ${examples[2].description}`, async () => { - const input = yaml.parse(examples[2].example).steps[0].input; + let input; + try { + input = yaml.parse(examples[2].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } await action.handler({ ...mockContext, @@ -150,7 +165,12 @@ describe('fetch:rails', () => { }); it(`should ${examples[3].description}`, async () => { - const input = yaml.parse(examples[3].example).steps[0].input; + let input; + try { + input = yaml.parse(examples[3].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } await action.handler({ ...mockContext, @@ -170,7 +190,12 @@ describe('fetch:rails', () => { }); it(`should ${examples[4].description}`, async () => { - const input = yaml.parse(examples[4].example).steps[0].input; + let input; + try { + input = yaml.parse(examples[4].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } await action.handler({ ...mockContext, @@ -190,7 +215,12 @@ describe('fetch:rails', () => { }); it(`should ${examples[5].description}`, async () => { - const input = yaml.parse(examples[5].example).steps[0].input; + let input; + try { + input = yaml.parse(examples[5].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } await action.handler({ ...mockContext, @@ -210,7 +240,12 @@ describe('fetch:rails', () => { }); it(`should ${examples[6].description}`, async () => { - const input = yaml.parse(examples[6].example).steps[0].input; + let input; + try { + input = yaml.parse(examples[6].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } await action.handler({ ...mockContext, @@ -230,7 +265,12 @@ describe('fetch:rails', () => { }); it(`should ${examples[7].description}`, async () => { - const input = yaml.parse(examples[7].example).steps[0].input; + let input; + try { + input = yaml.parse(examples[7].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } await action.handler({ ...mockContext, @@ -250,7 +290,12 @@ describe('fetch:rails', () => { }); it(`should ${examples[8].description}`, async () => { - const input = yaml.parse(examples[8].example).steps[0].input; + let input; + try { + input = yaml.parse(examples[8].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } await action.handler({ ...mockContext, @@ -270,7 +315,12 @@ describe('fetch:rails', () => { }); it(`should ${examples[9].description}`, async () => { - const input = yaml.parse(examples[9].example).steps[0].input; + let input; + try { + input = yaml.parse(examples[9].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } await action.handler({ ...mockContext, From 31fd39b4dce1c39d258ed72c43dabe8de38e1c38 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 17 Jul 2024 12:22:36 +0200 Subject: [PATCH 068/225] chore: fixing discord link Signed-off-by: blam --- microsite/blog/2022-12-19-backstage-wrapped-2022.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/microsite/blog/2022-12-19-backstage-wrapped-2022.mdx b/microsite/blog/2022-12-19-backstage-wrapped-2022.mdx index e92c9f2265..c15b9fe208 100644 --- a/microsite/blog/2022-12-19-backstage-wrapped-2022.mdx +++ b/microsite/blog/2022-12-19-backstage-wrapped-2022.mdx @@ -20,7 +20,7 @@ Last year we celebrated 75 [public adopters](https://github.com/backstage/backst ![Accelerated growth of Public Backstage Adopters, from January 2021–December 2022.](assets/2022-12-19/public-backstage-adopters-2022.png) -Not only are our adopters growing at an exponential rate, but so too are our contributors. Our [Discord server](https://discord.gg/backstage-687207715902193673-687207715902193673) is a perfect example of the worlds of our adopters and contributors melding. On average, at any given hour of the day or night, there are more than 500 people online available to talk and provide support on any Backstage topic. Discord discussions run a full gamut of topics – from technical support questions to best practices for onboarding engineers to Backstage – basically, anything and everything an adopter may be interested to know and learn. At the very beginning of 2022, we “only” had 4k Discord members. Today, we have a growing membership of 8.2k. +Not only are our adopters growing at an exponential rate, but so too are our contributors. Our [Discord server](https://discord.gg/backstage-687207715902193673) is a perfect example of the worlds of our adopters and contributors melding. On average, at any given hour of the day or night, there are more than 500 people online available to talk and provide support on any Backstage topic. Discord discussions run a full gamut of topics – from technical support questions to best practices for onboarding engineers to Backstage – basically, anything and everything an adopter may be interested to know and learn. At the very beginning of 2022, we “only” had 4k Discord members. Today, we have a growing membership of 8.2k. Isn’t it what we can call a great and vibrant community of enthusiasts? From c8f1cae0efb32b4b859bd7fa12d65e85c6dd5dbd Mon Sep 17 00:00:00 2001 From: Boris Bera Date: Wed, 17 Jul 2024 11:31:12 -0400 Subject: [PATCH 069/225] Add `signIn.resolvers` to auth providers config schema Signed-off-by: Boris Bera --- .changeset/breezy-jeans-tie.md | 18 ++++++++++++++++++ .../config.d.ts | 7 +++++++ .../config.d.ts | 6 ++++++ .../config.d.ts | 7 +++++++ .../config.d.ts | 6 ++++++ .../config.d.ts | 9 +++++++++ .../config.d.ts | 7 +++++++ .../config.d.ts | 7 +++++++ .../config.d.ts | 7 +++++++ .../config.d.ts | 7 +++++++ .../config.d.ts | 7 +++++++ .../config.d.ts | 6 ++++++ .../config.d.ts | 7 +++++++ .../config.d.ts | 7 +++++++ .../config.d.ts | 7 +++++++ 15 files changed, 115 insertions(+) create mode 100644 .changeset/breezy-jeans-tie.md diff --git a/.changeset/breezy-jeans-tie.md b/.changeset/breezy-jeans-tie.md new file mode 100644 index 0000000000..4601389a4c --- /dev/null +++ b/.changeset/breezy-jeans-tie.md @@ -0,0 +1,18 @@ +--- +'@backstage/plugin-auth-backend-module-cloudflare-access-provider': patch +'@backstage/plugin-auth-backend-module-vmware-cloud-provider': patch +'@backstage/plugin-auth-backend-module-atlassian-provider': patch +'@backstage/plugin-auth-backend-module-bitbucket-provider': patch +'@backstage/plugin-auth-backend-module-microsoft-provider': patch +'@backstage/plugin-auth-backend-module-onelogin-provider': patch +'@backstage/plugin-auth-backend-module-aws-alb-provider': patch +'@backstage/plugin-auth-backend-module-gcp-iap-provider': patch +'@backstage/plugin-auth-backend-module-github-provider': patch +'@backstage/plugin-auth-backend-module-gitlab-provider': patch +'@backstage/plugin-auth-backend-module-google-provider': patch +'@backstage/plugin-auth-backend-module-oauth2-provider': patch +'@backstage/plugin-auth-backend-module-oidc-provider': patch +'@backstage/plugin-auth-backend-module-okta-provider': patch +--- + +Add `signIn` to authentication provider configuration schema diff --git a/plugins/auth-backend-module-atlassian-provider/config.d.ts b/plugins/auth-backend-module-atlassian-provider/config.d.ts index d2c8a249c1..b6ca96d62f 100644 --- a/plugins/auth-backend-module-atlassian-provider/config.d.ts +++ b/plugins/auth-backend-module-atlassian-provider/config.d.ts @@ -28,6 +28,13 @@ export interface Config { audience?: string; callbackUrl?: string; additionalScopes?: string | string[]; + signIn?: { + resolvers: Array< + | { resolver: 'usernameMatchingUserEntityName' } + | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { resolver: 'emailMatchingUserEntityProfileEmail' } + >; + }; }; }; }; diff --git a/plugins/auth-backend-module-aws-alb-provider/config.d.ts b/plugins/auth-backend-module-aws-alb-provider/config.d.ts index 1978e5a4df..5a3e90cf6b 100644 --- a/plugins/auth-backend-module-aws-alb-provider/config.d.ts +++ b/plugins/auth-backend-module-aws-alb-provider/config.d.ts @@ -21,6 +21,12 @@ export interface Config { awsalb?: { issuer?: string; region: string; + signIn?: { + resolvers: Array< + | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { resolver: 'emailMatchingUserEntityProfileEmail' } + >; + }; }; }; }; diff --git a/plugins/auth-backend-module-bitbucket-provider/config.d.ts b/plugins/auth-backend-module-bitbucket-provider/config.d.ts index 17cac1c194..df68209469 100644 --- a/plugins/auth-backend-module-bitbucket-provider/config.d.ts +++ b/plugins/auth-backend-module-bitbucket-provider/config.d.ts @@ -26,6 +26,13 @@ export interface Config { */ clientSecret: string; additionalScopes?: string | string[]; + signIn?: { + resolvers: Array< + | { resolver: 'userIdMatchingUserEntityAnnotation' } + | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { resolver: 'emailMatchingUserEntityProfileEmail' } + >; + }; }; }; }; diff --git a/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts b/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts index ff7d722d48..1300eb8c57 100644 --- a/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts +++ b/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts @@ -27,6 +27,12 @@ export interface Config { token: string; subject: string; }>; + signIn?: { + resolvers: Array< + | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { resolver: 'emailMatchingUserEntityProfileEmail' } + >; + }; }; /** * The backstage token expiration. diff --git a/plugins/auth-backend-module-gcp-iap-provider/config.d.ts b/plugins/auth-backend-module-gcp-iap-provider/config.d.ts index 945378ada6..452f9819c3 100644 --- a/plugins/auth-backend-module-gcp-iap-provider/config.d.ts +++ b/plugins/auth-backend-module-gcp-iap-provider/config.d.ts @@ -32,6 +32,15 @@ export interface Config { * The name of the header to read the JWT token from, defaults to `'x-goog-iap-jwt-assertion'`. */ jwtHeader?: string; + + signIn?: { + resolvers: Array< + | { resolver: 'emailMatchingUserEntityAnnotation' } + | { resolver: 'idMatchingUserEntityAnnotation' } + | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { resolver: 'emailMatchingUserEntityProfileEmail' } + >; + }; }; }; }; diff --git a/plugins/auth-backend-module-github-provider/config.d.ts b/plugins/auth-backend-module-github-provider/config.d.ts index f60d4c239d..2ca17998f0 100644 --- a/plugins/auth-backend-module-github-provider/config.d.ts +++ b/plugins/auth-backend-module-github-provider/config.d.ts @@ -28,6 +28,13 @@ export interface Config { callbackUrl?: string; enterpriseInstanceUrl?: string; additionalScopes?: string | string[]; + signIn?: { + resolvers: Array< + | { resolver: 'usernameMatchingUserEntityName' } + | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { resolver: 'emailMatchingUserEntityProfileEmail' } + >; + }; }; }; }; diff --git a/plugins/auth-backend-module-gitlab-provider/config.d.ts b/plugins/auth-backend-module-gitlab-provider/config.d.ts index 18f7cb5005..d21c2bd62a 100644 --- a/plugins/auth-backend-module-gitlab-provider/config.d.ts +++ b/plugins/auth-backend-module-gitlab-provider/config.d.ts @@ -28,6 +28,13 @@ export interface Config { audience?: string; callbackUrl?: string; additionalScopes?: string | string[]; + signIn?: { + resolvers: Array< + | { resolver: 'usernameMatchingUserEntityName' } + | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { resolver: 'emailMatchingUserEntityProfileEmail' } + >; + }; }; }; }; diff --git a/plugins/auth-backend-module-google-provider/config.d.ts b/plugins/auth-backend-module-google-provider/config.d.ts index 08a0e08f2b..3abab05cfe 100644 --- a/plugins/auth-backend-module-google-provider/config.d.ts +++ b/plugins/auth-backend-module-google-provider/config.d.ts @@ -27,6 +27,13 @@ export interface Config { clientSecret: string; callbackUrl?: string; additionalScopes?: string | string[]; + signIn?: { + resolvers: Array< + | { resolver: 'emailMatchingUserEntityAnnotation' } + | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { resolver: 'emailMatchingUserEntityProfileEmail' } + >; + }; }; }; }; diff --git a/plugins/auth-backend-module-microsoft-provider/config.d.ts b/plugins/auth-backend-module-microsoft-provider/config.d.ts index a7ec027083..b1ed2d5766 100644 --- a/plugins/auth-backend-module-microsoft-provider/config.d.ts +++ b/plugins/auth-backend-module-microsoft-provider/config.d.ts @@ -29,6 +29,13 @@ export interface Config { domainHint?: string; callbackUrl?: string; additionalScopes?: string | string[]; + signIn?: { + resolvers: Array< + | { resolver: 'emailMatchingUserEntityAnnotation' } + | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { resolver: 'emailMatchingUserEntityProfileEmail' } + >; + }; }; }; }; diff --git a/plugins/auth-backend-module-oauth2-provider/config.d.ts b/plugins/auth-backend-module-oauth2-provider/config.d.ts index 9b1ee18f1b..fa9dec7ce1 100644 --- a/plugins/auth-backend-module-oauth2-provider/config.d.ts +++ b/plugins/auth-backend-module-oauth2-provider/config.d.ts @@ -32,6 +32,13 @@ export interface Config { additionalScopes?: string | string[]; disableRefresh?: boolean; includeBasicAuth?: boolean; + signIn?: { + resolvers: Array< + | { resolver: 'usernameMatchingUserEntityName' } + | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { resolver: 'emailMatchingUserEntityProfileEmail' } + >; + }; }; }; }; diff --git a/plugins/auth-backend-module-oidc-provider/config.d.ts b/plugins/auth-backend-module-oidc-provider/config.d.ts index 398e63c466..dbc06e51bb 100644 --- a/plugins/auth-backend-module-oidc-provider/config.d.ts +++ b/plugins/auth-backend-module-oidc-provider/config.d.ts @@ -31,6 +31,12 @@ export interface Config { tokenSignedResponseAlg?: string; additionalScopes?: string | string[]; prompt?: string; + signIn?: { + resolvers: Array< + | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { resolver: 'emailMatchingUserEntityProfileEmail' } + >; + }; }; }; }; diff --git a/plugins/auth-backend-module-okta-provider/config.d.ts b/plugins/auth-backend-module-okta-provider/config.d.ts index aaf6ec1f91..0689171153 100644 --- a/plugins/auth-backend-module-okta-provider/config.d.ts +++ b/plugins/auth-backend-module-okta-provider/config.d.ts @@ -30,6 +30,13 @@ export interface Config { idp?: string; callbackUrl?: string; additionalScopes?: string | string[]; + signIn?: { + resolvers: Array< + | { resolver: 'emailMatchingUserEntityAnnotation' } + | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { resolver: 'emailMatchingUserEntityProfileEmail' } + >; + }; }; }; }; diff --git a/plugins/auth-backend-module-onelogin-provider/config.d.ts b/plugins/auth-backend-module-onelogin-provider/config.d.ts index d4f1457461..8257e2ac71 100644 --- a/plugins/auth-backend-module-onelogin-provider/config.d.ts +++ b/plugins/auth-backend-module-onelogin-provider/config.d.ts @@ -27,6 +27,13 @@ export interface Config { clientSecret: string; issuer: string; callbackUrl?: string; + signIn?: { + resolvers: Array< + | { resolver: 'usernameMatchingUserEntityName' } + | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { resolver: 'emailMatchingUserEntityProfileEmail' } + >; + }; }; }; }; diff --git a/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts b/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts index 6ea07f1936..a08451cff3 100644 --- a/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts +++ b/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts @@ -25,6 +25,13 @@ export interface Config { scope?: string; consoleEndpoint?: string; additionalScopes?: string | string[]; + signIn?: { + resolvers: Array< + | { resolver: 'profileEmailMatchingUserEntityEmail' } + | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { resolver: 'emailMatchingUserEntityProfileEmail' } + >; + }; }; }; }; From ee2b0e54330409df51f9ed2ac78090c77afd8f53 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Wed, 17 Jul 2024 17:08:02 +0100 Subject: [PATCH 070/225] cli: add support for forcing use of react development when building Signed-off-by: MT Lewis --- .changeset/little-bulldogs-guess.md | 8 +++++ packages/cli/src/lib/bundler/config.ts | 44 +++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 .changeset/little-bulldogs-guess.md diff --git a/.changeset/little-bulldogs-guess.md b/.changeset/little-bulldogs-guess.md new file mode 100644 index 0000000000..8d247a182b --- /dev/null +++ b/.changeset/little-bulldogs-guess.md @@ -0,0 +1,8 @@ +--- +'@backstage/cli': patch +--- + +Add ability to force use of the development versions of `react` and `react-dom` +by setting the `FORCE_REACT_DEVELOPMENT` environment variable to `true` when +building, for example by using a command like `FORCE_REACT_DEVELOPMENT=true yarn +build:all`. diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 60c8a7be66..44cdc4a475 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -15,7 +15,8 @@ */ import { BackendBundlingOptions, BundlingOptions } from './types'; -import { posix as posixPath, resolve as resolvePath } from 'path'; +import { posix as posixPath, resolve as resolvePath, dirname } from 'path'; +import chalk from 'chalk'; import webpack, { ProvidePlugin } from 'webpack'; import { BackstagePackage } from '@backstage/cli-node'; @@ -33,7 +34,7 @@ import fs from 'fs-extra'; import { getPackages } from '@manypkg/get-packages'; import { isChildPath } from '@backstage/cli-common'; import nodeExternals from 'webpack-node-externals'; -import { optimization } from './optimization'; +import { optimization as optimizationConfig } from './optimization'; import pickBy from 'lodash/pickBy'; import { readEntryPoints } from '../entryPoints'; import { runPlain } from '../run'; @@ -232,12 +233,47 @@ export async function createConfig( require.resolve('react-refresh'), ]; + const mode = isDev ? 'development' : 'production'; + const optimization = optimizationConfig(options); + + if (process.env.FORCE_REACT_DEVELOPMENT === 'true') { + console.log( + chalk.yellow( + `⚠️ WARNING: Forcing react and react-dom into development mode. This build should not be used in production.`, + ), + ); + + const reactPackageDirs = [ + `${dirname(require.resolve('react/package.json'))}/`, + `${dirname(require.resolve('react-dom/package.json'))}/`, + ]; + + // Don't define process.env.NODE_ENV with value matching config.mode. + optimization.nodeEnv = false; + + // Instead, provide a custom definition which always uses "development" if + // the module is part of `react` or `react-dom`, and `config.mode` otherwise. + plugins.push( + new webpack.DefinePlugin({ + 'process.env.NODE_ENV': webpack.DefinePlugin.runtimeValue( + ({ module }) => { + if (reactPackageDirs.some(val => module.resource.startsWith(val))) { + return '"development"'; + } + + return `"${mode}"`; + }, + ), + }), + ); + } + const withCache = yn(process.env[BUILD_CACHE_ENV_VAR], { default: false }); return { - mode: isDev ? 'development' : 'production', + mode, profile: false, - optimization: optimization(options), + optimization, bail: false, performance: { hints: false, // we check the gzip size instead From 7eb08a69dd0cdc11fdf363a07149a8ca9438eadd Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Wed, 17 Jul 2024 20:53:55 +0100 Subject: [PATCH 071/225] 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 46e949f5ed570aefcc6917f4e771f633aef714e3 Mon Sep 17 00:00:00 2001 From: Francisco Meneses Date: Wed, 17 Jul 2024 17:16:29 -0400 Subject: [PATCH 072/225] fix Spanish welcomeTitle message Signed-off-by: Francisco Meneses --- .../WelcomeTitle/locales/goodAfternoon.locales.json | 1 + .../WelcomeTitle/locales/goodEvening.locales.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/home/src/homePageComponents/WelcomeTitle/locales/goodAfternoon.locales.json b/plugins/home/src/homePageComponents/WelcomeTitle/locales/goodAfternoon.locales.json index 9010ba4359..ddb51654fa 100644 --- a/plugins/home/src/homePageComponents/WelcomeTitle/locales/goodAfternoon.locales.json +++ b/plugins/home/src/homePageComponents/WelcomeTitle/locales/goodAfternoon.locales.json @@ -103,6 +103,7 @@ "Sinhala": "සුභ දවාලක්", "Slovenian": "Dober dan", "Somali": "Galab wanaagsan", + "Spanish": "Buenas tardes", "Southern Sámi": "Buerie biejjie", "Sumerian": "𒄭𒌓𒁀", "Swahili": "Habari ya mchana", diff --git a/plugins/home/src/homePageComponents/WelcomeTitle/locales/goodEvening.locales.json b/plugins/home/src/homePageComponents/WelcomeTitle/locales/goodEvening.locales.json index 5a98abafaa..c8d8c7334e 100644 --- a/plugins/home/src/homePageComponents/WelcomeTitle/locales/goodEvening.locales.json +++ b/plugins/home/src/homePageComponents/WelcomeTitle/locales/goodEvening.locales.json @@ -85,7 +85,7 @@ "Slovak": "Dober vecher", "Slovenian": "Dober vecher", "Somali": "Habeen wanaagsan", - "Spanish": "Buenas tardes", + "Spanish": "Buenas noches", "Sumerian": "𒄭𒈪", "Swedish": "God afton", "Tagalog": "Magandang gabi", From fdcc05947fc7447a28d3090235d882896e37d10a Mon Sep 17 00:00:00 2001 From: Francisco Meneses Date: Wed, 17 Jul 2024 17:34:17 -0400 Subject: [PATCH 073/225] add changeset Signed-off-by: Francisco Meneses --- .changeset/slow-toes-jog.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/slow-toes-jog.md diff --git a/.changeset/slow-toes-jog.md b/.changeset/slow-toes-jog.md new file mode 100644 index 0000000000..245feab544 --- /dev/null +++ b/.changeset/slow-toes-jog.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-home': patch +--- + +Fixed a bug on the WelcomeTitle component where the welcome message wasn't correct when the language was set to Spanish From 644d4696c28ca675efc0f240c428b5f43547f734 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Thu, 18 Jul 2024 10:35:40 +0100 Subject: [PATCH 074/225] cli: only allow forcing react development mode when module federation is enabled Signed-off-by: MT Lewis --- .changeset/little-bulldogs-guess.md | 15 ++++++++++++--- packages/cli/src/lib/bundler/config.ts | 5 ++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.changeset/little-bulldogs-guess.md b/.changeset/little-bulldogs-guess.md index 8d247a182b..1f86f0a67f 100644 --- a/.changeset/little-bulldogs-guess.md +++ b/.changeset/little-bulldogs-guess.md @@ -3,6 +3,15 @@ --- Add ability to force use of the development versions of `react` and `react-dom` -by setting the `FORCE_REACT_DEVELOPMENT` environment variable to `true` when -building, for example by using a command like `FORCE_REACT_DEVELOPMENT=true yarn -build:all`. +to allow for detailed error messages and use of fast-refresh. Note that builds +with the development versions of `react` and `react-dom` should not be +deployed in production. + +This feature can be used by setting the `FORCE_REACT_DEVELOPMENT` environment +variable to `true`: + +```bash +EXPERIMENTAL_MODULE_FEDERATION=true \ +FORCE_REACT_DEVELOPMENT=true \ +yarn build:all +``` diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 44cdc4a475..8e3d7eee19 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -236,7 +236,10 @@ export async function createConfig( const mode = isDev ? 'development' : 'production'; const optimization = optimizationConfig(options); - if (process.env.FORCE_REACT_DEVELOPMENT === 'true') { + if ( + process.env.EXPERIMENTAL_MODULE_FEDERATION === 'true' && + process.env.FORCE_REACT_DEVELOPMENT === 'true' + ) { console.log( chalk.yellow( `⚠️ WARNING: Forcing react and react-dom into development mode. This build should not be used in production.`, From ec950c58caac142a311225af4463e50982f7ee11 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 18 Jul 2024 12:35:16 +0200 Subject: [PATCH 075/225] chore: remove app-api dependency Signed-off-by: blam --- plugins/search-backend-module-pg/package.json | 1 - yarn.lock | 1 - 2 files changed, 2 deletions(-) diff --git a/plugins/search-backend-module-pg/package.json b/plugins/search-backend-module-pg/package.json index 8bdcf14306..1693c14c8a 100644 --- a/plugins/search-backend-module-pg/package.json +++ b/plugins/search-backend-module-pg/package.json @@ -49,7 +49,6 @@ "test": "backstage-cli package test" }, "dependencies": { - "@backstage/backend-app-api": "workspace:^", "@backstage/backend-common": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", "@backstage/config": "workspace:^", diff --git a/yarn.lock b/yarn.lock index c6a1d2139a..9261d70f21 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7271,7 +7271,6 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/plugin-search-backend-module-pg@workspace:plugins/search-backend-module-pg" dependencies: - "@backstage/backend-app-api": "workspace:^" "@backstage/backend-common": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" "@backstage/backend-test-utils": "workspace:^" From d3d6f5e9ec07a34dc9d6c66c84493629d75ea0a4 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 18 Jul 2024 12:42:16 +0200 Subject: [PATCH 076/225] chore: remove app-api dep Signed-off-by: blam --- packages/backend-dynamic-feature-service/package.json | 3 ++- .../src/schemas/rootLoggerServiceFactory.ts | 4 ++-- yarn.lock | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/backend-dynamic-feature-service/package.json b/packages/backend-dynamic-feature-service/package.json index 0d05b97bd9..c693135f30 100644 --- a/packages/backend-dynamic-feature-service/package.json +++ b/packages/backend-dynamic-feature-service/package.json @@ -41,8 +41,8 @@ "start": "backstage-cli package start" }, "dependencies": { - "@backstage/backend-app-api": "workspace:^", "@backstage/backend-common": "workspace:^", + "@backstage/backend-defaults": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", "@backstage/backend-tasks": "workspace:^", "@backstage/cli-common": "workspace:^", @@ -70,6 +70,7 @@ "winston": "^3.2.1" }, "devDependencies": { + "@backstage/backend-app-api": "workspace:^", "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "wait-for-expect": "^3.0.2" diff --git a/packages/backend-dynamic-feature-service/src/schemas/rootLoggerServiceFactory.ts b/packages/backend-dynamic-feature-service/src/schemas/rootLoggerServiceFactory.ts index 6681a65a75..bf3df3ffe7 100644 --- a/packages/backend-dynamic-feature-service/src/schemas/rootLoggerServiceFactory.ts +++ b/packages/backend-dynamic-feature-service/src/schemas/rootLoggerServiceFactory.ts @@ -18,9 +18,9 @@ import { createServiceFactory, coreServices, } from '@backstage/backend-plugin-api'; -import { WinstonLogger } from '@backstage/backend-app-api'; +import { WinstonLogger } from '@backstage/backend-defaults/rootLogger'; import { transports, format } from 'winston'; -import { createConfigSecretEnumerator } from '@backstage/backend-app-api'; +import { createConfigSecretEnumerator } from '@backstage/backend-defaults/rootLogger'; import { loadConfigSchema } from '@backstage/config-loader'; import { getPackages } from '@manypkg/get-packages'; import { dynamicPluginsSchemasServiceRef } from './schemas'; diff --git a/yarn.lock b/yarn.lock index 9261d70f21..53bef5284a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3708,6 +3708,7 @@ __metadata: dependencies: "@backstage/backend-app-api": "workspace:^" "@backstage/backend-common": "workspace:^" + "@backstage/backend-defaults": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" "@backstage/backend-tasks": "workspace:^" "@backstage/backend-test-utils": "workspace:^" From 7251567926578e77bddd0f87ec668b91209826e8 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 18 Jul 2024 12:43:24 +0200 Subject: [PATCH 077/225] chore: changeset Signed-off-by: blam --- .changeset/nine-glasses-nail.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/nine-glasses-nail.md diff --git a/.changeset/nine-glasses-nail.md b/.changeset/nine-glasses-nail.md new file mode 100644 index 0000000000..0d68da9f2b --- /dev/null +++ b/.changeset/nine-glasses-nail.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-dynamic-feature-service': patch +'@backstage/plugin-search-backend-module-pg': patch +--- + +Removing `@backstage/backend-app-api` dependency From 0a40f13580e9b6af226ab6bdbd49e2505d988a2c Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 18 Jul 2024 13:05:32 +0200 Subject: [PATCH 078/225] chore: wip Signed-off-by: blam --- .../src/components/TabbedLayout/RoutedTabs.tsx | 3 +++ .../src/layout/HeaderTabs/HeaderTabs.test.tsx | 9 --------- .../core-components/src/layout/HeaderTabs/HeaderTabs.tsx | 7 +------ 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx b/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx index 2c727efe1b..6a5bf2970f 100644 --- a/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx +++ b/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx @@ -96,6 +96,9 @@ export function RoutedTabs(props: { routes: SubRoute[] }) { tabs={headerTabs} selectedIndex={index} onChange={onTabChange} + tabProps={{ + component: Link, + }} /> diff --git a/packages/core-components/src/layout/HeaderTabs/HeaderTabs.test.tsx b/packages/core-components/src/layout/HeaderTabs/HeaderTabs.test.tsx index dad40c4615..5aec83bc2b 100644 --- a/packages/core-components/src/layout/HeaderTabs/HeaderTabs.test.tsx +++ b/packages/core-components/src/layout/HeaderTabs/HeaderTabs.test.tsx @@ -95,13 +95,4 @@ describe('', () => { await user.click(rendered.getByText('Docs')); expect(mockOnChange).toHaveBeenCalledTimes(1); }); - - it('should render 2 nav tabs', async () => { - const rendered = await renderInTestApp(); - const tabs = rendered.queryAllByTestId(id => id.startsWith('header-tab')); - expect(tabs).toHaveLength(2); - tabs.forEach(tab => { - expect(tab.tagName.toLocaleLowerCase('en-US')).toBe('a'); - }); - }); }); diff --git a/packages/core-components/src/layout/HeaderTabs/HeaderTabs.tsx b/packages/core-components/src/layout/HeaderTabs/HeaderTabs.tsx index 9ee9c5525f..002c9fe1ea 100644 --- a/packages/core-components/src/layout/HeaderTabs/HeaderTabs.tsx +++ b/packages/core-components/src/layout/HeaderTabs/HeaderTabs.tsx @@ -18,7 +18,6 @@ import { makeStyles } from '@material-ui/core/styles'; import TabUI, { TabProps } from '@material-ui/core/Tab'; import Tabs from '@material-ui/core/Tabs'; import React, { useCallback, useEffect, useState } from 'react'; -import { Link } from '../../components/Link'; // TODO(blam): Remove this implementation when the Tabs are ready // This is just a temporary solution to implementing tabs for now @@ -96,9 +95,7 @@ export function HeaderTabs(props: HeaderTabsProps) { setSelectedTab(selectedIndex); } }, [selectedIndex]); - function removeLeadingSlash(path: string) { - return path.replace(/^\//, ''); - } + return ( Date: Wed, 26 Jun 2024 16:20:47 +0200 Subject: [PATCH 079/225] 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 080/225] 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 081/225] 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 082/225] 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 083/225] 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 084/225] 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 085/225] 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 086/225] 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 087/225] 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 b1104f9bd5c8516e7d85a70660f394608d0057a2 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 18 Jul 2024 16:58:03 +0200 Subject: [PATCH 088/225] chore: skip backend dynamic Signed-off-by: blam --- .changeset/nine-glasses-nail.md | 1 - packages/backend-dynamic-feature-service/package.json | 3 +-- .../src/schemas/rootLoggerServiceFactory.ts | 4 ++-- yarn.lock | 1 - 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.changeset/nine-glasses-nail.md b/.changeset/nine-glasses-nail.md index 0d68da9f2b..138acce3dd 100644 --- a/.changeset/nine-glasses-nail.md +++ b/.changeset/nine-glasses-nail.md @@ -1,5 +1,4 @@ --- -'@backstage/backend-dynamic-feature-service': patch '@backstage/plugin-search-backend-module-pg': patch --- diff --git a/packages/backend-dynamic-feature-service/package.json b/packages/backend-dynamic-feature-service/package.json index c693135f30..0d05b97bd9 100644 --- a/packages/backend-dynamic-feature-service/package.json +++ b/packages/backend-dynamic-feature-service/package.json @@ -41,8 +41,8 @@ "start": "backstage-cli package start" }, "dependencies": { + "@backstage/backend-app-api": "workspace:^", "@backstage/backend-common": "workspace:^", - "@backstage/backend-defaults": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", "@backstage/backend-tasks": "workspace:^", "@backstage/cli-common": "workspace:^", @@ -70,7 +70,6 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/backend-app-api": "workspace:^", "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "wait-for-expect": "^3.0.2" diff --git a/packages/backend-dynamic-feature-service/src/schemas/rootLoggerServiceFactory.ts b/packages/backend-dynamic-feature-service/src/schemas/rootLoggerServiceFactory.ts index bf3df3ffe7..6681a65a75 100644 --- a/packages/backend-dynamic-feature-service/src/schemas/rootLoggerServiceFactory.ts +++ b/packages/backend-dynamic-feature-service/src/schemas/rootLoggerServiceFactory.ts @@ -18,9 +18,9 @@ import { createServiceFactory, coreServices, } from '@backstage/backend-plugin-api'; -import { WinstonLogger } from '@backstage/backend-defaults/rootLogger'; +import { WinstonLogger } from '@backstage/backend-app-api'; import { transports, format } from 'winston'; -import { createConfigSecretEnumerator } from '@backstage/backend-defaults/rootLogger'; +import { createConfigSecretEnumerator } from '@backstage/backend-app-api'; import { loadConfigSchema } from '@backstage/config-loader'; import { getPackages } from '@manypkg/get-packages'; import { dynamicPluginsSchemasServiceRef } from './schemas'; diff --git a/yarn.lock b/yarn.lock index 53bef5284a..9261d70f21 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3708,7 +3708,6 @@ __metadata: dependencies: "@backstage/backend-app-api": "workspace:^" "@backstage/backend-common": "workspace:^" - "@backstage/backend-defaults": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" "@backstage/backend-tasks": "workspace:^" "@backstage/backend-test-utils": "workspace:^" From 73184b37beea314d63196229f28c11bb14c89315 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 18 Jul 2024 18:46:25 +0200 Subject: [PATCH 089/225] ' 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 090/225] 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 091/225] 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 1eb5e9f686bd52bf99af3f611415ce2ad8bd7dc7 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 19 Jul 2024 14:06:17 +0200 Subject: [PATCH 092/225] chore: attempt to fix the tabs Signed-off-by: blam --- .../TabbedLayout/RoutedTabs.test.tsx | 12 +++++ .../components/TabbedLayout/RoutedTabs.tsx | 51 ++++++++----------- 2 files changed, 33 insertions(+), 30 deletions(-) diff --git a/packages/core-components/src/components/TabbedLayout/RoutedTabs.test.tsx b/packages/core-components/src/components/TabbedLayout/RoutedTabs.test.tsx index ec91208eee..991276c5d3 100644 --- a/packages/core-components/src/components/TabbedLayout/RoutedTabs.test.tsx +++ b/packages/core-components/src/components/TabbedLayout/RoutedTabs.test.tsx @@ -160,4 +160,16 @@ describe('RoutedTabs', () => { rendered.queryByText('tabbed-test-content-2'), ).not.toBeInTheDocument(); }); + + it('should render the tabs as links', async () => { + const routes = [testRoute2, testRoute1, testRoute3]; + const rendered = await renderInTestApp(); + + const tabs = rendered.queryAllByRole('tab'); + + for (const [k, v] of Object.entries(tabs)) { + expect(v.tagName).toBe('A'); + expect(v).toHaveAttribute('href', routes[Number(k)].path); + } + }); }); diff --git a/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx b/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx index 6a5bf2970f..d6f6eab144 100644 --- a/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx +++ b/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx @@ -15,15 +15,11 @@ */ import React, { useMemo } from 'react'; import { Helmet } from 'react-helmet'; -import { - matchRoutes, - useNavigate, - useParams, - useRoutes, -} from 'react-router-dom'; +import { matchRoutes, useParams, useRoutes } from 'react-router-dom'; import { Content } from '../../layout/Content'; import { HeaderTabs } from '../../layout/HeaderTabs'; import { SubRoute } from './types'; +import { Link } from '../Link'; export function useSelectedSubRoute(subRoutes: SubRoute[]): { index: number; @@ -68,38 +64,33 @@ export function useSelectedSubRoute(subRoutes: SubRoute[]): { export function RoutedTabs(props: { routes: SubRoute[] }) { const { routes } = props; - const navigate = useNavigate(); + const { index, route, element } = useSelectedSubRoute(routes); const headerTabs = useMemo( () => - routes.map(t => ({ - id: t.path, - label: t.title, - tabProps: t.tabProps, - })), + routes.map(t => { + const { path, title, tabProps } = t; + let to = path; + // Remove trailing /* + to = to.replace(/\/\*$/, ''); + // And remove leading / for relative navigation + to = to.replace(/^\//, ''); + return { + id: path, + label: title, + tabProps: { + ...tabProps, + component: Link, + to, + }, + }; + }), [routes], ); - const onTabChange = (tabIndex: number) => { - let { path } = routes[tabIndex]; - // Remove trailing /* - path = path.replace(/\/\*$/, ''); - // And remove leading / for relative navigation - path = path.replace(/^\//, ''); - // Note! route resolves relative to the position in the React tree, - // not relative to current location - navigate(path); - }; return ( <> - + {element} From 678971ad41380d5b958ed4b9f72e9372385d9380 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 19 Jul 2024 14:07:17 +0200 Subject: [PATCH 093/225] chore: added changeset Signed-off-by: blam --- .changeset/eighty-mirrors-flow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/eighty-mirrors-flow.md diff --git a/.changeset/eighty-mirrors-flow.md b/.changeset/eighty-mirrors-flow.md new file mode 100644 index 0000000000..57e978a78c --- /dev/null +++ b/.changeset/eighty-mirrors-flow.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Move the `Link` component to the `RoutedTabs` instead of the `HeaderTabs` component From de4cd433c15a6cc2ba63448ff31542decedaf2bf Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Fri, 19 Jul 2024 13:31:28 +0100 Subject: [PATCH 094/225] cli: simplify changeset Co-authored-by: Patrik Oldsberg Signed-off-by: MT Lewis --- .changeset/little-bulldogs-guess.md | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/.changeset/little-bulldogs-guess.md b/.changeset/little-bulldogs-guess.md index 1f86f0a67f..3e646a033f 100644 --- a/.changeset/little-bulldogs-guess.md +++ b/.changeset/little-bulldogs-guess.md @@ -2,16 +2,4 @@ '@backstage/cli': patch --- -Add ability to force use of the development versions of `react` and `react-dom` -to allow for detailed error messages and use of fast-refresh. Note that builds -with the development versions of `react` and `react-dom` should not be -deployed in production. - -This feature can be used by setting the `FORCE_REACT_DEVELOPMENT` environment -variable to `true`: - -```bash -EXPERIMENTAL_MODULE_FEDERATION=true \ -FORCE_REACT_DEVELOPMENT=true \ -yarn build:all -``` +The experimental module federation build now has the ability to force the use of development versions of `react` and `react-dom` by setting the `FORCE_REACT_DEVELOPMENT` flag. From 2e1a7f10e5f29af3e4f253ad62b7d61148c5f995 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Fri, 19 Jul 2024 13:48:20 +0100 Subject: [PATCH 095/225] cli: only check for truthiness of environment variables when forcing react mode Signed-off-by: MT Lewis --- packages/cli/src/lib/bundler/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 8e3d7eee19..3d3be4f485 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -237,8 +237,8 @@ export async function createConfig( const optimization = optimizationConfig(options); if ( - process.env.EXPERIMENTAL_MODULE_FEDERATION === 'true' && - process.env.FORCE_REACT_DEVELOPMENT === 'true' + !!process.env.EXPERIMENTAL_MODULE_FEDERATION && + !!process.env.FORCE_REACT_DEVELOPMENT ) { console.log( chalk.yellow( From 0983429447db996138e90c6168f82e47b405223e Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Fri, 19 Jul 2024 14:05:28 +0100 Subject: [PATCH 096/225] cli: expand comment Signed-off-by: MT Lewis --- packages/cli/src/lib/bundler/config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 3d3be4f485..e10ac0253e 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -251,7 +251,9 @@ export async function createConfig( `${dirname(require.resolve('react-dom/package.json'))}/`, ]; - // Don't define process.env.NODE_ENV with value matching config.mode. + // Don't define process.env.NODE_ENV with value matching config.mode. If we + // don't set this to false, webpack will define the value of + // process.env.NODE_ENV for us, and the definition below will be ignored. optimization.nodeEnv = false; // Instead, provide a custom definition which always uses "development" if From 232b134606d193c262d38892e86e41bc979305fd Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Fri, 19 Jul 2024 14:07:35 +0100 Subject: [PATCH 097/225] cli: skip forcing react to development mode if mode is development anyway Signed-off-by: MT Lewis --- packages/cli/src/lib/bundler/config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index e10ac0253e..1f29277676 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -237,6 +237,7 @@ export async function createConfig( const optimization = optimizationConfig(options); if ( + mode === 'production' && !!process.env.EXPERIMENTAL_MODULE_FEDERATION && !!process.env.FORCE_REACT_DEVELOPMENT ) { From 6c94522688dc49b850db6d23c57b1a794c4b0602 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Fri, 19 Jul 2024 14:08:54 +0100 Subject: [PATCH 098/225] cli: remove explicit boolean coercions when forcing react mode Signed-off-by: MT Lewis --- packages/cli/src/lib/bundler/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 1f29277676..9a611a5954 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -238,8 +238,8 @@ export async function createConfig( if ( mode === 'production' && - !!process.env.EXPERIMENTAL_MODULE_FEDERATION && - !!process.env.FORCE_REACT_DEVELOPMENT + process.env.EXPERIMENTAL_MODULE_FEDERATION && + process.env.FORCE_REACT_DEVELOPMENT ) { console.log( chalk.yellow( From 0e706b3484998631a591b8a07a23b075781ffb25 Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Fri, 19 Jul 2024 15:40:42 +0200 Subject: [PATCH 099/225] 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 From b8600fe1650507c7b0e443ba58d8183e4a7413a8 Mon Sep 17 00:00:00 2001 From: Adam Letizia Date: Fri, 19 Jul 2024 09:48:20 -0500 Subject: [PATCH 100/225] fix(scaffolder): refetch user credentials when selected host is changed in RepoUrlPicker Signed-off-by: Adam Letizia --- .changeset/tough-lies-repair.md | 5 ++ .../RepoUrlPicker/RepoUrlPicker.test.tsx | 56 +++++++++++++++---- .../fields/RepoUrlPicker/RepoUrlPicker.tsx | 11 +++- 3 files changed, 58 insertions(+), 14 deletions(-) create mode 100644 .changeset/tough-lies-repair.md diff --git a/.changeset/tough-lies-repair.md b/.changeset/tough-lies-repair.md new file mode 100644 index 0000000000..25b1884bbc --- /dev/null +++ b/.changeset/tough-lies-repair.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +RepoUrlPicker: refetch user credentials when a new host is selected diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx index 002652cfab..fce9d9517c 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx @@ -33,6 +33,7 @@ import { ScaffolderRJSFField, } from '@backstage/plugin-scaffolder-react'; import { act, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; describe('RepoUrlPicker', () => { const mockScaffolderApi: Partial = { @@ -323,14 +324,29 @@ describe('RepoUrlPicker', () => { }); }); - it('should not call the scmAuthApi if secret is available in the state', async () => { + it('should call the scmAuthApi with the new host when the host is changed', async () => { const SecretsComponent = () => { const { secrets } = useTemplateSecrets(); return (
{JSON.stringify({ secrets })}
); }; - const { getByTestId } = await renderInTestApp( + const allowedHosts = ['github.com', 'gitlab.example.com']; + + (mockScmAuthApi.getCredentials as jest.Mock).mockImplementation( + ({ url }) => { + let token = ''; + if (url === `https://${allowedHosts[0]}`) { + token = 'abc123'; + } else if (url === `https://${allowedHosts[1]}`) { + token = 'def456'; + } + return Promise.resolve({ token }); + }, + ); + const secondHost = allowedHosts[1]; + + const { getAllByRole, getByTestId } = await renderInTestApp( { [scaffolderApiRef, mockScaffolderApi], ]} > - + { ); await act(async () => { + // need to wait for the debounce to finish to fetch credentials for the first selected host + await new Promise(resolve => setTimeout(resolve, 600)); + }); + const firstHostSecrets = JSON.parse( + getByTestId('current-secrets').textContent!, + ); + expect(firstHostSecrets).toEqual({ + secrets: { testKey: 'abc123' }, + }); + + await act(async () => { + // Select the second host + const hostInput = getAllByRole('combobox')[0]; + await userEvent.selectOptions(hostInput, secondHost); + // need to wait for the debounce to finish await new Promise(resolve => setTimeout(resolve, 600)); }); - - // as we already have a secret in the state, getCredentials should not be called again. - expect(mockScmAuthApi.getCredentials).toHaveBeenCalledTimes(0); - - const currentSecrets = JSON.parse( + const secondHostSecrets = JSON.parse( getByTestId('current-secrets').textContent!, ); - - expect(currentSecrets).toEqual({ - secrets: { testKey: 'abc123' }, + expect(secondHostSecrets).toEqual({ + secrets: { testKey: 'def456' }, + }); + expect(mockScmAuthApi.getCredentials).toHaveBeenCalledWith({ + url: `https://${secondHost}`, + additionalScope: { + repoWrite: true, + }, }); }); }); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index e0d714ffda..e653be9178 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -49,6 +49,9 @@ export const RepoUrlPicker = (props: RepoUrlPickerProps) => { const [state, setState] = useState( parseRepoPickerUrl(formData), ); + const [credentialsHost, setCredentialsHost] = useState( + undefined, + ); const integrationApi = useApi(scmIntegrationsApiRef); const scmAuthApi = useApi(scmAuthApiRef); const { secrets, setSecrets } = useTemplateSecrets(); @@ -128,8 +131,11 @@ export const RepoUrlPicker = (props: RepoUrlPickerProps) => { return; } - // don't show login prompt if secret value is already in state - if (secrets[requestUserCredentials.secretsKey]) { + // don't show login prompt if secret value is already in state for selected host + if ( + secrets[requestUserCredentials.secretsKey] && + credentialsHost === state.host + ) { return; } @@ -147,6 +153,7 @@ export const RepoUrlPicker = (props: RepoUrlPickerProps) => { // set the secret using the key provided in the ui:options for use // in the templating the manifest with ${{ secrets[secretsKey] }} setSecrets({ [requestUserCredentials.secretsKey]: token }); + setCredentialsHost(state.host); }, 500, [state, uiSchema], From 924c0a6379fa9a2c521622259126125b0bc2ddc0 Mon Sep 17 00:00:00 2001 From: rghara Date: Fri, 19 Jul 2024 12:53:18 -0400 Subject: [PATCH 101/225] Updated document getting-started/logging-in with missing login screen screenshot Signed-off-by: rghara --- docs/assets/getting-started/login-screen.png | Bin 0 -> 108521 bytes docs/getting-started/logging-in.md | 2 ++ 2 files changed, 2 insertions(+) create mode 100755 docs/assets/getting-started/login-screen.png diff --git a/docs/assets/getting-started/login-screen.png b/docs/assets/getting-started/login-screen.png new file mode 100755 index 0000000000000000000000000000000000000000..70b56f399a22d19ad863f77db22739f67a352501 GIT binary patch literal 108521 zcmZs?1yCIAwk{k(aCd?Rw_pkG?(V?{BDe)-fCQHS!3pj%1PksmNN~3xgS)$Pn|%A* z=iL9lT?JHkO*2*fu3qc0H&jhU76X+8_1Uv$81iyb8qb~~20wcS{~j3;xZ+}UdIkJ} z`>Y`=@vL-+Y#TUv@kv}+{MoaLD6~5e0&tGvAouR`vuC)@Pd{*rA1U2|i{_SEdd`}5 zmJZ4voI&qF&LH60vu9q^<~EiNYW9|P&Y!6T47{k_sRcM*Q+oivyDqknq;LmWF&yghgt-cb8DRU8M zJhx9m)uW~l+bF?jMIm5+nMD7Ckv^MA50x;P(2Us_SCNy8Ls1EVdpR-eK{+z|OWSfP z{G*5KrpIv8>CnND@#5verSR~l;m<9%doCYcU0q>mX|t}9KHynQ*c#skj}+wO*=>ai@S&V+4FJ5MKnFP)~gdul<54x z`DbmbwH^jE;Ke!u9$p@S>9;}hct*vLtOm`zGvXle&UGRPvw(cD1ODN3PNwnUs^PRF zH+muj#Bu+CENOO(9pnab)b_Poc`zQp%*e2VwHV!r*k!Ee;BuuMooGi$%&$r;9dNos zf~Y8uLnSBNu^m@e-3)EO=huO-Q*?v8P5wg9rtGZ?a_ZLbcC;|wtbBh8*sB^ITYuKM z8u8DH;gy7TlRma@t-#r)3W*$@@>m4(B{R)Ajv;9j3PRbPvJtYWSDg_^(QA&OxEXcZ z^0WyQoVEVeq3?fdkk&kIIPw!xn6G0~d~81CoT*YBX1}fQ-?0?y_FXuXPDbf^@B^9noT}HG7|rXlLA(xA`fWQk3j0`n}oMW zw3t>*sG>q7s|KWJ`b7_2%KMJ!7+bk>8kKc5ucCKfyVQSDU21f$rQj!qLf(t}d|x(z zhb)_(*m8xo6S|PMeod%Wm$*E|*kGSL8bo2I6o=(^XDwq+9}W>oJJKIkJ?^oN8Zyet|D~bQwLKb-etfDpNF0s%2W54gNVC`PPUCI< zIUk;}4`=-A-&k+JpQ{wL3HA)gE!820Yq}8R>Vy5RJoejAGzhFHTlJ1s` zN)%bh<(hfrADsu8&F)sLsZtUY6ibMqOo-EJjRC1CJ7@u-*U~y1DNpgqrlR_(w+hkfCKseIJgaa85-rX#KA6;#z6@t85v!8z?bT`9~NvSThZD0w*$X zFq#(*v={lX$bCUE0v&vQ81v5B-2-Nab92=5QZ}Ad3w)(kU1(-EjSAR-RjE2;^tUN$ zlSjaNMUPKg)V>+`xlYLN25f6I z%s8jojD1_!e1YQi--ABWD71HPRtcxn*txVwcP52;f<7eCF5UGME@6GRX&-<^na8{@ zNFGvqr%ojt^z>Lv^QNQ<$^U%v9J!?qrppM{<{=-lpQl>?KR2U5WWy z7h|}Iw2TF3jFWXs-fWAfc9wtW3uy5pY(ap7NOGG{bIu?ce z3;)TRkKbo6$NRz}M~Wn6H(Ap*_F0{TKi@$aan|1EA(4OT_;(&Hjam=dE&?40`2@P+ zs;z|rjG#0pQW$y{<*;K-F9$cZDnE?G$;pDnwre_`aSA+jqJ8Hb@PS9?Xtn>{j4k70 zr8!;nX)^}&9vxEIq9u`Wz=k#CBCegPyu+4Q%$4Ee3t`@$Qi>cz$)cb=c^VtBa&SBs0VzX-PU&)^`)uPN zaz8kRkYG{UfzMWnxkajcxxkhPhu)lwAd@f!RqzTq5w(?2NZqmeDh{29V^pw8Ob?v~ zIPSg&O;%zX=d)m4f?N>rJq^CC4u*mo&isf+u*Zk#qF1QTYbWbJZ>||&O7mN|m-izG zsjW*e$si0zzY%x=w#&tCwQ!Fc#njufvzlMI)BBJFUUG?TJVl^5VGNS(>}4lRmN{B( z#S=og=CKNIX-bR=>&2H{xRbbMG)TLnLyQGViM1|rP^@2ORN{-}&IkHbXTrDY$I54@ z$Nu=R0Y@0&YfOBK|5w@k81wIY+0oC?1qb`tnLug8T%7ubEc}%XN*{-~b8(SlOQ^)s zJ?aJ%b!nOA-Zgrx&e0-iD`zmfB`8qThZMiksRy&~x+wm!5Y*On?38VVza^x}jasov zgFfu44aIhQ6mYCHSD5Cox)Elj4?hZtux9?aSy;q?`UxSFw9JMwkzwL$mjppm2>PY5 zYpIANuSmA%n8R!q5=3MW3UW&&$X4oXiujN( z0DH&&BHuw8&W*+O(1PrrTe&G5SMYc#q<{)i0I%dTMC*BW&SRF+#ZW0iGY>>n_|6g} zd6rrl+zv7KF`GAHEWB8$lRWwmgpTO5KL$yx5L{^FC}l5ypQy*Fc8HWP;LC6+53g4? zs68^+qKK2Y?ulbhy(K0E<~F~*-il;5hyA-Zw!v)L+9P?t^gYp`xi~h`0VSajf%=j# z=a@PM<&~P6@2!J(a@{R0F{gu6;I`SDkN(BtK2!u5bU9<0w{@u|81;~@eC~-7 zcx8q&5^Ygs8u1=Y?T#r`3C=($W2vnD&6WtYA?LzNR=zre1rEJ8S6>w}qmJchcs9l7 zPsL4O{k%+rml^}?d_kgO6_2O+2_HB2$%IW1nf|sM3SsU`&+9HN>ffIfu$Vs~v$rMz zGwS+9E9mRGdm_ZDFkziPVLi}db}%=iFOp!Sq@nU@OtD;b5b7NHF3xNbFD9;j$n_sv z874GTH4Gw)@H-V0M>h_;VlsYAwI5*IaUtg}dhqJBe3|nly^)uQ9hR%k5%?C}ahhRb z6&nc{G(kD)Kv8J6P%~0`WDWH!n6cn#g+)O^BFwH9?A_M8S;7z(eASy!TF;REXPReP ziyhTg@qlU?9j~NW+;rVpvq3ZHp9^BK^Te%d0%8F*~$Ms2!t8g5QsEh1SvHq+HR) zh-$=8kR{rTC977PV=qpdg?kvppdxKX+w?KBh5iF)f~7j5o%^X?KtEVNO{ZpjrmS1b z=k^arZG}6N8et;^3`JLlvoe-#=MxE<4P{nTmKa%&V*P&@vu?X+KhM+N9G_{jpIsZb zWURnNW!H(U`)WG^rBjjmb_aWn0nToypF^)sbfI?C?2CPhp#6GCKnIq1K8~pxBkY8tvN`ExhWbf&<}>ddF1 zT(^23SG}(j@@-YZsc(i-PlY3Y!X!&VCcDlI>6A`$zO;J9`kV@&))C66PI469MiE3N z?czC(6S%4NaEE!`FS@SAmv}b((6X1$=?Py^bialKiNBBxpfP}&hUwyG2bnPZ9WTd% z6!a$2=Z>u)141)vkEVhydk3>{3Gj6oEC~$X zIDmjb@py$p>UQz!YUConS6I*$UA@35ULR@K9AFK$!FFUOJ$jb+vTPvs!eP!2L8e}f zr$;JOCcUq;lj%IShcas>47v3~dzA+DY!s8`iOXqs75R}YS2cY{g0#}LA1k^mWSbhm zGkq9fF$LDJ@@z&n`FS%bZ{)(5&Sn+;g9O}AMe6^8+urf1DAo)26=x`uiH$O~@OKRr zJo5hmFTu1OAZnHVtP3Y0E_Vk`k@tH3N~_4%6S^gd3ccYm2-g-@9}!fe#+p9KowS|(t^Q*29_7O53|P79=3eEj z+r)+J1Z(g4<;y0r4H;~Pq>{<^VvDxof9sVIZEdYPYPq^)pNDZ@TN}y1 z!mY3&upJ+3uI?57M{UdUudaE_KOzToUTRT7zlj7IX=}EeX`t7Z%8})@BZk#NAc4}# zNWTDzq@CwTI&r@$Y=h;~HTHFSV2aqvS_*-D!*RjK?G(bj;F@`^5-P_Wfu@xivY&9^B}LSNK>JY8Hmuo6PGXf>O?^E zWCWp-EM)ADTxyupPrf)sw4z^qvIyT4l1{K-zh|pl6qm46f-2}rc&U}02Kfp)Q5rnnFEkKT$Usz%Ks_p~we76ZBwYe?H8ZZl*}8d^rWb zwMn7%%m`SUvuJ7CFGg8gPr;|mHP5olkqB5hM%JPJt=qm0ikPY#mGHB)e!B$9zKht& zuS|QwutAjV;7Hc+REMzkH6jU1F&i)-&4^~0js%WmmwvsT59cOX|{8IS)Mdu|Z4 zOj_j=0^L;{JDp9WiD7KfPbKRAi*j`;SDJ4&Z0AxBBo~Mp|A-^0cklM&wTHJ(0dg+`;zW#8zR%rE^Ncm^%nKN%*kil@&tvfCqc@cGCZ8Cx+=tS z{X0cT54Q^VzhmbA4@W|;Xr+JJV|st+!y3augZuc)2~M>R92<3~8{$CpTu8kCD^~`d zF;RdMf7EzfFpvG*j5O;m*;|c2@PvLAZ+vp4)A4d_>d5_LA7%6qy=dvm-1^n(ulhU${mvTODVitU?e9qW4-#n> zx;P0hlKP&ZQ@+k9qO_->y)UHJ>0nNWlfl0YZ{W4en|tIX2|NboD=$-n%G*r*M$E3n zrlVUjJ-b)#f&n?e5<1_9`)!M`q1O?1D`|H`RPYX+^gS#jRF#N8DpR8bc0FvNuJ#SI#3@W^2SFyP%q~@;*k3;aDB)Co$tPl4V z*HGa`EkFQtSWAhq=YOA)nRK=OJl%9%>?L_jjQ?=(8P0lAKy&62)ay$g)OlrYoug$d`7Tdb~C?TRS6V!m}{HO@=yD$KpBjJX|ew3 zvFlGcas`p(SrU5v7YS;-?*dhG=FEzxB&Oeb- zhqG+yGV~~Cle?qb1!rj0ZZk|a5yIR<90cn4A`E9LE$W?td?4* zbt#>DmSg-uxN=ylptA|x+-L8KS@gAH5+rW0`p7Dgt6;u!EPopjo2qf{OUrK2~i|AO?3tPPJV zqz*wrq#VVKNsWG%v20Va1$Cgt88aAe*v%6!xYy*toS1aX{r7;2ycsOo^*tGXJpRP%J1S0C58CAp z?ivG%oi1;(U5;g^vaW)OFEBAIm2UB7fpt~g{jsn;vWj1l|5+KcnzG}s9YgstXG6T@YW;?a_ZIA)}I@VpcSQMMlwII+BXb}@; z4N{U*!+h>!f^TiJFVd&kN$(C?J4|oxu@MXnq-e|-e?dVv_t|DAljlk>+U#{d{BfM9 z2#BXZvBC@1U=qY{B8ZBU99ze-5fT$eyZ`8BK0WZ>1frB~yO*rgoidzv`*V$WzkA$q zYFZ%batBuj4*JdJiOB@y^Tui&^Tjvhl%yOtNkV|cUj)#Q{9=+QxnuPUN_H}oJlTQ$ zCHv23xZ}vs0K$q)x11V3WYX9NCxGz z_xatd<@FWnDxf3_sba(O%0U;zZhmLmP?&gnLBAm9GZ4wWt82hz5uUCO@u!RVN)vACvGHE)gx|hu@nK z5h{w*dsgFwBAOMl@qOUuM)lg28A4d*i9)Hsb zWe=eCQ3xs&3BrSh7oI97+jHm!kaNJz-{JiPbW^Y@>9#1AulIacg)hf;{OHT?5gD(;GW?lbkxck5Kbl~8G%i-wnTmS@By+sZF zZveW`0b3Io;DhL7l=E&Qs9zr@*UmjY`{cB(uZ>|8z=;lBBi(!XtkL8TJ#~dQIx9 z`=$22ESZ8`>e^fCZVGUC>nEazA#5l|7aYs|u{g79e*l(tv)l>=ZRqV5Ekz5RqhP!o zA3!~DD?(MNuW9JE+Z}tcy5#VFqCXnmroREG=Myja?|8@!1!(fg)VrGW-*dJ0Zu!yC z_8R(&xpnB-x9V}Pf9qnvOh+-MIj(tm!ntVCAUiS&<^3Eto+ z8Xv7L-aHQWANam}D=CA3za)5XNfXxs z8N*&n+!;y80!uuV!mn4YS|zNK)BI1O7eILOtbCkpVC~l;>cebCS#Grl<*{`-rv2ki z9@q3HrjJ*`0&au3_Ih+qs;OKsP z;tYLQ5XeU~)=-E6v01R>y9}-Yp3az-uo1LupRh(6=@_+1n_gMZLVwmOL9K-j)39J$ zLu}Ma2-l2Qqei@vY%86nH~QNf{ENb{;aKdm(bP>vr}-bHUH$#K+T?z}_hhYlF7~qw zlraDxH)(loSBW82dQjMFqYlkU!T5i=E|Y58%atO^r~V0Q+PtJq7pmP=8s!O+pi$r4 zgt4!yIvU$lMds>TceH6Yy^DxCKK54g$=f>PL9}DJKZh2C$PyW8WAl#ziekwy z#T)EO;TZx5TYY@u`iw_yMH@&D7SIniaU=H<{-G+omjUlf}lK@KaUuD#k|7VNP6UM+9Fh~>7;lm;pbc6VXMUr=M z*%XDb#?SH0^g%QX6<-E{#t>`hJM3L-7HS5I)_QKJ?hu()v=@WBoPBW-3`xseA-E;F z;zY4!LGZq-e8Splb-82U*qvd`5r8-`SCM7etD1fN1UH-_8zCW)H4Bs&gonNRV{+^Lk;Li;v zd0;$#wGzZ&=ZOtSW)8N|WK`y(3L3{uE@s}Jx4uyNw7%EG!1Cl*`|VEa5fdkQlPHVH zoMxW2(R%V*G!HMB(9wTx$^fT&)awO3UDafY7Cm|AHO_7oV+@Qj+s=Frbw--Sb+V7c zHuTMP=)%XM&M+Sv7M7C1aWmn&8W&D=iy?P%fFuGckW1+ItW{*`nS zeqx38uUDc!mkn4Tqg9L~@8!+@h$M?dfk|1p>U6olGvccaQ6o@nBTK;J2i)0F4x#r+ z3F_Rie6!@J{9ziH3|a&-Xh{r^HkQ|W)c35iYZ?$6bSB?3S2NBkDG;tD#&CW-JMHOb z&wXkT@4vl_7H}Hq0#2V=$rv^PxLj|c2ybtq{w+Amz-?=>9&N%I;;f&WAb4d<|C2a7 zxcKK!Tj}kXE`1V(?@>nnJ~iu~EoQ3Vcrx&sOp5`C|P zi{r-G)w*QJocK3X|8$TeF#l9ca|%J~%8Uz3Ir%NT9^)Gm>Ti3!DGg){#)H{gP=+`_ z%cN}iNu8{ZN$=atp0%n4sr76*qmq)ujR)7Z5rzm1NNBY zJK)cZ$Vzqw(9H~Yuro|%JmmCa5V6e!p|=M+W&B-X81LH|4Q4r%vq?Oq%N)PCa!&7e zKq>&XWZZEOIyNkWhH#*|r{=jR5{L%lddrlLP#PbAYur*oMGuoXjrcwK6<&sT-00H9 zWz0m=;7_f6#i0u03jiSmt(jus!Q zP5B!HN-;AZ3{OGf{iWL1uG@=}Ps}_IBcb2DH3~-O=P2-BA z)?XUlb@;c0_3BPTVG1{{TCm(RL+Nwvv$H&B20)S|$3e%z)Xgys`}DVSxMQoYe&yR` zIu6Os-()iTMar(s)FMt2%(wkDY2_uN%OeOnS|N#4Sk$SrN1o~7RT-P0{@3mO+8Fg2 zJn`hQU7c~^xc%2;15oFCe`mrHABUrVG{NZquUS9aL#xI*OqZ zgy%Y$@aK9~oM1lab&7zG&1-BiT6#^x?cguZjhDXG?|HLsbwto!YRch`sk z*y-WN9tyI~Rv~9zi+eA(vPd6#drHgZ_`mQbqvNPAtmgb|M1H4MtiFo@U5~TMv7d0$ zOA0^mSKp_9wQkxD^`TBH{xgLB1W9?nX=I^%h#gFY+izARi#g2xtJx6QxSPZWngqfL)vJokJ`!5sq^f>$vK@~2) z!tRno{Kw7z_Ohwec}H&MdwwMAdj1siFJ6D0Q8r81OjqZ7;p7Vq|0k-LLEN;@ZR(zb9u(DRX z^V&WOACo#Vs${03gEZqS*SnUu_o%+aou!=*mE5T6mt~UMUr7*L?iuIW^NuYF0h4ao zVmCK?T}`jkQ6)}d{YNg;=yjnf+lTUUIkcKUo#YO(_+KIZ6B@yh(i%j{VCd~>cg<{A zNT|wu%DbRY_{Wco1Cvba6ck)2;wV<@k#KIR0Q_t)5#JMJc^!E{uVlQ*b@v%e2iM5rbYHEK`+ z2-!H8a<7U^ftn@=E~>I*ImeLK{Hpk7ME`R91tOZiZB9TUrL6QXX$>XapYgoca=6?u zo+S|*MExlllm&MBq!v37H!7G+PIj67Ne(lhX(((foZ138GUX*h13Xga+D}=35eO0Q zHxW}thnJ-LBa8Q@toa~K9Lhh-*9AJJUjMR@`?K?Zsfhc1+nc0+h>3m2wMw*&c7VNS zcGNn3OS7@kjq~BQA$(CP!wmaBPOE0A>C10enzj5e>IFCga)FOSNE3`#Mxr)tdB-+d ze^z1$=tEdd36iXLn5MdL~K1O>tX?YdZzH2iSClbq*kXT`J=ex$wC z=ma!L{#@@=;-x|S8fw(jDP?}_%kF4UcE@F>5+~C|H4&xKilqFFu{sq?c%fMch&afx z{Xnc@&TS&{8?b|5n5TPJOJ{}hlMU>>T>O? z-!As~E@5ffec44{?qG918l;UidtGZ|9iVJbE6{(lE=m2nHvcj3X!_8bz%|hh(?F*~ zwX_arLoTtGa%Cj^j+*@IzqZ;D%OQ5P5K97}ftVv1zb*F*`Pu6cD=Z4wD#7EPp}?c; z%`->3BT7#9T~;6i#7wuFw8q9U-Fk*v2X9waRaa9e2S^)XI{p(K z{0~e0_Jc28a>&{#4ix=L87)4IIrG7HYzx_W)ARh0(x^ahJ2L8`z)glgM|+GEQ`58eAOxn$$lOYx86?jlJY zs`$)zHy1x^*r7$iM8fRS<&Vb)X^ngfkFSiczayX6@`Hb;FA_+~of~tU@bM3=5eR#E zTthT?SmOSZhJ_GT=M2Q#c87 zBHiRuDBwfw8s;63hi6sz2Ky+fg*lYuw4}2ihNL+3ob|HFD8h(hI*%jJ5RkaTeni?6 zLsu1cezLEAD7JbKb1-m>k3?G(z1Y!bIbg6(J1m}k#k-2lYT!*lQr-! zyLGQiFb_GqiLeFFFqg*0#Zb-kFuhdy)6$GY=62?`6kVj-j)irNdsZ~Ixp~8rtO^w( z>2ly;qNdBaes-D2ZZUTwa<=VAaLTZe#|SAayLsP`fdxIJpG|6c4TMxasGzUmBA?ZOKJs*lBHwi@XLf{T8{&IhlvGpzV6`>)JqO7sXL*MWqcuEuyuGMO@e2_w4 zO9}@nJ&)?Qs#P2Bv^6ses9-29OXaToP-lGhTEsYYSN+g#Ar0w8oQd8$;AkemU?%@ims9_aB1o?vw&I5gdt^UX0_|gupxR zxNB+@7|Kfn4lW;e-W@ekDhF=MVm+^RW0_BAFnaRe{o6+(fP0F>ufNAVsjgjgwEfcr zrx}T=TRvIscZwV9a~7QK9HuPU6lskDl(@qT`z@Ztd%7&-l+3$G?eP*!9w%8^P1}Fd zhFKZ*#y(V9tp_{PbAY@qe6|~(XoB3e%bpFS8#m#>l?08v9f<^hk}SY#a14dcJpWAy zP@t)Do=qwi;3zJ3�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

yjD;9z)BowS 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 From aab708eb27b0a3809685d7d3e1057ac0f9ea8b54 Mon Sep 17 00:00:00 2001 From: parmar-abhinav Date: Sun, 21 Jul 2024 10:14:29 +0530 Subject: [PATCH 109/225] Added test cases for gitlab:issue:edit examples Signed-off-by: parmar-abhinav --- .changeset/plenty-tools-exist.md | 5 + .../actions/gitlabIssueEdit.examples.test.ts | 817 ++++++++++++++++++ .../src/actions/gitlabIssueEdit.examples.ts | 177 +++- 3 files changed, 997 insertions(+), 2 deletions(-) create mode 100644 .changeset/plenty-tools-exist.md create mode 100644 plugins/scaffolder-backend-module-gitlab/src/actions/gitlabIssueEdit.examples.test.ts diff --git a/.changeset/plenty-tools-exist.md b/.changeset/plenty-tools-exist.md new file mode 100644 index 0000000000..0ce22a74f2 --- /dev/null +++ b/.changeset/plenty-tools-exist.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +--- + +Added test cases for gitlab:issue:edit examples diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabIssueEdit.examples.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabIssueEdit.examples.test.ts new file mode 100644 index 0000000000..7e50eac66c --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabIssueEdit.examples.test.ts @@ -0,0 +1,817 @@ +/* + * 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 { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import { ConfigReader } from '@backstage/core-app-api'; +import { ScmIntegrations } from '@backstage/integration'; +import { editGitlabIssueAction } from './gitlabIssueEdit'; +import { examples } from './gitlabIssueEdit.examples'; +import yaml from 'yaml'; + +const mockGitlabClient = { + Issues: { + edit: jest.fn(), + }, +}; +jest.mock('@gitbeaker/rest', () => ({ + Gitlab: class { + constructor() { + return mockGitlabClient; + } + }, +})); + +describe('gitlab:issue:edit', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers({ + now: new Date(1999, 6, 14, 12, 0, 0), + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const config = new ConfigReader({ + integrations: { + gitlab: [ + { + host: 'gitlab.com', + token: 'myIntegrationsToken', + apiBaseUrl: 'https://gitlab.com/api/v4', + }, + ], + }, + }); + const integrations = ScmIntegrations.fromConfig(config); + + const action = editGitlabIssueAction({ integrations }); + + it(`Should ${examples[0].description}`, async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + issueIid: 42, + title: 'Computer banks to rule the world', + }, + workspacePath: 'seen2much', + }); + + mockGitlabClient.Issues.edit.mockResolvedValue({ + id: 123, + iid: 42, + web_url: 'https://gitlab.com/hangar18-/issues/42', + }); + + let input; + try { + input = yaml.parse(examples[0].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(mockGitlabClient.Issues.edit).toHaveBeenCalledWith(12, 42, { + title: input.title, + issueType: undefined, + addLabels: undefined, + removeLabels: undefined, + description: input.description, + assigneeIds: [], + confidential: false, + discussionLocked: false, + epicId: undefined, + labels: undefined, + updatedAt: new Date().toISOString(), + dueDate: undefined, + milestoneId: undefined, + weight: undefined, + stateEvent: undefined, + }); + + expect(mockContext.output).toHaveBeenCalledWith('issueId', 123); + expect(mockContext.output).toHaveBeenCalledWith('issueIid', 42); + expect(mockContext.output).toHaveBeenCalledWith( + 'issueUrl', + 'https://gitlab.com/hangar18-/issues/42', + ); + }); + + it(`Should ${examples[1].description}`, async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + issueIid: 42, + title: 'Computer banks to rule the world', + }, + workspacePath: 'seen2much', + }); + + mockGitlabClient.Issues.edit.mockResolvedValue({ + id: 123, + iid: 42, + web_url: 'https://gitlab.com/hangar18-/issues/42', + }); + + let input; + try { + input = yaml.parse(examples[1].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(mockGitlabClient.Issues.edit).toHaveBeenCalledWith(12, 42, { + title: input.title, + issueType: undefined, + addLabels: undefined, + removeLabels: undefined, + description: input.description, + assigneeIds: [18], + confidential: false, + discussionLocked: false, + epicId: undefined, + labels: undefined, + updatedAt: '2024-05-10T18:00:00.000Z', + dueDate: '2024-09-28', + milestoneId: undefined, + weight: undefined, + stateEvent: undefined, + }); + + expect(mockContext.output).toHaveBeenCalledWith('issueId', 123); + expect(mockContext.output).toHaveBeenCalledWith('issueIid', 42); + expect(mockContext.output).toHaveBeenCalledWith( + 'issueUrl', + 'https://gitlab.com/hangar18-/issues/42', + ); + }); + + it(`Should ${examples[2].description}`, async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + issueIid: 42, + title: 'Computer banks to rule the world', + }, + workspacePath: 'seen2much', + }); + + mockGitlabClient.Issues.edit.mockResolvedValue({ + id: 123, + iid: 42, + web_url: 'https://gitlab.com/hangar18-/issues/42', + }); + + let input; + try { + input = yaml.parse(examples[2].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + epicId: undefined, + }, + }); + + expect(mockGitlabClient.Issues.edit).toHaveBeenCalledWith(12, 42, { + title: input.title, + issueType: undefined, + addLabels: undefined, + removeLabels: undefined, + description: input.description, + assigneeIds: [18, 15], + confidential: false, + discussionLocked: true, + epicId: undefined, + labels: 'phase1:label1,phase2:label2', + updatedAt: '2024-05-10T18:00:00.000Z', + dueDate: '2024-09-28', + milestoneId: undefined, + weight: undefined, + stateEvent: undefined, + }); + + expect(mockContext.output).toHaveBeenCalledWith('issueId', 123); + expect(mockContext.output).toHaveBeenCalledWith('issueIid', 42); + expect(mockContext.output).toHaveBeenCalledWith( + 'issueUrl', + 'https://gitlab.com/hangar18-/issues/42', + ); + }); + + it(`Should ${examples[2].description}`, async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + issueIid: 42, + title: 'Computer banks to rule the world', + }, + workspacePath: 'seen2much', + }); + + mockGitlabClient.Issues.edit.mockResolvedValue({ + id: 123, + iid: 42, + web_url: 'https://gitlab.com/hangar18-/issues/42', + }); + + let input; + try { + input = yaml.parse(examples[2].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + epicId: undefined, + }, + }); + + expect(mockGitlabClient.Issues.edit).toHaveBeenCalledWith(12, 42, { + title: input.title, + issueType: undefined, + addLabels: undefined, + removeLabels: undefined, + description: input.description, + assigneeIds: [18, 15], + confidential: false, + discussionLocked: true, + epicId: undefined, + labels: 'phase1:label1,phase2:label2', + updatedAt: '2024-05-10T18:00:00.000Z', + dueDate: '2024-09-28', + milestoneId: undefined, + weight: undefined, + stateEvent: undefined, + }); + + expect(mockContext.output).toHaveBeenCalledWith('issueId', 123); + expect(mockContext.output).toHaveBeenCalledWith('issueIid', 42); + expect(mockContext.output).toHaveBeenCalledWith( + 'issueUrl', + 'https://gitlab.com/hangar18-/issues/42', + ); + }); + + it(`Should ${examples[3].description}`, async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + issueIid: 42, + title: 'Computer banks to rule the world', + }, + workspacePath: 'seen2much', + }); + + mockGitlabClient.Issues.edit.mockResolvedValue({ + id: 123, + iid: 42, + web_url: 'https://gitlab.com/hangar18-/issues/42', + }); + + let input; + try { + input = yaml.parse(examples[3].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(mockGitlabClient.Issues.edit).toHaveBeenCalledWith(12, 42, { + title: 'Computer banks to rule the world', + issueType: undefined, + addLabels: undefined, + removeLabels: undefined, + description: input.description, + assigneeIds: [], + confidential: false, + discussionLocked: false, + epicId: undefined, + labels: undefined, + updatedAt: new Date().toISOString(), + dueDate: undefined, + milestoneId: undefined, + weight: undefined, + stateEvent: 'close', + }); + + expect(mockContext.output).toHaveBeenCalledWith('issueId', 123); + expect(mockContext.output).toHaveBeenCalledWith('issueIid', 42); + expect(mockContext.output).toHaveBeenCalledWith( + 'issueUrl', + 'https://gitlab.com/hangar18-/issues/42', + ); + }); + + it(`Should ${examples[4].description}`, async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + issueIid: 42, + title: 'Computer banks to rule the world', + }, + workspacePath: 'seen2much', + }); + + mockGitlabClient.Issues.edit.mockResolvedValue({ + id: 123, + iid: 42, + web_url: 'https://gitlab.com/hangar18-/issues/42', + }); + + let input; + try { + input = yaml.parse(examples[4].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(mockGitlabClient.Issues.edit).toHaveBeenCalledWith(12, 42, { + title: 'Computer banks to rule the world', + issueType: undefined, + addLabels: undefined, + removeLabels: undefined, + description: input.description, + assigneeIds: [], + confidential: false, + discussionLocked: false, + epicId: undefined, + labels: undefined, + updatedAt: new Date().toISOString(), + dueDate: undefined, + milestoneId: undefined, + weight: undefined, + stateEvent: 'reopen', + }); + + expect(mockContext.output).toHaveBeenCalledWith('issueId', 123); + expect(mockContext.output).toHaveBeenCalledWith('issueIid', 42); + expect(mockContext.output).toHaveBeenCalledWith( + 'issueUrl', + 'https://gitlab.com/hangar18-/issues/42', + ); + }); + + it(`Should ${examples[5].description}`, async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + issueIid: 42, + title: 'Computer banks to rule the world', + }, + workspacePath: 'seen2much', + }); + + mockGitlabClient.Issues.edit.mockResolvedValue({ + id: 123, + iid: 42, + web_url: 'https://gitlab.com/hangar18-/issues/42', + }); + + let input; + try { + input = yaml.parse(examples[5].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(mockGitlabClient.Issues.edit).toHaveBeenCalledWith(12, 42, { + title: input.title, + issueType: undefined, + addLabels: undefined, + removeLabels: undefined, + description: input.description, + assigneeIds: [18, 20], + confidential: false, + discussionLocked: false, + epicId: undefined, + labels: undefined, + updatedAt: new Date().toISOString(), + dueDate: undefined, + milestoneId: input.milestoneId, + weight: undefined, + stateEvent: undefined, + }); + + expect(mockContext.output).toHaveBeenCalledWith('issueId', 123); + expect(mockContext.output).toHaveBeenCalledWith('issueIid', 42); + expect(mockContext.output).toHaveBeenCalledWith( + 'issueUrl', + 'https://gitlab.com/hangar18-/issues/42', + ); + }); + + it(`Should ${examples[6].description}`, async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + issueIid: 42, + title: 'Computer banks to rule the world', + }, + workspacePath: 'seen2much', + }); + + mockGitlabClient.Issues.edit.mockResolvedValue({ + id: 123, + iid: 42, + web_url: 'https://gitlab.com/hangar18-/issues/42', + }); + + let input; + try { + input = yaml.parse(examples[6].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(mockGitlabClient.Issues.edit).toHaveBeenCalledWith(12, 42, { + title: input.title, + issueType: undefined, + addLabels: undefined, + removeLabels: undefined, + description: input.description, + assigneeIds: [], + confidential: false, + discussionLocked: false, + epicId: undefined, + labels: input.labels, + updatedAt: new Date().toISOString(), + dueDate: undefined, + milestoneId: input.milestoneId, + weight: input.weight, + stateEvent: undefined, + }); + + expect(mockContext.output).toHaveBeenCalledWith('issueId', 123); + expect(mockContext.output).toHaveBeenCalledWith('issueIid', 42); + expect(mockContext.output).toHaveBeenCalledWith( + 'issueUrl', + 'https://gitlab.com/hangar18-/issues/42', + ); + }); + + it(`Should ${examples[7].description}`, async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + issueIid: 42, + title: 'Computer banks to rule the world', + }, + workspacePath: 'seen2much', + }); + + mockGitlabClient.Issues.edit.mockResolvedValue({ + id: 123, + iid: 42, + web_url: 'https://gitlab.com/hangar18-/issues/42', + }); + + let input; + try { + input = yaml.parse(examples[7].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(mockGitlabClient.Issues.edit).toHaveBeenCalledWith(12, 42, { + title: input.title, + issueType: undefined, + addLabels: undefined, + removeLabels: undefined, + description: input.description, + assigneeIds: [], + confidential: input.confidential, + discussionLocked: false, + epicId: undefined, + labels: input.labels, + updatedAt: new Date().toISOString(), + dueDate: undefined, + milestoneId: input.milestoneId, + weight: input.weight, + stateEvent: undefined, + }); + + expect(mockContext.output).toHaveBeenCalledWith('issueId', 123); + expect(mockContext.output).toHaveBeenCalledWith('issueIid', 42); + expect(mockContext.output).toHaveBeenCalledWith( + 'issueUrl', + 'https://gitlab.com/hangar18-/issues/42', + ); + }); + + it(`Should ${examples[8].description}`, async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + issueIid: 42, + title: 'Computer banks to rule the world', + }, + workspacePath: 'seen2much', + }); + + mockGitlabClient.Issues.edit.mockResolvedValue({ + id: 123, + iid: 42, + web_url: 'https://gitlab.com/hangar18-/issues/42', + }); + + let input; + try { + input = yaml.parse(examples[8].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(mockGitlabClient.Issues.edit).toHaveBeenCalledWith(12, 42, { + title: input.title, + issueType: undefined, + addLabels: undefined, + removeLabels: undefined, + description: input.description, + assigneeIds: [], + confidential: false, + discussionLocked: input.discussionLocked, + epicId: undefined, + labels: input.labels, + updatedAt: new Date().toISOString(), + dueDate: undefined, + milestoneId: input.milestoneId, + weight: input.weight, + stateEvent: undefined, + }); + + expect(mockContext.output).toHaveBeenCalledWith('issueId', 123); + expect(mockContext.output).toHaveBeenCalledWith('issueIid', 42); + expect(mockContext.output).toHaveBeenCalledWith( + 'issueUrl', + 'https://gitlab.com/hangar18-/issues/42', + ); + }); + + it(`Should ${examples[9].description}`, async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + issueIid: 42, + title: 'Computer banks to rule the world', + }, + workspacePath: 'seen2much', + }); + + mockGitlabClient.Issues.edit.mockResolvedValue({ + id: 123, + iid: 42, + web_url: 'https://gitlab.com/hangar18-/issues/42', + }); + + let input; + try { + input = yaml.parse(examples[9].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(mockGitlabClient.Issues.edit).toHaveBeenCalledWith(12, 42, { + title: input.title, + issueType: undefined, + addLabels: undefined, + removeLabels: input.removeLabels, + description: input.description, + assigneeIds: [], + confidential: false, + discussionLocked: false, + epicId: undefined, + labels: input.labels, + updatedAt: new Date().toISOString(), + dueDate: undefined, + milestoneId: input.milestoneId, + weight: input.weight, + stateEvent: undefined, + }); + + expect(mockContext.output).toHaveBeenCalledWith('issueId', 123); + expect(mockContext.output).toHaveBeenCalledWith('issueIid', 42); + expect(mockContext.output).toHaveBeenCalledWith( + 'issueUrl', + 'https://gitlab.com/hangar18-/issues/42', + ); + }); + it(`Should ${examples[10].description}`, async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + issueIid: 42, + title: 'Computer banks to rule the world', + }, + workspacePath: 'seen2much', + }); + + mockGitlabClient.Issues.edit.mockResolvedValue({ + id: 123, + iid: 42, + web_url: 'https://gitlab.com/hangar18-/issues/42', + }); + + let input; + try { + input = yaml.parse(examples[10].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(mockGitlabClient.Issues.edit).toHaveBeenCalledWith(12, 42, { + title: input.title, + issueType: undefined, + addLabels: undefined, + removeLabels: input.removeLabels, + description: input.description, + assigneeIds: [], + confidential: false, + discussionLocked: false, + epicId: undefined, + labels: input.labels, + updatedAt: new Date().toISOString(), + dueDate: undefined, + milestoneId: input.milestoneId, + weight: input.weight, + stateEvent: undefined, + }); + + expect(mockContext.output).toHaveBeenCalledWith('issueId', 123); + expect(mockContext.output).toHaveBeenCalledWith('issueIid', 42); + expect(mockContext.output).toHaveBeenCalledWith( + 'issueUrl', + 'https://gitlab.com/hangar18-/issues/42', + ); + }); + it(`Should ${examples[11].description}`, async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + issueIid: 42, + title: 'Computer banks to rule the world', + }, + workspacePath: 'seen2much', + }); + + mockGitlabClient.Issues.edit.mockResolvedValue({ + id: 123, + iid: 42, + web_url: 'https://gitlab.com/hangar18-/issues/42', + }); + + let input; + try { + input = yaml.parse(examples[11].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(mockGitlabClient.Issues.edit).toHaveBeenCalledWith(12, 42, { + title: input.title, + issueType: input.issueType, + addLabels: undefined, + removeLabels: input.removeLabels, + description: input.description, + assigneeIds: [], + confidential: false, + discussionLocked: false, + epicId: undefined, + labels: input.labels, + updatedAt: new Date().toISOString(), + dueDate: undefined, + milestoneId: input.milestoneId, + weight: input.weight, + stateEvent: undefined, + }); + + expect(mockContext.output).toHaveBeenCalledWith('issueId', 123); + expect(mockContext.output).toHaveBeenCalledWith('issueIid', 42); + expect(mockContext.output).toHaveBeenCalledWith( + 'issueUrl', + 'https://gitlab.com/hangar18-/issues/42', + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabIssueEdit.examples.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabIssueEdit.examples.ts index 861c0736f0..8686a66250 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabIssueEdit.examples.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabIssueEdit.examples.ts @@ -50,7 +50,7 @@ export const examples: TemplateExample[] = [ title: 'Test Issue', assignees: [18], description: 'This is the edited description of the issue', - updatedAt: '2024-05-10 18:00:00.000', + updatedAt: '2024-05-10T18:00:00.000Z', dueDate: '2024-09-28', }, }, @@ -72,7 +72,7 @@ export const examples: TemplateExample[] = [ assignees: [18, 15], description: 'This is the description of the issue', confidential: false, - updatedAt: '2024-05-10 18:00:00.000', + updatedAt: '2024-05-10T18:00:00.000Z', dueDate: '2024-09-28', discussionLocked: true, epicId: 1, @@ -82,4 +82,177 @@ export const examples: TemplateExample[] = [ ], }), }, + { + description: 'Edit a gitlab issue to change its state to close', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabIssue', + name: 'EditIssues', + action: 'gitlab:issue:edit', + input: { + ...commonGitlabConfigExample, + projectId: 12, + stateEvent: 'close', + }, + }, + ], + }), + }, + { + description: 'Edit a gitlab issue to change its state to reopened', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabIssue', + name: 'EditIssues', + action: 'gitlab:issue:edit', + input: { + ...commonGitlabConfigExample, + projectId: 12, + stateEvent: 'reopen', + }, + }, + ], + }), + }, + { + description: + 'Edit a gitlab issue to assign it to multiple users and set milestone', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabIssue', + name: 'EditIssues', + action: 'gitlab:issue:edit', + input: { + ...commonGitlabConfigExample, + projectId: 12, + title: 'Test issue with milestone', + assignees: [18, 20], + description: 'This issue has milestone set', + milestoneId: 5, + }, + }, + ], + }), + }, + { + description: 'Edit a gitlab issue to add weight and update labels', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabIssue', + name: 'EditIssues', + action: 'gitlab:issue:edit', + input: { + ...commonGitlabConfigExample, + projectId: 12, + title: 'Issue with weight and labels', + description: 'This issue has weight and new labels', + weight: 3, + labels: 'bug,urgent', + }, + }, + ], + }), + }, + { + description: 'Edit a gitlab issue to make it confidential', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabIssue', + name: 'EditIssues', + action: 'gitlab:issue:edit', + input: { + ...commonGitlabConfigExample, + projectId: 12, + title: 'Confidential Issue', + description: 'This issue is confidential', + confidential: true, + }, + }, + ], + }), + }, + { + description: 'Edit a gitlab issue to lock the discussion', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabIssue', + name: 'EditIssues', + action: 'gitlab:issue:edit', + input: { + ...commonGitlabConfigExample, + projectId: 12, + title: 'Locked Discussion Issue', + description: 'This discussion on this issue is locked', + discussionLocked: true, + }, + }, + ], + }), + }, + { + description: 'Edit a gitlab issue to remove labels and update milestone', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabIssue', + name: 'EditIssues', + action: 'gitlab:issue:edit', + input: { + ...commonGitlabConfigExample, + projectId: 12, + title: 'Issue with labels removed and milestone updated', + description: 'This issue has labels removed and milestone updated', + removeLabels: 'phase1:label1', + milestoneId: 6, + }, + }, + ], + }), + }, + { + description: 'Edit a gitlab issue to remove some labels and new ones', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabIssue', + name: 'EditIssues', + action: 'gitlab:issue:edit', + input: { + ...commonGitlabConfigExample, + projectId: 12, + title: 'Issue with labels updated', + description: 'This issue has labels removed and new ones added', + removeLabels: 'bug,urgent', + labels: 'enhancement:documentation', + }, + }, + ], + }), + }, + { + description: 'Edit a gitlab issue to change issue type and add labels', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabIssue', + name: 'EditIssues', + action: 'gitlab:issue:edit', + input: { + ...commonGitlabConfigExample, + projectId: 12, + title: 'Issue with type and labels', + description: 'This issue has been changes and new labels added', + labels: 'task,high-priority', + issueType: 'task', + }, + }, + ], + }), + }, ]; From 1ba4c2f6da8412660a236fa3588c4fa129cdf92e Mon Sep 17 00:00:00 2001 From: parmar-abhinav Date: Sun, 21 Jul 2024 10:59:49 +0530 Subject: [PATCH 110/225] Added test cases for gitlab:pipeline:trigger examples Signed-off-by: parmar-abhinav --- .changeset/strange-papayas-beg.md | 5 + .../gitlabPipelineTrigger.examples.test.ts | 320 ++++++++++++++++++ .../actions/gitlabPipelineTrigger.examples.ts | 63 ++++ 3 files changed, 388 insertions(+) create mode 100644 .changeset/strange-papayas-beg.md create mode 100644 plugins/scaffolder-backend-module-gitlab/src/actions/gitlabPipelineTrigger.examples.test.ts diff --git a/.changeset/strange-papayas-beg.md b/.changeset/strange-papayas-beg.md new file mode 100644 index 0000000000..6ce33fb029 --- /dev/null +++ b/.changeset/strange-papayas-beg.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +--- + +Added test cases for gitlab:pipeline:trigger examples diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabPipelineTrigger.examples.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabPipelineTrigger.examples.test.ts new file mode 100644 index 0000000000..53cbfb0b87 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabPipelineTrigger.examples.test.ts @@ -0,0 +1,320 @@ +/* + * 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 { ConfigReader } from '@backstage/core-app-api'; +import { ScmIntegrations } from '@backstage/integration'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import { createTriggerGitlabPipelineAction } from './gitlabPipelineTrigger'; +import { examples } from './gitlabPipelineTrigger.examples'; +import yaml from 'yaml'; + +const mockGitlabClient = { + PipelineTriggerTokens: { + create: jest.fn(), + trigger: jest.fn(), + remove: jest.fn(), + }, +}; +jest.mock('@gitbeaker/rest', () => ({ + Gitlab: class { + constructor() { + return mockGitlabClient; + } + }, +})); + +describe('gitlab:pipeline:trigger', () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + jest.useFakeTimers({ + now: new Date(1988, 5, 3, 12, 0, 0), + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const config = new ConfigReader({ + integrations: { + gitlab: [ + { + host: 'gitlab.com', + token: 'glpat-abcdef', + apiBaseUrl: 'https://gitlab.com/api/v4', + }, + ], + }, + }); + const integrations = ScmIntegrations.fromConfig(config); + + const action = createTriggerGitlabPipelineAction({ integrations }); + + it(`should ${examples[0].description}`, async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + tokenDescription: 'Gitlab sample pipeline', + branch: 'main', + }, + workspacePath: 'repository', + }); + + mockGitlabClient.PipelineTriggerTokens.create.mockResolvedValue({ + id: 42, + description: 'Gitlab sample pipeline', + createdAt: new Date().toISOString(), + last_used: null, + token: 'abcdd-ghijkl', + updated_at: new Date().toISOString(), + owner: null, + }); + + mockGitlabClient.PipelineTriggerTokens.trigger.mockResolvedValue({ + id: 99, + web_url: 'https://gitlab.com/hangar18-/pipelines/99', + }); + + let input; + try { + input = yaml.parse(examples[0].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(mockGitlabClient.PipelineTriggerTokens.create).toHaveBeenCalledWith( + input.projectId, + input.tokenDescription, + ); + + expect(mockGitlabClient.PipelineTriggerTokens.trigger).toHaveBeenCalledWith( + input.projectId, + 'main', + 'abcdd-ghijkl', + { variables: input.variables }, + ); + + expect(mockGitlabClient.PipelineTriggerTokens.remove).toHaveBeenCalledWith( + input.projectId, + 42, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'pipelineUrl', + 'https://gitlab.com/hangar18-/pipelines/99', + ); + }); + + it(`should ${examples[1].description}`, async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + tokenDescription: 'Gitlab sample pipeline', + branch: 'main', + }, + workspacePath: 'repository', + }); + + mockGitlabClient.PipelineTriggerTokens.create.mockResolvedValue({ + id: 42, + description: 'Gitlab sample pipeline', + createdAt: new Date().toISOString(), + last_used: null, + token: 'abcdd-ghijkl', + updated_at: new Date().toISOString(), + owner: null, + }); + + mockGitlabClient.PipelineTriggerTokens.trigger.mockResolvedValue({ + id: 99, + web_url: 'https://gitlab.com/hangar18-/pipelines/99', + }); + + let input; + try { + input = yaml.parse(examples[1].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(mockGitlabClient.PipelineTriggerTokens.create).toHaveBeenCalledWith( + input.projectId, + input.tokenDescription, + ); + + expect(mockGitlabClient.PipelineTriggerTokens.trigger).toHaveBeenCalledWith( + input.projectId, + 'main', + 'abcdd-ghijkl', + { variables: input.variables }, + ); + + expect(mockGitlabClient.PipelineTriggerTokens.remove).toHaveBeenCalledWith( + input.projectId, + 42, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'pipelineUrl', + 'https://gitlab.com/hangar18-/pipelines/99', + ); + }); + it(`should ${examples[2].description}`, async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + tokenDescription: 'Gitlab sample pipeline', + branch: 'main', + }, + workspacePath: 'repository', + }); + + mockGitlabClient.PipelineTriggerTokens.create.mockResolvedValue({ + id: 42, + description: 'Gitlab sample pipeline', + createdAt: new Date().toISOString(), + last_used: null, + token: 'abcdd-ghijkl', + updated_at: new Date().toISOString(), + owner: null, + }); + + mockGitlabClient.PipelineTriggerTokens.trigger.mockResolvedValue({ + id: 99, + web_url: 'https://gitlab.com/hangar18-/pipelines/99', + }); + + let input; + try { + input = yaml.parse(examples[2].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(mockGitlabClient.PipelineTriggerTokens.create).toHaveBeenCalledWith( + input.projectId, + input.tokenDescription, + ); + + expect(mockGitlabClient.PipelineTriggerTokens.trigger).toHaveBeenCalledWith( + input.projectId, + 'main', + 'abcdd-ghijkl', + { variables: input.variables }, + ); + + expect(mockGitlabClient.PipelineTriggerTokens.remove).toHaveBeenCalledWith( + input.projectId, + 42, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'pipelineUrl', + 'https://gitlab.com/hangar18-/pipelines/99', + ); + }); + it(`should ${examples[3].description}`, async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + tokenDescription: 'Gitlab sample pipeline', + branch: 'main', + }, + workspacePath: 'repository', + }); + + mockGitlabClient.PipelineTriggerTokens.create.mockResolvedValue({ + id: 42, + description: 'Gitlab sample pipeline', + createdAt: new Date().toISOString(), + last_used: null, + token: 'abcdd-ghijkl', + updated_at: new Date().toISOString(), + owner: null, + }); + + mockGitlabClient.PipelineTriggerTokens.trigger.mockResolvedValue({ + id: 99, + web_url: 'https://gitlab.com/hangar18-/pipelines/99', + }); + + let input; + try { + input = yaml.parse(examples[3].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...input, + }, + }); + + expect(mockGitlabClient.PipelineTriggerTokens.create).toHaveBeenCalledWith( + input.projectId, + input.tokenDescription, + ); + + expect(mockGitlabClient.PipelineTriggerTokens.trigger).toHaveBeenCalledWith( + input.projectId, + 'main', + 'abcdd-ghijkl', + { variables: input.variables }, + ); + + expect(mockGitlabClient.PipelineTriggerTokens.remove).toHaveBeenCalledWith( + input.projectId, + 42, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'pipelineUrl', + 'https://gitlab.com/hangar18-/pipelines/99', + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabPipelineTrigger.examples.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabPipelineTrigger.examples.ts index d0840030df..f213fcd83f 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabPipelineTrigger.examples.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabPipelineTrigger.examples.ts @@ -39,4 +39,67 @@ export const examples: TemplateExample[] = [ ], }), }, + { + description: 'Trigger a GitLab Project Pipeline with No Variables', + example: yaml.stringify({ + steps: [ + { + id: 'triggerPipeline', + name: 'Trigger Project Pipeline', + action: 'gitlab:pipeline:trigger', + input: { + ...commonGitlabConfigExample, + projectId: 12, + tokenDescription: + 'This is the text that will appear in the pipeline token', + token: 'glpt-xxxxxxxxxxxx', + branch: 'main', + variables: {}, + }, + }, + ], + }), + }, + { + description: 'Trigger a GitLab Project Pipeline with Single Variables', + example: yaml.stringify({ + steps: [ + { + id: 'triggerPipeline', + name: 'Trigger Project Pipeline', + action: 'gitlab:pipeline:trigger', + input: { + ...commonGitlabConfigExample, + projectId: 12, + tokenDescription: + 'This is the text that will appear in the pipeline token', + token: 'glpt-xxxxxxxxxxxx', + branch: 'main', + variables: { var_one: 'one' }, + }, + }, + ], + }), + }, + { + description: 'Trigger a GitLab Project Pipeline with Multiple Variables', + example: yaml.stringify({ + steps: [ + { + id: 'triggerPipeline', + name: 'Trigger Project Pipeline', + action: 'gitlab:pipeline:trigger', + input: { + ...commonGitlabConfigExample, + projectId: 12, + tokenDescription: + 'This is the text that will appear in the pipeline token', + token: 'glpt-xxxxxxxxxxxx', + branch: 'main', + variables: { var_one: 'one', var_two: 'two', var_three: 'three' }, + }, + }, + ], + }), + }, ]; From e53074fb0ec07d93025beefa783f52de2ce21b4c Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Sun, 21 Jul 2024 01:44:13 -0400 Subject: [PATCH 111/225] Update default backend plugin to use RootConfigService Signed-off-by: Stephen Glass --- .changeset/heavy-numbers-love.md | 5 +++++ .../cli/templates/default-backend-plugin/package.json.hbs | 1 - .../templates/default-backend-plugin/src/service/router.ts | 5 ++--- 3 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 .changeset/heavy-numbers-love.md diff --git a/.changeset/heavy-numbers-love.md b/.changeset/heavy-numbers-love.md new file mode 100644 index 0000000000..c18d144636 --- /dev/null +++ b/.changeset/heavy-numbers-love.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Updated default backend plugin to use `RootConfigService` instead of `Config`. This also removes the dependency on `@backstage/config` as it's no longer used. diff --git a/packages/cli/templates/default-backend-plugin/package.json.hbs b/packages/cli/templates/default-backend-plugin/package.json.hbs index 3b808fc5e5..8dd31cbc38 100644 --- a/packages/cli/templates/default-backend-plugin/package.json.hbs +++ b/packages/cli/templates/default-backend-plugin/package.json.hbs @@ -31,7 +31,6 @@ "@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}", "@backstage/backend-defaults": "{{versionQuery '@backstage/backend-defaults'}}", "@backstage/backend-plugin-api": "{{versionQuery '@backstage/backend-plugin-api'}}", - "@backstage/config": "{{versionQuery '@backstage/config'}}", "express": "{{versionQuery 'express' '4.17.1'}}", "express-promise-router": "{{versionQuery 'express-promise-router' '4.1.0'}}", "node-fetch": "{{versionQuery 'node-fetch' '2.6.7'}}" diff --git a/packages/cli/templates/default-backend-plugin/src/service/router.ts b/packages/cli/templates/default-backend-plugin/src/service/router.ts index 312d7e252e..ced654aa82 100644 --- a/packages/cli/templates/default-backend-plugin/src/service/router.ts +++ b/packages/cli/templates/default-backend-plugin/src/service/router.ts @@ -1,12 +1,11 @@ import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter'; -import { LoggerService } from '@backstage/backend-plugin-api'; -import { Config } from '@backstage/config'; +import { LoggerService, RootConfigService } from '@backstage/backend-plugin-api'; import express from 'express'; import Router from 'express-promise-router'; export interface RouterOptions { logger: LoggerService; - config: Config; + config: RootConfigService; } export async function createRouter( From ef742dc56e889f254983cd1a0575c0880a104aa0 Mon Sep 17 00:00:00 2001 From: parmar-abhinav Date: Sun, 21 Jul 2024 12:29:54 +0530 Subject: [PATCH 112/225] Added test cases for gitlab:projectAccessToken:create example Signed-off-by: parmar-abhinav --- .changeset/six-rats-kick.md | 5 + ...bProjectAccessTokenCreate.examples.test.ts | 256 ++++++++++++++++++ ...gitlabProjectAccessTokenCreate.examples.ts | 158 +++++++++++ .../gitlabProjectAccessTokenCreate.test.ts | 221 +++++++++++++++ 4 files changed, 640 insertions(+) create mode 100644 .changeset/six-rats-kick.md create mode 100644 plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectAccessTokenCreate.test.ts diff --git a/.changeset/six-rats-kick.md b/.changeset/six-rats-kick.md new file mode 100644 index 0000000000..23958a2502 --- /dev/null +++ b/.changeset/six-rats-kick.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +--- + +Added test cases for gitlab:projectAccessToken:create example diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectAccessTokenCreate.examples.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectAccessTokenCreate.examples.test.ts index 811926cc82..0762212809 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectAccessTokenCreate.examples.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectAccessTokenCreate.examples.test.ts @@ -51,6 +51,10 @@ describe('gitlab:projectAccessToken:create examples', () => { host: 'hosted.gitlab.com', apiBaseUrl: 'https://api.hosted.gitlab.com', }, + { + host: 'gitlab.example.com', + apiBaseUrl: 'https://api.gitlab.example.com', + }, ], }, }); @@ -192,4 +196,256 @@ describe('gitlab:projectAccessToken:create examples', () => { expect(mockContext.output).toHaveBeenCalledWith('access_token', 'TOKEN'); }); + + it(`should ${examples[5].description}`, async () => { + mockGitlabClient.ProjectAccessTokens.create.mockResolvedValue({ + token: 'personal-access-token', + username: 'gitlab-user', + }); + + const input = yaml.parse(examples[5].example).steps[0].input; + await action.handler({ + ...mockContext, + input, + }); + + expect(mockGitlabClient.ProjectAccessTokens.create).toHaveBeenCalledWith( + '456', + 'tokenname', + ['read_repository'], + { + accessLevel: 30, + expiresAt: '2025-07-21', + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'access_token', + 'personal-access-token', + ); + }); + + it(`should ${examples[6].description}`, async () => { + mockGitlabClient.ProjectAccessTokens.create.mockResolvedValue({ + token: 'personal-access-token', + username: 'gitlab-user', + }); + + const input = yaml.parse(examples[6].example).steps[0].input; + await action.handler({ + ...mockContext, + input, + }); + + expect(mockGitlabClient.ProjectAccessTokens.create).toHaveBeenCalledWith( + '456', + 'full-access-token', + ['read_repository'], + { + accessLevel: 40, + expiresAt: '2024-12-31', + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'access_token', + 'personal-access-token', + ); + }); + + it(`should ${examples[7].description}`, async () => { + mockGitlabClient.ProjectAccessTokens.create.mockResolvedValue({ + token: 'personal-access-token', + username: 'gitlab-user', + }); + + const input = yaml.parse(examples[7].example).steps[0].input; + await action.handler({ + ...mockContext, + input, + }); + + expect(mockGitlabClient.ProjectAccessTokens.create).toHaveBeenCalledWith( + '101112', + 'tokenname', + ['read_repository'], + { + accessLevel: 40, + expiresAt: '2025-07-21', + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'access_token', + 'personal-access-token', + ); + }); + + it(`should ${examples[8].description}`, async () => { + mockGitlabClient.ProjectAccessTokens.create.mockResolvedValue({ + token: 'personal-access-token', + username: 'gitlab-user', + }); + + const input = yaml.parse(examples[8].example).steps[0].input; + await action.handler({ + ...mockContext, + input, + }); + + expect(mockGitlabClient.ProjectAccessTokens.create).toHaveBeenCalledWith( + '101112', + 'tokenname', + ['read_repository', 'read_api'], + { + accessLevel: 40, + expiresAt: '2025-07-21', + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'access_token', + 'personal-access-token', + ); + }); + + it(`should ${examples[9].description}`, async () => { + mockGitlabClient.ProjectAccessTokens.create.mockResolvedValue({ + token: 'personal-access-token', + username: 'gitlab-user', + }); + + const input = yaml.parse(examples[9].example).steps[0].input; + await action.handler({ + ...mockContext, + input, + }); + + expect(mockGitlabClient.ProjectAccessTokens.create).toHaveBeenCalledWith( + '101112', + 'tokenname', + ['read_repository'], + { + accessLevel: 10, + expiresAt: '2025-07-21', + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'access_token', + 'personal-access-token', + ); + }); + + it(`should ${examples[10].description}`, async () => { + mockGitlabClient.ProjectAccessTokens.create.mockResolvedValue({ + token: 'personal-access-token', + username: 'gitlab-user', + }); + + const input = yaml.parse(examples[10].example).steps[0].input; + await action.handler({ + ...mockContext, + input, + }); + + expect(mockGitlabClient.ProjectAccessTokens.create).toHaveBeenCalledWith( + '101112', + 'tokenname', + ['read_repository'], + { + accessLevel: 40, + expiresAt: '2025-07-21', + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'access_token', + 'personal-access-token', + ); + }); + + it(`should ${examples[11].description}`, async () => { + mockGitlabClient.ProjectAccessTokens.create.mockResolvedValue({ + token: 'personal-access-token', + username: 'gitlab-user', + }); + + const input = yaml.parse(examples[11].example).steps[0].input; + await action.handler({ + ...mockContext, + input, + }); + + expect(mockGitlabClient.ProjectAccessTokens.create).toHaveBeenCalledWith( + '101112', + 'tokenname', + ['read_repository'], + { + accessLevel: 50, + expiresAt: '2025-07-21', + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'access_token', + 'personal-access-token', + ); + }); + + it(`should ${examples[12].description}`, async () => { + mockGitlabClient.ProjectAccessTokens.create.mockResolvedValue({ + token: 'personal-access-token', + username: 'gitlab-user', + }); + + const input = yaml.parse(examples[12].example).steps[0].input; + await action.handler({ + ...mockContext, + input, + }); + + expect(mockGitlabClient.ProjectAccessTokens.create).toHaveBeenCalledWith( + '101112', + 'no-expiry-token', + ['read_repository'], + { + accessLevel: 40, + expiresAt: '2025-07-21', + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'access_token', + 'personal-access-token', + ); + }); + + it(`should ${examples[13].description}`, async () => { + mockGitlabClient.ProjectAccessTokens.create.mockResolvedValue({ + token: 'personal-access-token', + username: 'gitlab-user', + }); + + const input = yaml.parse(examples[13].example).steps[0].input; + await action.handler({ + ...mockContext, + input, + }); + + expect(mockGitlabClient.ProjectAccessTokens.create).toHaveBeenCalledWith( + '101112', + 'tokenname', + ['read_repository'], + { + accessLevel: 40, + expiresAt: '2025-07-21', + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'access_token', + 'personal-access-token', + ); + }); }); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectAccessTokenCreate.examples.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectAccessTokenCreate.examples.ts index 8d4f335a46..89f8ae6269 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectAccessTokenCreate.examples.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectAccessTokenCreate.examples.ts @@ -102,4 +102,162 @@ export const examples: TemplateExample[] = [ ], }), }, + { + description: 'Create a GitLab project access token with an access level', + example: yaml.stringify({ + steps: [ + { + id: 'createAccessToken', + action: 'gitlab:projectAccessToken:create', + name: 'Create GitLab Project Access Token', + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: '456', + accessLevel: 30, + }, + }, + ], + }), + }, + { + description: 'Create a GitLab project access token with multiple options', + example: yaml.stringify({ + steps: [ + { + id: 'createAccessToken', + action: 'gitlab:projectAccessToken:create', + name: 'Create GitLab Project Access Token', + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: '456', + accessLevel: 40, + name: 'full-access-token', + expiresAt: '2024-12-31', + }, + }, + ], + }), + }, + { + description: + 'Create a GitLab project access token with a token for authorization', + example: yaml.stringify({ + steps: [ + { + id: 'createAccessToken', + action: 'gitlab:projectAccessToken:create', + name: 'Create GitLab Project Access Token', + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: '101112', + token: 'personal-access-token', + }, + }, + ], + }), + }, + { + description: 'Create a GitLab project access token with read-only scopes', + example: yaml.stringify({ + steps: [ + { + id: 'createAccessToken', + action: 'gitlab:projectAccessToken:create', + name: 'Create GitLab Project Access Token', + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: '101112', + scopes: ['read_repository', 'read_api'], + }, + }, + ], + }), + }, + { + description: 'Create a GitLab project access token with guest access level', + example: yaml.stringify({ + steps: [ + { + id: 'createAccessToken', + action: 'gitlab:projectAccessToken:create', + name: 'Create GitLab Project Access Token', + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: '101112', + accessLevel: 10, + }, + }, + ], + }), + }, + { + description: + 'Create a GitLab project access token with maintainer access level', + example: yaml.stringify({ + steps: [ + { + id: 'createAccessToken', + action: 'gitlab:projectAccessToken:create', + name: 'Create GitLab Project Access Token', + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: '101112', + accessLevel: 40, + }, + }, + ], + }), + }, + { + description: 'Create a GitLab project access token with owner access level', + example: yaml.stringify({ + steps: [ + { + id: 'createAccessToken', + action: 'gitlab:projectAccessToken:create', + name: 'Create GitLab Project Access Token', + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: '101112', + accessLevel: 50, + }, + }, + ], + }), + }, + { + description: + 'Create a GitLab project access token with a specified name and no expiration date', + example: yaml.stringify({ + steps: [ + { + id: 'createAccessToken', + action: 'gitlab:projectAccessToken:create', + name: 'Create GitLab Project Access Token', + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: '101112', + name: 'no-expiry-token', + }, + }, + ], + }), + }, + { + description: + 'Create a GitLab project access token for a specific gitlab instance', + example: yaml.stringify({ + steps: [ + { + id: 'createAccessToken', + action: 'gitlab:projectAccessToken:create', + name: 'Create GitLab Project Access Token', + input: { + repoUrl: 'gitlab.example.com?repo=repo&owner=owner', + projectId: '101112', + }, + }, + ], + }), + }, ]; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectAccessTokenCreate.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectAccessTokenCreate.test.ts new file mode 100644 index 0000000000..34ce8878f2 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectAccessTokenCreate.test.ts @@ -0,0 +1,221 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { ScmIntegrations } from '@backstage/integration'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import { createGitlabProjectAccessTokenAction } from './gitlabProjectAccessTokenCreate'; // Adjust the import based on your project structure + +import { DateTime } from 'luxon'; + +jest.mock('node-fetch'); + +const mockGitlabClient = { + ProjectAccessTokens: { + create: jest.fn(), + }, +}; + +jest.mock('@gitbeaker/rest', () => ({ + Gitlab: class { + constructor() { + return mockGitlabClient; + } + }, +})); + +describe('gitlab:projectAccessToken:create examples', () => { + const config = new ConfigReader({ + integrations: { + gitlab: [ + { + host: 'gitlab.com', + token: 'gitlab-token', + apiBaseUrl: 'https://api.gitlab.com', + }, + { + host: 'hosted.gitlab.com', + apiBaseUrl: 'https://api.hosted.gitlab.com', + }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + const action = createGitlabProjectAccessTokenAction({ integrations }); + + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + }, + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should create a GitLab project access token with minimal options.', async () => { + mockGitlabClient.ProjectAccessTokens.create.mockResolvedValue({ + token: 'gitlab-token', + username: 'gitlab-user', + }); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: '987', + }, + }); + + expect(mockGitlabClient.ProjectAccessTokens.create).toHaveBeenCalledWith( + '987', + 'tokenname', + ['read_repository'], + { + accessLevel: 40, + expiresAt: DateTime.now().plus({ days: 365 }).toISODate()!, + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'access_token', + 'gitlab-token', + ); + }); + + it('should create a GitLab project access token with custom scopes.', async () => { + mockGitlabClient.ProjectAccessTokens.create.mockResolvedValue({ + token: 'gitlab-token', + username: 'gitlab-user', + }); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: '987', + scopes: ['read_registry', 'write_repository'], + }, + }); + + expect(mockGitlabClient.ProjectAccessTokens.create).toHaveBeenCalledWith( + '987', + 'tokenname', + ['read_registry', 'write_repository'], + { + accessLevel: 40, + expiresAt: DateTime.now().plus({ days: 365 }).toISODate()!, + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'access_token', + 'gitlab-token', + ); + }); + + it('should create a GitLab project access token with a specified name.', async () => { + mockGitlabClient.ProjectAccessTokens.create.mockResolvedValue({ + token: 'gitlab-token', + username: 'gitlab-user', + }); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: '2110', + name: 'token', + }, + }); + + expect(mockGitlabClient.ProjectAccessTokens.create).toHaveBeenCalledWith( + '2110', + 'token', + ['read_repository'], + { + accessLevel: 40, + expiresAt: DateTime.now().plus({ days: 365 }).toISODate()!, + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'access_token', + 'gitlab-token', + ); + }); + + it('should create a GitLab project access token with a numeric project ID.', async () => { + mockGitlabClient.ProjectAccessTokens.create.mockResolvedValue({ + token: 'gitlab-token', + username: 'gitlab-user', + }); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 23, + }, + }); + + expect(mockGitlabClient.ProjectAccessTokens.create).toHaveBeenCalledWith( + 23, + 'tokenname', + ['read_repository'], + { + accessLevel: 40, + expiresAt: DateTime.now().plus({ days: 365 }).toISODate()!, + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'access_token', + 'gitlab-token', + ); + }); + + it('should create a GitLab project access token with a specified expired Date.', async () => { + mockGitlabClient.ProjectAccessTokens.create.mockResolvedValue({ + token: 'gitlab-token', + username: 'gitlab-user', + }); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: '123', + expiresAt: '1999-07-14', + }, + }); + + expect(mockGitlabClient.ProjectAccessTokens.create).toHaveBeenCalledWith( + '123', + 'tokenname', + ['read_repository'], + { + accessLevel: 40, + expiresAt: '1999-07-14', + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'access_token', + 'gitlab-token', + ); + }); +}); From fba7537ad1fd83d9578d71ea546b36b3cf1be9f3 Mon Sep 17 00:00:00 2001 From: Jordan Slott Date: Sun, 21 Jul 2024 15:31:35 -0400 Subject: [PATCH 113/225] Fixes #25709 Memoize filtered entity graph nodes Signed-off-by: Jordan Slott --- .changeset/light-pianos-exercise.md | 5 +++++ .../EntityRelationsGraph/useEntityRelationGraph.ts | 12 ++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 .changeset/light-pianos-exercise.md diff --git a/.changeset/light-pianos-exercise.md b/.changeset/light-pianos-exercise.md new file mode 100644 index 0000000000..4bf161eb06 --- /dev/null +++ b/.changeset/light-pianos-exercise.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-graph': patch +--- + +Memoize entity graph nodes when applying an `entityFilter` to prevent repeated redraws diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.ts index af2a491ada..07e3085f1f 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { Entity } from '@backstage/catalog-model'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { useEntityStore } from './useEntityStore'; import { pickBy } from 'lodash'; @@ -104,9 +104,13 @@ export function useEntityRelationGraph({ requestEntities, ]); - const filteredEntities = entityFilter - ? pickBy(entities, (value, _key) => entityFilter(value)) - : entities; + const filteredEntities = useMemo( + () => + entityFilter + ? pickBy(entities, (value, _key) => entityFilter(value)) + : entities, + [entities, entityFilter], + ); return { entities: filteredEntities, From 4e79d199ccb03c1b57dd4b889b83c058b13fd399 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 17 Jul 2024 19:59:42 +0200 Subject: [PATCH 114/225] backend-defaults,test-utils: use, export, and test health service Signed-off-by: Patrik Oldsberg --- .changeset/gentle-dryers-smile.md | 5 ++ .changeset/soft-gorillas-refuse.md | 5 ++ .../api-report-rootHttpRouter.md | 8 ++ .../rootHttpRouter/createHealthRouter.ts | 6 +- .../src/entrypoints/rootHttpRouter/index.ts | 1 + .../rootHttpRouterServiceFactory.test.ts | 74 +++++++++++++++++++ .../src/next/wiring/TestBackend.test.ts | 9 +++ .../src/next/wiring/TestBackend.ts | 7 +- 8 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 .changeset/gentle-dryers-smile.md create mode 100644 .changeset/soft-gorillas-refuse.md create mode 100644 packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.test.ts diff --git a/.changeset/gentle-dryers-smile.md b/.changeset/gentle-dryers-smile.md new file mode 100644 index 0000000000..b061461da1 --- /dev/null +++ b/.changeset/gentle-dryers-smile.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +The `createHealthRouter` utility that allows you to create a health check router is now exported via `@backstage/backend-defaults/rootHttpRouter`. diff --git a/.changeset/soft-gorillas-refuse.md b/.changeset/soft-gorillas-refuse.md new file mode 100644 index 0000000000..323c4d20d2 --- /dev/null +++ b/.changeset/soft-gorillas-refuse.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': patch +--- + +The default services for `startTestBackend` and `ServiceFactoryTester` now includes the Root Health Service. diff --git a/packages/backend-defaults/api-report-rootHttpRouter.md b/packages/backend-defaults/api-report-rootHttpRouter.md index 329398a7b8..36f3aa3ab6 100644 --- a/packages/backend-defaults/api-report-rootHttpRouter.md +++ b/packages/backend-defaults/api-report-rootHttpRouter.md @@ -3,6 +3,7 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +/// /// import { Config } from '@backstage/config'; @@ -17,10 +18,17 @@ import { LoggerService } from '@backstage/backend-plugin-api'; import { RequestHandler } from 'express'; import { RequestListener } from 'http'; import { RootConfigService } from '@backstage/backend-plugin-api'; +import { RootHealthService } from '@backstage/backend-plugin-api'; import { RootHttpRouterService } from '@backstage/backend-plugin-api'; +import { Router } from 'express'; import type { Server } from 'node:http'; import { ServiceFactory } from '@backstage/backend-plugin-api'; +// @public (undocumented) +export function createHealthRouter(options: { + health: RootHealthService; +}): Router; + // @public export function createHttpServer( listener: RequestListener, diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/createHealthRouter.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/createHealthRouter.ts index 007d1dd9cd..3772ab0fab 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/createHealthRouter.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/createHealthRouter.ts @@ -1,5 +1,3 @@ -import { RootHealthService } from '@backstage/backend-plugin-api'; - /* * Copyright 2024 The Backstage Authors * @@ -16,9 +14,13 @@ import { RootHealthService } from '@backstage/backend-plugin-api'; * limitations under the License. */ +import { RootHealthService } from '@backstage/backend-plugin-api'; import Router from 'express-promise-router'; import { Request, Response } from 'express'; +/** + * @public + */ export function createHealthRouter(options: { health: RootHealthService }) { const router = Router(); diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/index.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/index.ts index 9c2d96dab2..e9aee8a0e0 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/index.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/index.ts @@ -18,6 +18,7 @@ export { DefaultRootHttpRouter, type DefaultRootHttpRouterOptions, } from './DefaultRootHttpRouter'; +export { createHealthRouter } from './createHealthRouter'; export * from './http'; export { rootHttpRouterServiceFactory, diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.test.ts new file mode 100644 index 0000000000..dea0159dff --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.test.ts @@ -0,0 +1,74 @@ +/* + * 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 { + ServiceFactoryTester, + mockServices, +} from '@backstage/backend-test-utils'; +import { Express } from 'express'; +import request from 'supertest'; +import { rootHttpRouterServiceFactory } from './rootHttpRouterServiceFactory'; +import { coreServices } from '@backstage/backend-plugin-api'; + +describe('rootHttpRouterServiceFactory', () => { + it('should make the health endpoints available', async () => { + let app: Express | undefined = undefined; + + const tester = ServiceFactoryTester.from( + rootHttpRouterServiceFactory({ + configure(options) { + options.applyDefaults(); + app = options.app; + }, + }), + { + dependencies: [ + mockServices.rootConfig.factory({ + data: { + app: { baseUrl: 'http://localhost' }, + backend: { + baseUrl: 'http://localhost', + listen: { host: '', port: 0 }, + }, + }, + }), + ], + }, + ); + + // Trigger creation of the http service, accessing the app instance through the configure callback + await tester.getSubject(); + + await request(app!) + .get('/.backstage/health/v1/liveness') + .expect(200, { status: 'ok' }); + + await request(app!).get('/.backstage/health/v1/readiness').expect(503, { + message: 'Backend has not started yet', + status: 'error', + }); + + const lifecycle = await tester.getService(coreServices.rootLifecycle); + + await (lifecycle as any).startup(); // Trigger startup by calling the private startup method + + await request(app!).get('/.backstage/health/v1/readiness').expect(200, { + status: 'ok', + }); + + expect('test').toBe('test'); + }); +}); diff --git a/packages/backend-test-utils/src/next/wiring/TestBackend.test.ts b/packages/backend-test-utils/src/next/wiring/TestBackend.test.ts index ecba34290d..a01d4bebf5 100644 --- a/packages/backend-test-utils/src/next/wiring/TestBackend.test.ts +++ b/packages/backend-test-utils/src/next/wiring/TestBackend.test.ts @@ -242,6 +242,15 @@ describe('TestBackend', () => { expect(res.body).toEqual({ message: 'pong' }); }); + it('should expose health check endpoints', async () => { + const { server } = await startTestBackend({ features: [] }); + + const res = await request(server).get('/.backstage/health/v1/liveness'); + + expect(res.status).toEqual(200); + expect(res.body).toEqual({ status: 'ok' }); + }); + it('should provide extension point implementations', async () => { expect.assertions(3); diff --git a/packages/backend-test-utils/src/next/wiring/TestBackend.ts b/packages/backend-test-utils/src/next/wiring/TestBackend.ts index 16ee6b78d2..3d662a8c12 100644 --- a/packages/backend-test-utils/src/next/wiring/TestBackend.ts +++ b/packages/backend-test-utils/src/next/wiring/TestBackend.ts @@ -37,6 +37,7 @@ import express from 'express'; // Direct internal import to avoid duplication // eslint-disable-next-line @backstage/no-forbidden-package-imports import { InternalBackendFeature } from '@backstage/backend-plugin-api/src/wiring/types'; +import { createHealthRouter } from '@backstage/backend-defaults/rootHttpRouter'; /** @public */ export interface TestBackendOptions { @@ -73,6 +74,7 @@ export const defaultServiceFactories = [ mockServices.lifecycle.factory(), mockServices.logger.factory(), mockServices.permissions.factory(), + mockServices.rootHealth.factory(), mockServices.rootLifecycle.factory(), mockServices.rootLogger.factory(), mockServices.scheduler.factory(), @@ -251,15 +253,18 @@ export async function startTestBackend( config: coreServices.rootConfig, lifecycle: coreServices.rootLifecycle, rootLogger: coreServices.rootLogger, + health: coreServices.rootHealth, }, - async factory({ config, lifecycle, rootLogger }) { + async factory({ config, lifecycle, rootLogger, health }) { const router = DefaultRootHttpRouter.create(); const logger = rootLogger.child({ service: 'rootHttpRouter' }); const app = express(); const middleware = MiddlewareFactory.create({ config, logger }); + const healthRouter = createHealthRouter({ health }); + app.use(healthRouter); app.use(router.handler()); app.use(middleware.notFound()); app.use(middleware.error()); From 31bfc4417f7a4d04d5899ce1d638505c30f9f627 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 20 Jul 2024 12:47:09 +0200 Subject: [PATCH 115/225] frontend-plugin-api: update extension data ref declaration to allow embedding of ID in type Signed-off-by: Patrik Oldsberg --- .changeset/dull-ghosts-double.md | 7 ++ .changeset/tiny-oranges-pretend.md | 19 ++++++ .../architecture/03-extensions.md | 4 +- .../architecture/08-naming-patterns.md | 20 +++--- .../src/tree/instantiateAppNodeTree.test.ts | 8 ++- packages/frontend-plugin-api/api-report.md | 52 +++++++++++--- .../src/extensions/createApiExtension.ts | 5 +- .../createAppRootWrapperExtension.tsx | 7 +- .../extensions/createComponentExtension.tsx | 2 +- .../src/extensions/createNavItemExtension.tsx | 2 +- .../src/extensions/createNavLogoExtension.tsx | 2 +- .../src/extensions/createRouterExtension.tsx | 7 +- .../extensions/createSignInPageExtension.tsx | 2 +- .../src/extensions/createThemeExtension.ts | 5 +- .../extensions/createTranslationExtension.ts | 2 +- .../src/wiring/coreExtensionData.ts | 8 ++- .../src/wiring/createExtension.test.ts | 2 +- .../src/wiring/createExtension.ts | 2 +- .../src/wiring/createExtensionDataRef.test.ts | 9 +++ .../src/wiring/createExtensionDataRef.ts | 68 +++++++++++++------ .../src/wiring/createPlugin.test.ts | 4 +- plugins/catalog-react/api-report-alpha.md | 13 +++- plugins/catalog-react/src/alpha.tsx | 18 ++--- plugins/home/api-report-alpha.md | 6 +- plugins/home/src/alpha.tsx | 4 +- plugins/search-react/api-report-alpha.md | 1 + plugins/search-react/src/alpha.tsx | 2 +- 27 files changed, 204 insertions(+), 77 deletions(-) create mode 100644 .changeset/dull-ghosts-double.md create mode 100644 .changeset/tiny-oranges-pretend.md diff --git a/.changeset/dull-ghosts-double.md b/.changeset/dull-ghosts-double.md new file mode 100644 index 0000000000..0bc5a09791 --- /dev/null +++ b/.changeset/dull-ghosts-double.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-catalog-react': patch +'@backstage/plugin-search-react': patch +'@backstage/plugin-home': patch +--- + +Updated alpha definitions of extension data references. diff --git a/.changeset/tiny-oranges-pretend.md b/.changeset/tiny-oranges-pretend.md new file mode 100644 index 0000000000..87227fb962 --- /dev/null +++ b/.changeset/tiny-oranges-pretend.md @@ -0,0 +1,19 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Extension data references can now be defined in a way that encapsulates the ID string in the type, in addition to the data type itself. The old way of creating extension data references is deprecated and will be removed in a future release. + +For example, the following code: + +```ts +export const myExtension = createExtensionDataRef('my-plugin.my-data'); +``` + +Should be updated to the following: + +```ts +export const myExtension = createExtensionDataRef().with({ + id: 'my-plugin.my-data', +}); +``` diff --git a/docs/frontend-system/architecture/03-extensions.md b/docs/frontend-system/architecture/03-extensions.md index cdc73a17ec..2f3daba6f8 100644 --- a/docs/frontend-system/architecture/03-extensions.md +++ b/docs/frontend-system/architecture/03-extensions.md @@ -91,7 +91,9 @@ To create a new extension data reference to represent a type of shared extension ```ts export const reactElementExtensionDataRef = - createExtensionDataRef('my-plugin.reactElement'); + createExtensionDataRef().with({ + id: 'my-plugin.reactElement', + }); ``` The `ExtensionDataRef` can then be used to describe an output property of the extension. This will enforce typing on the return value of the extension factory: diff --git a/docs/frontend-system/architecture/08-naming-patterns.md b/docs/frontend-system/architecture/08-naming-patterns.md index a17525f3ce..f031b80d84 100644 --- a/docs/frontend-system/architecture/08-naming-patterns.md +++ b/docs/frontend-system/architecture/08-naming-patterns.md @@ -98,9 +98,9 @@ export interface SearchResultItemExtensionData { } export const searchResultItemExtensionDataRef = - createExtensionDataRef( - 'search.search-result-item', - ); + createExtensionDataRef().with({ + id: 'search.search-result-item', + }); ``` #### Grouped Extension Data @@ -109,8 +109,12 @@ This way of defining extension data is similar to the standalone way, but it use ```ts export const coreExtensionData = { - reactElement: createExtensionDataRef('core.react-element'), - routePath: createExtensionDataRef('core.route-path'), + reactElement: createExtensionDataRef().with({ + id: 'core.react-element', + }), + routePath: createExtensionDataRef().with({ + id: 'core.route-path', + }), }; ``` @@ -125,9 +129,9 @@ export function createGraphiQLEndpointExtension(options) { // Use a TypeScript namespace to merge the extension data references with the extension creator export namespace createGraphiQLEndpointExtension { - export const endpointDataRef = createExtensionDataRef( - 'graphiql.graphiql-endpoint.endpoint', - ); + export const endpointDataRef = createExtensionDataRef().with({ + id: 'graphiql.graphiql-endpoint.endpoint', + }); } ``` diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts index 3330b9add8..c5c66e8319 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts @@ -32,9 +32,11 @@ import { resolveAppTree } from './resolveAppTree'; import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; import { withLogCollector } from '@backstage/test-utils'; -const testDataRef = createExtensionDataRef('test'); -const otherDataRef = createExtensionDataRef('other'); -const inputMirrorDataRef = createExtensionDataRef('mirror'); +const testDataRef = createExtensionDataRef().with({ id: 'test' }); +const otherDataRef = createExtensionDataRef().with({ id: 'other' }); +const inputMirrorDataRef = createExtensionDataRef().with({ + id: 'mirror', +}); const simpleExtension = resolveExtensionDefinition( createExtension({ diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 29c165aaaa..65445b5c4d 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -151,6 +151,7 @@ export { AnyApiRef }; export type AnyExtensionDataMap = { [name in string]: ExtensionDataRef< unknown, + string, { optional?: true; } @@ -317,13 +318,15 @@ export { configApiRef }; // @public (undocumented) export interface ConfigurableExtensionDataRef< + TId extends string, TData, TConfig extends { optional?: true; } = {}, -> extends ExtensionDataRef { +> extends ExtensionDataRef { // (undocumented) optional(): ConfigurableExtensionDataRef< + TId, TData, TData & { optional: true; @@ -347,9 +350,17 @@ export type CoreErrorBoundaryFallbackProps = { // @public (undocumented) export const coreExtensionData: { - reactElement: ConfigurableExtensionDataRef; - routePath: ConfigurableExtensionDataRef; - routeRef: ConfigurableExtensionDataRef, {}>; + reactElement: ConfigurableExtensionDataRef< + 'core.reactElement', + JSX_2.Element, + {} + >; + routePath: ConfigurableExtensionDataRef<'core.routing.path', string, {}>; + routeRef: ConfigurableExtensionDataRef< + 'core.routing.ref', + RouteRef, + {} + >; }; // @public (undocumented) @@ -385,7 +396,11 @@ export function createApiExtension< // @public (undocumented) export namespace createApiExtension { const // (undocumented) - factoryDataRef: ConfigurableExtensionDataRef; + factoryDataRef: ConfigurableExtensionDataRef< + 'core.api.factory', + AnyApiFactory, + {} + >; } export { createApiFactory }; @@ -440,6 +455,7 @@ export function createAppRootWrapperExtension< export namespace createAppRootWrapperExtension { const // (undocumented) componentDataRef: ConfigurableExtensionDataRef< + 'app.root.wrapper', React_2.ComponentType<{ children?: React_2.ReactNode; }>, @@ -477,6 +493,7 @@ export function createComponentExtension< export namespace createComponentExtension { const // (undocumented) componentDataRef: ConfigurableExtensionDataRef< + 'core.component.component', { ref: ComponentRef; impl: ComponentType; @@ -544,10 +561,17 @@ export interface CreateExtensionBlueprintOptions< output: TOutput; } -// @public (undocumented) +// @public @deprecated (undocumented) export function createExtensionDataRef( id: string, -): ConfigurableExtensionDataRef; +): ConfigurableExtensionDataRef; + +// @public (undocumented) +export function createExtensionDataRef(): { + with(options: { + id: TId; + }): ConfigurableExtensionDataRef; +}; // @public (undocumented) export function createExtensionInput< @@ -646,6 +670,7 @@ export function createNavItemExtension(options: { export namespace createNavItemExtension { const // (undocumented) targetDataRef: ConfigurableExtensionDataRef< + 'core.nav-item.target', { title: string; icon: IconComponent_2; @@ -667,6 +692,7 @@ export function createNavLogoExtension(options: { export namespace createNavLogoExtension { const // (undocumented) logoElementsDataRef: ConfigurableExtensionDataRef< + 'core.nav-logo.logo-elements', { logoIcon?: JSX.Element | undefined; logoFull?: JSX.Element | undefined; @@ -760,6 +786,7 @@ export function createRouterExtension< export namespace createRouterExtension { const // (undocumented) componentDataRef: ConfigurableExtensionDataRef< + 'app.router.wrapper', React_2.ComponentType<{ children?: React_2.ReactNode; }>, @@ -796,6 +823,7 @@ export function createSignInPageExtension< export namespace createSignInPageExtension { const // (undocumented) componentDataRef: ConfigurableExtensionDataRef< + 'core.sign-in-page.component', React_2.ComponentType, {} >; @@ -818,7 +846,11 @@ export function createThemeExtension( // @public (undocumented) export namespace createThemeExtension { const // (undocumented) - themeDataRef: ConfigurableExtensionDataRef; + themeDataRef: ConfigurableExtensionDataRef< + 'core.theme.theme', + AppTheme, + {} + >; } // @public (undocumented) @@ -831,6 +863,7 @@ export function createTranslationExtension(options: { export namespace createTranslationExtension { const // (undocumented) translationDataRef: ConfigurableExtensionDataRef< + 'core.translation.translation', | TranslationResource | TranslationMessages< string, @@ -934,11 +967,12 @@ export interface ExtensionBoundaryProps { // @public (undocumented) export type ExtensionDataRef< TData, + TId extends string = string, TConfig extends { optional?: true; } = {}, > = { - id: string; + id: TId; T: TData; config: TConfig; $$type: '@backstage/ExtensionDataRef'; diff --git a/packages/frontend-plugin-api/src/extensions/createApiExtension.ts b/packages/frontend-plugin-api/src/extensions/createApiExtension.ts index 1680332960..93346f1ec9 100644 --- a/packages/frontend-plugin-api/src/extensions/createApiExtension.ts +++ b/packages/frontend-plugin-api/src/extensions/createApiExtension.ts @@ -72,6 +72,7 @@ export function createApiExtension< /** @public */ export namespace createApiExtension { - export const factoryDataRef = - createExtensionDataRef('core.api.factory'); + export const factoryDataRef = createExtensionDataRef().with({ + id: 'core.api.factory', + }); } diff --git a/packages/frontend-plugin-api/src/extensions/createAppRootWrapperExtension.tsx b/packages/frontend-plugin-api/src/extensions/createAppRootWrapperExtension.tsx index bbc51806af..ce2eb89f25 100644 --- a/packages/frontend-plugin-api/src/extensions/createAppRootWrapperExtension.tsx +++ b/packages/frontend-plugin-api/src/extensions/createAppRootWrapperExtension.tsx @@ -77,8 +77,7 @@ export function createAppRootWrapperExtension< /** @public */ export namespace createAppRootWrapperExtension { - export const componentDataRef = - createExtensionDataRef>>( - 'app.root.wrapper', - ); + export const componentDataRef = createExtensionDataRef< + ComponentType> + >().with({ id: 'app.root.wrapper' }); } diff --git a/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx b/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx index a1231d8353..a40ee3b069 100644 --- a/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx +++ b/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx @@ -92,5 +92,5 @@ export namespace createComponentExtension { export const componentDataRef = createExtensionDataRef<{ ref: ComponentRef; impl: ComponentType; - }>('core.component.component'); + }>().with({ id: 'core.component.component' }); } diff --git a/packages/frontend-plugin-api/src/extensions/createNavItemExtension.tsx b/packages/frontend-plugin-api/src/extensions/createNavItemExtension.tsx index 4b92d4dace..2a2c47835a 100644 --- a/packages/frontend-plugin-api/src/extensions/createNavItemExtension.tsx +++ b/packages/frontend-plugin-api/src/extensions/createNavItemExtension.tsx @@ -61,5 +61,5 @@ export namespace createNavItemExtension { title: string; icon: IconComponent; routeRef: RouteRef; - }>('core.nav-item.target'); + }>().with({ id: 'core.nav-item.target' }); } diff --git a/packages/frontend-plugin-api/src/extensions/createNavLogoExtension.tsx b/packages/frontend-plugin-api/src/extensions/createNavLogoExtension.tsx index 3d66b26d6b..1f2419bd02 100644 --- a/packages/frontend-plugin-api/src/extensions/createNavLogoExtension.tsx +++ b/packages/frontend-plugin-api/src/extensions/createNavLogoExtension.tsx @@ -51,5 +51,5 @@ export namespace createNavLogoExtension { export const logoElementsDataRef = createExtensionDataRef<{ logoIcon?: JSX.Element; logoFull?: JSX.Element; - }>('core.nav-logo.logo-elements'); + }>().with({ id: 'core.nav-logo.logo-elements' }); } diff --git a/packages/frontend-plugin-api/src/extensions/createRouterExtension.tsx b/packages/frontend-plugin-api/src/extensions/createRouterExtension.tsx index 32d61f7e60..12f946e8a7 100644 --- a/packages/frontend-plugin-api/src/extensions/createRouterExtension.tsx +++ b/packages/frontend-plugin-api/src/extensions/createRouterExtension.tsx @@ -77,8 +77,7 @@ export function createRouterExtension< /** @public */ export namespace createRouterExtension { - export const componentDataRef = - createExtensionDataRef>>( - 'app.router.wrapper', - ); + export const componentDataRef = createExtensionDataRef< + ComponentType> + >().with({ id: 'app.router.wrapper' }); } diff --git a/packages/frontend-plugin-api/src/extensions/createSignInPageExtension.tsx b/packages/frontend-plugin-api/src/extensions/createSignInPageExtension.tsx index b525cc3a7b..312d30c78c 100644 --- a/packages/frontend-plugin-api/src/extensions/createSignInPageExtension.tsx +++ b/packages/frontend-plugin-api/src/extensions/createSignInPageExtension.tsx @@ -79,5 +79,5 @@ export function createSignInPageExtension< export namespace createSignInPageExtension { export const componentDataRef = createExtensionDataRef< ComponentType - >('core.sign-in-page.component'); + >().with({ id: 'core.sign-in-page.component' }); } diff --git a/packages/frontend-plugin-api/src/extensions/createThemeExtension.ts b/packages/frontend-plugin-api/src/extensions/createThemeExtension.ts index cc6dbdabe9..1740f05a5a 100644 --- a/packages/frontend-plugin-api/src/extensions/createThemeExtension.ts +++ b/packages/frontend-plugin-api/src/extensions/createThemeExtension.ts @@ -33,6 +33,7 @@ export function createThemeExtension(theme: AppTheme) { /** @public */ export namespace createThemeExtension { - export const themeDataRef = - createExtensionDataRef('core.theme.theme'); + export const themeDataRef = createExtensionDataRef().with({ + id: 'core.theme.theme', + }); } diff --git a/packages/frontend-plugin-api/src/extensions/createTranslationExtension.ts b/packages/frontend-plugin-api/src/extensions/createTranslationExtension.ts index 8497a64b88..9445e32e24 100644 --- a/packages/frontend-plugin-api/src/extensions/createTranslationExtension.ts +++ b/packages/frontend-plugin-api/src/extensions/createTranslationExtension.ts @@ -38,5 +38,5 @@ export function createTranslationExtension(options: { export namespace createTranslationExtension { export const translationDataRef = createExtensionDataRef< TranslationResource | TranslationMessages - >('core.translation.translation'); + >().with({ id: 'core.translation.translation' }); } diff --git a/packages/frontend-plugin-api/src/wiring/coreExtensionData.ts b/packages/frontend-plugin-api/src/wiring/coreExtensionData.ts index a6ff41f6b0..cb48cb8df3 100644 --- a/packages/frontend-plugin-api/src/wiring/coreExtensionData.ts +++ b/packages/frontend-plugin-api/src/wiring/coreExtensionData.ts @@ -20,7 +20,9 @@ import { createExtensionDataRef } from './createExtensionDataRef'; /** @public */ export const coreExtensionData = { - reactElement: createExtensionDataRef('core.reactElement'), - routePath: createExtensionDataRef('core.routing.path'), - routeRef: createExtensionDataRef('core.routing.ref'), + reactElement: createExtensionDataRef().with({ + id: 'core.reactElement', + }), + routePath: createExtensionDataRef().with({ id: 'core.routing.path' }), + routeRef: createExtensionDataRef().with({ id: 'core.routing.ref' }), }; diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts index 42ab21db80..4323737540 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts @@ -18,7 +18,7 @@ import { createExtension } from './createExtension'; import { createExtensionDataRef } from './createExtensionDataRef'; import { createExtensionInput } from './createExtensionInput'; -const stringData = createExtensionDataRef('string'); +const stringData = createExtensionDataRef().with({ id: 'string' }); function unused(..._any: any[]) {} diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 28dc6a4e29..e87f3a638e 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -22,7 +22,7 @@ import { ExtensionInput } from './createExtensionInput'; /** @public */ export type AnyExtensionDataMap = { - [name in string]: ExtensionDataRef; + [name in string]: ExtensionDataRef; }; /** @public */ diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.test.ts b/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.test.ts index f7a0dc82d0..f979a7def0 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.test.ts @@ -18,6 +18,15 @@ import { createExtensionDataRef } from './createExtensionDataRef'; describe('createExtensionDataRef', () => { it('can be created and read', () => { + const ref = createExtensionDataRef().with({ id: 'foo' }); + expect(ref.id).toBe('foo'); + expect(String(ref)).toBe('ExtensionDataRef{id=foo,optional=false}'); + const refOptional = ref.optional(); + expect(refOptional.id).toBe('foo'); + expect(String(refOptional)).toBe('ExtensionDataRef{id=foo,optional=true}'); + }); + + it('can be created and read in the deprecated way', () => { const ref = createExtensionDataRef('foo'); expect(ref.id).toBe('foo'); expect(String(ref)).toBe('ExtensionDataRef{id=foo,optional=false}'); diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts b/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts index a7f4e4374e..a1c7b462f2 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts @@ -17,9 +17,10 @@ /** @public */ export type ExtensionDataRef< TData, + TId extends string = string, TConfig extends { optional?: true } = {}, > = { - id: string; + id: TId; T: TData; config: TConfig; $$type: '@backstage/ExtensionDataRef'; @@ -27,30 +28,59 @@ export type ExtensionDataRef< /** @public */ export interface ConfigurableExtensionDataRef< + TId extends string, TData, TConfig extends { optional?: true } = {}, -> extends ExtensionDataRef { - optional(): ConfigurableExtensionDataRef; +> extends ExtensionDataRef { + optional(): ConfigurableExtensionDataRef< + TId, + TData, + TData & { optional: true } + >; } -// TODO: change to options object with ID. -/** @public */ +/** + * @public + * @deprecated Use the following form instead: `createExtensionDataRef().with({ id: 'core.foo' })` + */ export function createExtensionDataRef( id: string, -): ConfigurableExtensionDataRef { +): ConfigurableExtensionDataRef; +/** @public */ +export function createExtensionDataRef(): { + with(options: { + id: TId; + }): ConfigurableExtensionDataRef; +}; +export function createExtensionDataRef(id?: string): + | ConfigurableExtensionDataRef + | { + with(options: { + id: TId; + }): ConfigurableExtensionDataRef; + } { + const createRef = (refId: TId) => + ({ + id: refId, + $$type: '@backstage/ExtensionDataRef', + config: {}, + optional() { + return { + ...this, + config: { ...this.config, optional: true }, + }; + }, + toString() { + const optional = Boolean(this.config.optional); + return `ExtensionDataRef{id=${refId},optional=${optional}}`; + }, + } as ConfigurableExtensionDataRef); + if (id) { + return createRef(id); + } return { - id, - $$type: '@backstage/ExtensionDataRef', - config: {}, - optional() { - return { - ...this, - config: { ...this.config, optional: true }, - }; + with(options: { id: TId }) { + return createRef(options.id); }, - toString() { - const optional = Boolean(this.config.optional); - return `ExtensionDataRef{id=${id},optional=${optional}}`; - }, - } as ConfigurableExtensionDataRef; + }; } diff --git a/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts b/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts index dab8a9778a..bb9bdc2107 100644 --- a/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts @@ -27,7 +27,9 @@ import { MockConfigApi, renderWithEffects } from '@backstage/test-utils'; import { createExtensionInput } from './createExtensionInput'; import { BackstagePlugin } from './types'; -const nameExtensionDataRef = createExtensionDataRef('name'); +const nameExtensionDataRef = createExtensionDataRef().with({ + id: 'name', +}); const Extension1 = createExtension({ name: '1', diff --git a/plugins/catalog-react/api-report-alpha.md b/plugins/catalog-react/api-report-alpha.md index b82c7ae3e2..d2bfb29458 100644 --- a/plugins/catalog-react/api-report-alpha.md +++ b/plugins/catalog-react/api-report-alpha.md @@ -17,12 +17,21 @@ import { TranslationRef } from '@backstage/core-plugin-api/alpha'; // @alpha (undocumented) export const catalogExtensionData: { - entityContentTitle: ConfigurableExtensionDataRef; + entityContentTitle: ConfigurableExtensionDataRef< + 'catalog.entity-content-title', + string, + {} + >; entityFilterFunction: ConfigurableExtensionDataRef< + 'catalog.entity-filter-function', (entity: Entity) => boolean, {} >; - entityFilterExpression: ConfigurableExtensionDataRef; + entityFilterExpression: ConfigurableExtensionDataRef< + 'catalog.entity-filter-expression', + string, + {} + >; }; // @alpha (undocumented) diff --git a/plugins/catalog-react/src/alpha.tsx b/plugins/catalog-react/src/alpha.tsx index 85712b358a..e81b8e5ebf 100644 --- a/plugins/catalog-react/src/alpha.tsx +++ b/plugins/catalog-react/src/alpha.tsx @@ -36,15 +36,15 @@ export * from './translation'; /** @alpha */ export const catalogExtensionData = { - entityContentTitle: createExtensionDataRef( - 'catalog.entity-content-title', - ), - entityFilterFunction: createExtensionDataRef<(entity: Entity) => boolean>( - 'catalog.entity-filter-function', - ), - entityFilterExpression: createExtensionDataRef( - 'catalog.entity-filter-expression', - ), + entityContentTitle: createExtensionDataRef().with({ + id: 'catalog.entity-content-title', + }), + entityFilterFunction: createExtensionDataRef< + (entity: Entity) => boolean + >().with({ id: 'catalog.entity-filter-function' }), + entityFilterExpression: createExtensionDataRef().with({ + id: 'catalog.entity-filter-expression', + }), }; // TODO: Figure out how to merge with provided config schema diff --git a/plugins/home/api-report-alpha.md b/plugins/home/api-report-alpha.md index 35ce0bb86c..1dd3da96be 100644 --- a/plugins/home/api-report-alpha.md +++ b/plugins/home/api-report-alpha.md @@ -11,7 +11,11 @@ const _default: BackstagePlugin<{}, {}>; export default _default; // @alpha (undocumented) -export const titleExtensionDataRef: ConfigurableExtensionDataRef; +export const titleExtensionDataRef: ConfigurableExtensionDataRef< + 'title', + string, + {} +>; // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/home/src/alpha.tsx b/plugins/home/src/alpha.tsx index e9ff26c9e1..b13c2a2e0e 100644 --- a/plugins/home/src/alpha.tsx +++ b/plugins/home/src/alpha.tsx @@ -31,7 +31,9 @@ const rootRouteRef = createRouteRef(); /** * @alpha */ -export const titleExtensionDataRef = createExtensionDataRef('title'); +export const titleExtensionDataRef = createExtensionDataRef().with({ + id: 'title', +}); const homePage = createPageExtension({ defaultPath: '/home', diff --git a/plugins/search-react/api-report-alpha.md b/plugins/search-react/api-report-alpha.md index 36f5f049c5..aea85d0f81 100644 --- a/plugins/search-react/api-report-alpha.md +++ b/plugins/search-react/api-report-alpha.md @@ -31,6 +31,7 @@ export function createSearchResultListItemExtension< export namespace createSearchResultListItemExtension { const // (undocumented) itemDataRef: ConfigurableExtensionDataRef< + 'search.search-result-list-item.item', { predicate?: SearchResultItemExtensionPredicate | undefined; component: SearchResultItemExtensionComponent; diff --git a/plugins/search-react/src/alpha.tsx b/plugins/search-react/src/alpha.tsx index db198fa99d..a22eaf4244 100644 --- a/plugins/search-react/src/alpha.tsx +++ b/plugins/search-react/src/alpha.tsx @@ -137,5 +137,5 @@ export namespace createSearchResultListItemExtension { export const itemDataRef = createExtensionDataRef<{ predicate?: SearchResultItemExtensionPredicate; component: SearchResultItemExtensionComponent; - }>('search.search-result-list-item.item'); + }>().with({ id: 'search.search-result-list-item.item' }); } From 0bfe7efa2284b31592c9966278f6d5ad5850c27a Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 18 Jul 2024 14:54:24 +0200 Subject: [PATCH 116/225] chore: wip Signed-off-by: blam Signed-off-by: blam --- .../frontend-app-api/src/wiring/createApp.tsx | 1 + .../src/extensions/IconBundleBlueprint.ts | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 packages/frontend-plugin-api/src/extensions/IconBundleBlueprint.ts diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index cf6ea6dab4..ae7283f301 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -171,6 +171,7 @@ export interface CreateAppFeatureLoader { /** @public */ export function createApp(options?: { + /** @deprecated - TODO */ icons?: { [key in string]: IconComponent }; features?: (FrontendFeature | CreateAppFeatureLoader)[]; configLoader?: () => Promise<{ config: ConfigApi }>; diff --git a/packages/frontend-plugin-api/src/extensions/IconBundleBlueprint.ts b/packages/frontend-plugin-api/src/extensions/IconBundleBlueprint.ts new file mode 100644 index 0000000000..7008dba5a1 --- /dev/null +++ b/packages/frontend-plugin-api/src/extensions/IconBundleBlueprint.ts @@ -0,0 +1,31 @@ +/* + * 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 { IconComponent } from '../icons'; +import { createExtensionBlueprint, createExtensionDataRef } from '../wiring'; + +const iconsDataRef = + createExtensionDataRef<{ [key in string]: IconComponent }>('core.icons'); + +/** @public */ +export const IconBundleBlueprint = createExtensionBlueprint({ + kind: 'icon-bundle', + namespace: 'app', + attachTo: { id: 'app', input: 'icons' }, + output: { + icons: iconsDataRef, + }, + factory: (_, params: { icons: { [key in string]: IconComponent } }) => params, +}); From 39f36a944251d056362702622d82eacd6ff54bb6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 22 Jul 2024 14:06:27 +0200 Subject: [PATCH 117/225] auth-backend-module-microsoft-provider: update scope handling to define required scopes in backend Signed-off-by: Patrik Oldsberg --- .changeset/new-scissors-try.md | 5 ++ docs/auth/microsoft/provider.md | 4 +- .../src/authenticator.ts | 20 ++++-- .../src/module.test.ts | 65 ++++++++++++++++++- 4 files changed, 83 insertions(+), 11 deletions(-) create mode 100644 .changeset/new-scissors-try.md diff --git a/.changeset/new-scissors-try.md b/.changeset/new-scissors-try.md new file mode 100644 index 0000000000..a9e9a45ce7 --- /dev/null +++ b/.changeset/new-scissors-try.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-microsoft-provider': patch +--- + +Updated the Microsoft authenticator to accurately define required scopes, but to also omit the required and additional scopes when requesting resource scopes. diff --git a/docs/auth/microsoft/provider.md b/docs/auth/microsoft/provider.md index f799eeb627..60104b99b7 100644 --- a/docs/auth/microsoft/provider.md +++ b/docs/auth/microsoft/provider.md @@ -67,8 +67,6 @@ auth: clientSecret: ${AZURE_CLIENT_SECRET} tenantId: ${AZURE_TENANT_ID} domainHint: ${AZURE_TENANT_ID} - additionalScopes: - - Mail.Send signIn: resolvers: # typically you would pick one of these @@ -86,7 +84,7 @@ The Microsoft provider is a structure with three mandatory configuration keys: Leave blank if your app registration is multi tenant. When specified, this reduces login friction for users with accounts in multiple tenants by automatically filtering away accounts from other tenants. For more details, see [Home Realm Discovery](https://learn.microsoft.com/en-us/azure/active-directory/manage-apps/home-realm-discovery-policy) -- `additionalScopes` (optional): List of scopes for the App Registration. The default and mandatory value is ['user.read']. +- `additionalScopes` (optional): List of scopes for the App Registration, to be requested in addition to the required ones. ### Resolvers diff --git a/plugins/auth-backend-module-microsoft-provider/src/authenticator.ts b/plugins/auth-backend-module-microsoft-provider/src/authenticator.ts index 1b6eb87fae..fe7dc8f7e1 100644 --- a/plugins/auth-backend-module-microsoft-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-microsoft-provider/src/authenticator.ts @@ -21,21 +21,30 @@ import { PassportProfile, } from '@backstage/plugin-auth-node'; import { ExtendedMicrosoftStrategy } from './strategy'; -import { union } from 'lodash'; /** @public */ export const microsoftAuthenticator = createOAuthAuthenticator({ defaultProfileTransform: PassportOAuthAuthenticatorHelper.defaultProfileTransform, + scopes: { + required: ['email', 'openid', 'offline_access', 'user.read'], + transform({ requested, granted, required, additional }) { + // Resources scopes are of the form `/`, and are handled + // separately from the normal scopes in the client. When request a + // resource scope we should only include forward the request scope along + // with offline_access. + const hasResourceScope = Array.from(requested).some(s => s.includes('/')); + if (hasResourceScope) { + return [...requested, 'offline_access']; + } + return [...requested, ...granted, ...required, ...additional]; + }, + }, initialize({ callbackUrl, config }) { const clientId = config.getString('clientId'); const clientSecret = config.getString('clientSecret'); const tenantId = config.getString('tenantId'); const domainHint = config.getOptionalString('domainHint'); - const scope = union( - ['user.read'], - config.getOptionalStringArray('additionalScopes'), - ); const helper = PassportOAuthAuthenticatorHelper.from( new ExtendedMicrosoftStrategy( @@ -44,7 +53,6 @@ export const microsoftAuthenticator = createOAuthAuthenticator({ clientSecret: clientSecret, callbackURL: callbackUrl, tenant: tenantId, - scope: scope, }, ( accessToken: string, diff --git a/plugins/auth-backend-module-microsoft-provider/src/module.test.ts b/plugins/auth-backend-module-microsoft-provider/src/module.test.ts index b50956b9eb..a072b99bde 100644 --- a/plugins/auth-backend-module-microsoft-provider/src/module.test.ts +++ b/plugins/auth-backend-module-microsoft-provider/src/module.test.ts @@ -67,7 +67,7 @@ describe('authModuleMicrosoftProvider', () => { expect(startUrl.pathname).toBe('/my-tenant-id/oauth2/v2.0/authorize'); expect(Object.fromEntries(startUrl.searchParams)).toEqual({ response_type: 'code', - scope: 'User.Read.All', + scope: 'email openid offline_access user.read User.Read.All', client_id: 'my-client-id', redirect_uri: `http://localhost:${server.port()}/api/auth/microsoft/handler/frame`, state: expect.any(String), @@ -97,6 +97,7 @@ describe('authModuleMicrosoftProvider', () => { clientSecret: 'another-client-secret', tenantId: 'another-tenant-id', domainHint: 'somedomain', + additionalScopes: ['some-extra-scope'], }, }, }, @@ -125,7 +126,7 @@ describe('authModuleMicrosoftProvider', () => { expect(startUrl.pathname).toBe('/another-tenant-id/oauth2/v2.0/authorize'); expect(Object.fromEntries(startUrl.searchParams)).toEqual({ response_type: 'code', - scope: 'user.read', + scope: 'email openid offline_access user.read some-extra-scope', client_id: 'another-client-id', redirect_uri: `http://localhost:${server.port()}/api/auth/microsoft/handler/frame`, state: expect.any(String), @@ -137,4 +138,64 @@ describe('authModuleMicrosoftProvider', () => { nonce: decodeURIComponent(nonceCookie.value), }); }); + + it('should not include required scopes for resources', async () => { + const { server } = await startTestBackend({ + features: [ + authPlugin, + authModuleMicrosoftProvider, + mockServices.rootConfig.factory({ + data: { + app: { + baseUrl: 'http://localhost:3000', + }, + auth: { + providers: { + microsoft: { + development: { + clientId: 'another-client-id', + clientSecret: 'another-client-secret', + tenantId: 'another-tenant-id', + additionalScopes: ['some-extra-scope'], + }, + }, + }, + }, + }, + }), + ], + }); + + const agent = request.agent(server); + + const res = await agent.get( + '/api/auth/microsoft/start?env=development&scope=some-resource/some-scope', + ); + + expect(res.status).toEqual(302); + + const nonceCookie = agent.jar.getCookie('microsoft-nonce', { + domain: 'localhost', + path: '/api/auth/microsoft/handler', + script: false, + secure: false, + }); + expect(nonceCookie).toBeDefined(); + + const startUrl = new URL(res.get('location')); + expect(startUrl.origin).toBe('https://login.microsoftonline.com'); + expect(startUrl.pathname).toBe('/another-tenant-id/oauth2/v2.0/authorize'); + expect(Object.fromEntries(startUrl.searchParams)).toEqual({ + response_type: 'code', + scope: 'some-resource/some-scope offline_access', + client_id: 'another-client-id', + redirect_uri: `http://localhost:${server.port()}/api/auth/microsoft/handler/frame`, + state: expect.any(String), + }); + + expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({ + env: 'development', + nonce: decodeURIComponent(nonceCookie.value), + }); + }); }); From 09d1370799f92e7043ae0d1d5ebbd0798ab917d3 Mon Sep 17 00:00:00 2001 From: Calvin Lee Date: Mon, 22 Jul 2024 22:29:47 +0900 Subject: [PATCH 118/225] Update .changeset/witty-bears-behave.md Co-authored-by: Ben Lambert Signed-off-by: Calvin Lee --- .changeset/witty-bears-behave.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/witty-bears-behave.md b/.changeset/witty-bears-behave.md index 7514759189..174467b9eb 100644 --- a/.changeset/witty-bears-behave.md +++ b/.changeset/witty-bears-behave.md @@ -2,4 +2,4 @@ '@backstage/integration': patch --- -Updated functions for getHarnessEditContentsUrl, getHarnessFileContentsUrl, getHarnessArchiveUrl, getHarnessLatestCommitUrl and parseHarnessUrl to handle account and org level urls +Updated functions for `getHarnessEditContentsUrl`, `getHarnessFileContentsUrl`, `getHarnessArchiveUrl`, `getHarnessLatestCommitUrl` and `parseHarnessUrl` to handle account and org level urls From dc37210a290aa28ed4e12d4d22a3f4696c693af1 Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 22 Jul 2024 15:38:22 +0200 Subject: [PATCH 119/225] feat: add support for collecting icons using the new extension blueprint Signed-off-by: blam --- packages/frontend-app-api/src/extensions/App.tsx | 4 ++++ packages/frontend-app-api/src/wiring/createApp.tsx | 10 +++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/frontend-app-api/src/extensions/App.tsx b/packages/frontend-app-api/src/extensions/App.tsx index 82e7600178..7794aceb79 100644 --- a/packages/frontend-app-api/src/extensions/App.tsx +++ b/packages/frontend-app-api/src/extensions/App.tsx @@ -24,6 +24,7 @@ import { createExtensionInput, createThemeExtension, createTranslationExtension, + IconBundleBlueprint, } from '@backstage/frontend-plugin-api'; export const App = createExtension({ @@ -42,6 +43,9 @@ export const App = createExtension({ translations: createExtensionInput({ translation: createTranslationExtension.translationDataRef, }), + icons: createExtensionInput({ + icon: IconBundleBlueprint.dataRefs.icons, + }), root: createExtensionInput( { element: coreExtensionData.reactElement, diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index ae7283f301..4d929b206f 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -25,6 +25,7 @@ import { createThemeExtension, createTranslationExtension, FrontendFeature, + IconBundleBlueprint, iconsApiRef, RouteResolutionApi, routeResolutionApiRef, @@ -247,6 +248,7 @@ export function createApp(options?: { * @public */ export function createSpecializedApp(options?: { + /** @deprecated - TODO */ icons?: { [key in string]: IconComponent }; features?: FrontendFeature[]; config?: ConfigApi; @@ -374,6 +376,11 @@ function createApiHolder( (x): x is typeof createTranslationExtension.translationDataRef.T => !!x, ) ?? []; + const extensionIcons = tree.root.edges.attachments + .get('icons') + ?.map(e => e.instance?.getData(IconBundleBlueprint.dataRefs.icons)) + .reduce((acc, bundle) => ({ ...acc, ...bundle }), {}); + for (const factory of pluginApis) { factoryRegistry.register('default', factory); } @@ -414,7 +421,8 @@ function createApiHolder( factoryRegistry.register('static', { api: iconsApiRef, deps: {}, - factory: () => new DefaultIconsApi({ ...defaultIcons, ...icons }), + factory: () => + new DefaultIconsApi({ ...defaultIcons, ...extensionIcons, ...icons }), }); factoryRegistry.register('static', { From 4a18c942be1d49700540005f9d6a1566d0268f98 Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 22 Jul 2024 15:39:33 +0200 Subject: [PATCH 120/225] feat: support exporting of dataRefs through blueprints Signed-off-by: blam --- .../src/extensions/IconBundleBlueprint.ts | 10 ++++--- .../src/extensions/index.ts | 1 + .../src/wiring/createExtensionBlueprint.ts | 27 ++++++++++++++++--- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/frontend-plugin-api/src/extensions/IconBundleBlueprint.ts b/packages/frontend-plugin-api/src/extensions/IconBundleBlueprint.ts index 7008dba5a1..2ad1404102 100644 --- a/packages/frontend-plugin-api/src/extensions/IconBundleBlueprint.ts +++ b/packages/frontend-plugin-api/src/extensions/IconBundleBlueprint.ts @@ -16,8 +16,9 @@ import { IconComponent } from '../icons'; import { createExtensionBlueprint, createExtensionDataRef } from '../wiring'; -const iconsDataRef = - createExtensionDataRef<{ [key in string]: IconComponent }>('core.icons'); +const iconsDataRef = createExtensionDataRef<{ + [key in string]: IconComponent; +}>().with({ id: 'core.icons' }); /** @public */ export const IconBundleBlueprint = createExtensionBlueprint({ @@ -27,5 +28,8 @@ export const IconBundleBlueprint = createExtensionBlueprint({ output: { icons: iconsDataRef, }, - factory: (_, params: { icons: { [key in string]: IconComponent } }) => params, + factory: (params: { icons: { [key in string]: IconComponent } }) => params, + dataRefs: { + icons: iconsDataRef, + }, }); diff --git a/packages/frontend-plugin-api/src/extensions/index.ts b/packages/frontend-plugin-api/src/extensions/index.ts index 562cb728cb..267201db81 100644 --- a/packages/frontend-plugin-api/src/extensions/index.ts +++ b/packages/frontend-plugin-api/src/extensions/index.ts @@ -25,3 +25,4 @@ export { createSignInPageExtension } from './createSignInPageExtension'; export { createThemeExtension } from './createThemeExtension'; export { createComponentExtension } from './createComponentExtension'; export { createTranslationExtension } from './createTranslationExtension'; +export { IconBundleBlueprint } from './IconBundleBlueprint'; diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index 6c79017a0a..b504db5f03 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -34,6 +34,7 @@ export interface CreateExtensionBlueprintOptions< TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, + TDataRefs extends AnyExtensionDataMap, > { kind: string; namespace?: string; @@ -50,6 +51,8 @@ export interface CreateExtensionBlueprintOptions< inputs: Expand>; }, ): Expand>; + + dataRefs?: TDataRefs; } /** @@ -60,7 +63,10 @@ export interface ExtensionBlueprint< TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, + TDataRefs extends AnyExtensionDataMap, > { + dataRefs: TDataRefs; + make(args: { namespace?: string; name?: string; @@ -97,15 +103,21 @@ class ExtensionBlueprintImpl< TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, + TDataRefs extends AnyExtensionDataMap, > { constructor( private readonly options: CreateExtensionBlueprintOptions< TParams, TInputs, TOutput, - TConfig + TConfig, + TDataRefs >, - ) {} + ) { + this.dataRefs = options.dataRefs!; + } + + dataRefs: TDataRefs; public make(args: { namespace?: string; @@ -184,8 +196,15 @@ export function createExtensionBlueprint< TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, + TDataRefs extends AnyExtensionDataMap = never, >( - options: CreateExtensionBlueprintOptions, -): ExtensionBlueprint { + options: CreateExtensionBlueprintOptions< + TParams, + TInputs, + TOutput, + TConfig, + TDataRefs + >, +): ExtensionBlueprint { return new ExtensionBlueprintImpl(options); } From 7777b5fb40b96fcefe6a418c0d3d657b3ae6b6a1 Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 22 Jul 2024 15:45:01 +0200 Subject: [PATCH 121/225] chore: added changeset Signed-off-by: blam --- .changeset/forty-ties-agree.md | 18 ++++++++++++++++++ .changeset/forty-ties-disagree.md | 5 +++++ 2 files changed, 23 insertions(+) create mode 100644 .changeset/forty-ties-agree.md create mode 100644 .changeset/forty-ties-disagree.md diff --git a/.changeset/forty-ties-agree.md b/.changeset/forty-ties-agree.md new file mode 100644 index 0000000000..caa45841a2 --- /dev/null +++ b/.changeset/forty-ties-agree.md @@ -0,0 +1,18 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Added a new `IconBundleBlueprint` that lets you create icon bundle extensions that can be installed in an App in order to override or add new app icons. + +```tsx +import { IconBundleBlueprint } from '@backstage/frontend-plugin-api'; + +const exampleIconBundle = IconBundleBlueprint.make({ + name: 'example-bundle', + params: { + icons: { + user: MyOwnUserIcon, + }, + }, +}); +``` diff --git a/.changeset/forty-ties-disagree.md b/.changeset/forty-ties-disagree.md new file mode 100644 index 0000000000..214097b35d --- /dev/null +++ b/.changeset/forty-ties-disagree.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Support icon overriding with the new `IconBundleBlueprint` API. From be5cb5ea365f09044cc18788e7b5d9eaa6b3689e Mon Sep 17 00:00:00 2001 From: Devin Lauderdale Date: Mon, 22 Jul 2024 08:58:25 -0500 Subject: [PATCH 122/225] docs: remove unmaintained dora-metrics plugin Signed-off-by: Devin Lauderdale --- microsite/data/plugins/dora-metrics.yaml | 10 ---------- microsite/static/img/okay.png | Bin 46181 -> 0 bytes 2 files changed, 10 deletions(-) delete mode 100644 microsite/data/plugins/dora-metrics.yaml delete mode 100644 microsite/static/img/okay.png diff --git a/microsite/data/plugins/dora-metrics.yaml b/microsite/data/plugins/dora-metrics.yaml deleted file mode 100644 index 3f59902f46..0000000000 --- a/microsite/data/plugins/dora-metrics.yaml +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: DORA Metrics -author: Okay -authorUrl: https://www.okayhq.com -category: Metrics -description: Embed dashboards (like DORA metrics) in your team or service pages from any dev tools (including Github, Gitlab, Jira, Argo, CircleCI, Buildkite, Pagerduty, Rollbar, Sentry, etc). -documentation: https://github.com/OkayHQ/backstage-plugin -iconUrl: /img/okay.png -npmPackageName: '@okayhq/backstage-plugin' -addedDate: '2022-06-16' diff --git a/microsite/static/img/okay.png b/microsite/static/img/okay.png deleted file mode 100644 index 357219d9d2c51e1d9a16f3f7a0c4fc476936d685..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 46181 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 From cf9da6dafbe6c2fd038d9b8176a2f28816b76e10 Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 22 Jul 2024 16:10:05 +0200 Subject: [PATCH 123/225] chore: added test for dataRef exporting Signed-off-by: blam --- .../wiring/createExtensionBlueprint.test.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index c7b077bdbd..87b3cec152 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -18,6 +18,7 @@ import React from 'react'; import { coreExtensionData } from './coreExtensionData'; import { createExtensionBlueprint } from './createExtensionBlueprint'; import { createExtensionTester } from '@backstage/frontend-test-utils'; +import { createExtensionDataRef } from './createExtensionDataRef'; describe('createExtensionBlueprint', () => { it('should allow creation of extension blueprints', () => { @@ -102,4 +103,28 @@ describe('createExtensionBlueprint', () => { const { container } = createExtensionTester(extension).render(); expect(container.querySelector('h2')).toHaveTextContent('Hello, world!'); }); + + it('should allow exporting the dataRefs from the extension blueprint', () => { + const dataRef = createExtensionDataRef().with({ id: 'test.data' }); + + const TestExtensionBlueprint = createExtensionBlueprint({ + kind: 'test-extension', + attachTo: { id: 'test', input: 'default' }, + output: { + element: coreExtensionData.reactElement, + }, + dataRefs: { + data: dataRef, + }, + factory(params: { text: string }) { + return { + element:

{params.text}

, + }; + }, + }); + + expect(TestExtensionBlueprint.dataRefs).toEqual({ + data: dataRef, + }); + }); }); From 493feaca4d4af19c4174a531d268f4d6e63c1a9e Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Thu, 18 Jul 2024 11:10:18 +0100 Subject: [PATCH 124/225] cli: allow running with no configuration files Co-authored-by: Patrik Oldsberg Signed-off-by: MT Lewis --- .changeset/rich-mugs-dress.md | 7 ++++++ packages/cli/src/lib/bundler/config.ts | 6 ++++- packages/cli/src/lib/config.ts | 8 ++++--- packages/config-loader/api-report.md | 1 + .../src/sources/ConfigSources.test.ts | 13 +++++++++- .../src/sources/ConfigSources.ts | 24 +++++++++++++------ 6 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 .changeset/rich-mugs-dress.md diff --git a/.changeset/rich-mugs-dress.md b/.changeset/rich-mugs-dress.md new file mode 100644 index 0000000000..5008e33a70 --- /dev/null +++ b/.changeset/rich-mugs-dress.md @@ -0,0 +1,7 @@ +--- +'@backstage/config-loader': patch +--- + +Add boolean `allowMissingDefaultConfig` option to `ConfigSources.default` and +`ConfigSources.defaultForTargets`, which results in omission of a ConfigSource +for the default app-config.yaml configuration file if it's not present. diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 1f08f74a85..042f74d1f3 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -48,8 +48,12 @@ const BUILD_CACHE_ENV_VAR = 'BACKSTAGE_CLI_EXPERIMENTAL_BUILD_CACHE'; export function resolveBaseUrl(config: Config): URL { const baseUrl = config.getOptionalString('app.baseUrl'); + try { - return new URL(baseUrl ?? '/', 'http://localhost:3000'); + return new URL( + baseUrl ?? '/', + `http://localhost:${process.env.PORT ?? '3000'}`, + ); } catch (error) { throw new Error(`Invalid app.baseUrl, ${error}`); } diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 0c657b640e..0be20b0185 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -96,11 +96,13 @@ export async function loadCliConfig(options: Options) { }, }); + const configurationLoadedMessage = appConfigs.length + ? `Loaded config from ${appConfigs.map(c => c.context).join(', ')}` + : `No configuration files found, running without config`; + // printing to stderr to not clobber stdout in case the cli command // outputs structured data (e.g. as config:schema does) - process.stderr.write( - `Loaded config from ${appConfigs.map(c => c.context).join(', ')}\n`, - ); + process.stderr.write(`${configurationLoadedMessage}\n`); try { const frontendAppConfigs = schema.process(appConfigs, { diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md index bc8f217bbb..835e53351d 100644 --- a/packages/config-loader/api-report.md +++ b/packages/config-loader/api-report.md @@ -21,6 +21,7 @@ export type AsyncConfigSourceGenerator = AsyncGenerator< // @public export interface BaseConfigSourcesOptions { + allowMissingDefaultConfig?: boolean; // (undocumented) remote?: Pick; // (undocumented) diff --git a/packages/config-loader/src/sources/ConfigSources.test.ts b/packages/config-loader/src/sources/ConfigSources.test.ts index 4b14572666..e19ac1d51f 100644 --- a/packages/config-loader/src/sources/ConfigSources.test.ts +++ b/packages/config-loader/src/sources/ConfigSources.test.ts @@ -69,13 +69,18 @@ describe('ConfigSources', () => { }); it('should create default sources for targets', () => { + const fsSpy = jest.spyOn(fs, 'pathExistsSync').mockImplementation(path => { + return path === `${root}app-config.yaml`; + }); + expect( mergeSources( ConfigSources.defaultForTargets({ rootDir: '/', targets: [] }), ), ).toEqual([{ name: 'FileConfigSource', path: `${root}app-config.yaml` }]); - const fsSpy = jest.spyOn(fs, 'pathExistsSync').mockReturnValue(true); + fsSpy.mockReturnValue(true); + expect( mergeSources( ConfigSources.defaultForTargets({ rootDir: '/', targets: [] }), @@ -159,6 +164,10 @@ describe('ConfigSources', () => { }); it('should create a default source', () => { + const fsSpy = jest.spyOn(fs, 'pathExistsSync').mockImplementation(path => { + return path === `${root}app-config.yaml`; + }); + expect( mergeSources( ConfigSources.default({ @@ -184,6 +193,8 @@ describe('ConfigSources', () => { { name: 'FileConfigSource', path: resolvePath('b.yaml') }, { name: 'EnvConfigSource', env: { HOME: '/' } }, ]); + + fsSpy.mockRestore(); }); it('should merge sources', () => { diff --git a/packages/config-loader/src/sources/ConfigSources.ts b/packages/config-loader/src/sources/ConfigSources.ts index ecff39da2d..be8892f9a9 100644 --- a/packages/config-loader/src/sources/ConfigSources.ts +++ b/packages/config-loader/src/sources/ConfigSources.ts @@ -74,6 +74,11 @@ export interface BaseConfigSourcesOptions { watch?: boolean; rootDir?: string; remote?: Pick; + /** + * Allow the default app-config.yaml to be missing, in which case the source + * will not be created. + */ + allowMissingDefaultConfig?: boolean; /** * A custom substitution function that overrides the default one. @@ -177,14 +182,19 @@ export class ConfigSources { if (argSources.length === 0) { const defaultPath = resolvePath(rootDir, 'app-config.yaml'); const localPath = resolvePath(rootDir, 'app-config.local.yaml'); + const alwaysIncludeDefaultConfigSource = + !options.allowMissingDefaultConfig; + + if (alwaysIncludeDefaultConfigSource || fs.pathExistsSync(defaultPath)) { + argSources.push( + FileConfigSource.create({ + watch: options.watch, + path: defaultPath, + substitutionFunc: options.substitutionFunc, + }), + ); + } - argSources.push( - FileConfigSource.create({ - watch: options.watch, - path: defaultPath, - substitutionFunc: options.substitutionFunc, - }), - ); if (fs.pathExistsSync(localPath)) { argSources.push( FileConfigSource.create({ From 0eedec304dbe8e4f8b1e982a1e29b081cbed3ddd Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Thu, 18 Jul 2024 11:18:07 +0100 Subject: [PATCH 125/225] cli: add changeset for module federation local development changes Signed-off-by: MT Lewis --- .changeset/strong-otters-compete.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/strong-otters-compete.md diff --git a/.changeset/strong-otters-compete.md b/.changeset/strong-otters-compete.md new file mode 100644 index 0000000000..fdcb982b88 --- /dev/null +++ b/.changeset/strong-otters-compete.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Add support for dynamic plugins via the EXPERIMENTAL_MODULE_FEDERATION environment variable when running `yarn start`. From 9d661bb3807a534cf023dc84d816cee5d3c81472 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Fri, 19 Jul 2024 14:28:13 +0100 Subject: [PATCH 126/225] cli: only consider PORT environment variable for module federation remotes Signed-off-by: MT Lewis --- packages/cli/src/lib/bundler/config.ts | 43 +++++++++++++++++++------- packages/cli/src/lib/bundler/server.ts | 7 +++-- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 042f74d1f3..42cdae24e3 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { BackendBundlingOptions, BundlingOptions } from './types'; +import { + BackendBundlingOptions, + BundlingOptions, + ModuleFederationOptions, +} from './types'; import { posix as posixPath, resolve as resolvePath, dirname } from 'path'; import chalk from 'chalk'; import webpack, { ProvidePlugin } from 'webpack'; @@ -46,24 +50,32 @@ import { hasReactDomClient } from './hasReactDomClient'; const BUILD_CACHE_ENV_VAR = 'BACKSTAGE_CLI_EXPERIMENTAL_BUILD_CACHE'; -export function resolveBaseUrl(config: Config): URL { +export function resolveBaseUrl( + config: Config, + moduleFederation?: ModuleFederationOptions, +): URL { const baseUrl = config.getOptionalString('app.baseUrl'); + const defaultBaseUrl = + moduleFederation?.mode === 'remote' + ? `http://localhost:${process.env.PORT ?? '3000'}` + : 'http://localhost:3000'; + try { - return new URL( - baseUrl ?? '/', - `http://localhost:${process.env.PORT ?? '3000'}`, - ); + return new URL(baseUrl ?? '/', defaultBaseUrl); } catch (error) { throw new Error(`Invalid app.baseUrl, ${error}`); } } -export function resolveEndpoint(config: Config): { +export function resolveEndpoint( + config: Config, + moduleFederation?: ModuleFederationOptions, +): { host: string; port: number; } { - const url = resolveBaseUrl(config); + const url = resolveBaseUrl(config, moduleFederation); return { host: config.getOptionalString('app.listen.host') ?? url.hostname, @@ -114,7 +126,13 @@ export async function createConfig( paths: BundlingPaths, options: BundlingOptions, ): Promise { - const { checksEnabled, isDev, frontendConfig, publicSubPath = '' } = options; + const { + checksEnabled, + isDev, + frontendConfig, + moduleFederation, + publicSubPath = '', + } = options; const { plugins, loaders } = transforms(options); // Any package that is part of the monorepo but outside the monorepo root dir need @@ -122,14 +140,17 @@ export async function createConfig( const { packages } = await getPackages(cliPaths.targetDir); const externalPkgs = packages.filter(p => !isChildPath(paths.root, p.dir)); - const validBaseUrl = resolveBaseUrl(frontendConfig); + const validBaseUrl = resolveBaseUrl(frontendConfig, moduleFederation); let publicPath = validBaseUrl.pathname.replace(/\/$/, ''); if (publicSubPath) { publicPath = `${publicPath}${publicSubPath}`.replace('//', '/'); } if (isDev) { - const { host, port } = resolveEndpoint(options.frontendConfig); + const { host, port } = resolveEndpoint( + options.frontendConfig, + options.moduleFederation, + ); plugins.push( new ReactRefreshPlugin({ diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index 0421f4d019..6b89847fb6 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -130,8 +130,11 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be } const { frontendConfig, fullConfig } = cliConfig; - const url = resolveBaseUrl(frontendConfig); - const { host, port } = resolveEndpoint(frontendConfig); + const url = resolveBaseUrl(frontendConfig, options.moduleFederation); + const { host, port } = resolveEndpoint( + frontendConfig, + options.moduleFederation, + ); const detectedModulesEntryPoint = await createDetectedModulesEntryPoint({ config: fullConfig, From 239dffc970b4b040ffdf9aee24f6937c4994f5ba Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Mon, 22 Jul 2024 11:06:42 +0100 Subject: [PATCH 127/225] cli: remove usage of deprecated functionality from @backstage/config-loader Co-authored-by: Patrik Oldsberg Signed-off-by: MT Lewis --- .changeset/ninety-icons-smile.md | 5 +++ packages/cli/src/lib/config.ts | 76 +++++++++++++++++++------------- 2 files changed, 51 insertions(+), 30 deletions(-) create mode 100644 .changeset/ninety-icons-smile.md diff --git a/.changeset/ninety-icons-smile.md b/.changeset/ninety-icons-smile.md new file mode 100644 index 0000000000..f137879706 --- /dev/null +++ b/.changeset/ninety-icons-smile.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Remove usage of deprecated functionality from @backstage/config-loader diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 0be20b0185..49cce1f1ed 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -14,14 +14,9 @@ * limitations under the License. */ -import { - ConfigTarget, - loadConfig, - loadConfigSchema, -} from '@backstage/config-loader'; +import { ConfigSources, loadConfigSchema } from '@backstage/config-loader'; import { AppConfig, ConfigReader } from '@backstage/config'; import { paths } from './paths'; -import { isValidUrl } from './urls'; import { getPackages } from '@manypkg/get-packages'; import { PackageGraph } from '@backstage/cli-node'; @@ -37,13 +32,6 @@ type Options = { }; export async function loadCliConfig(options: Options) { - const configTargets: ConfigTarget[] = []; - options.args.forEach(arg => { - if (!isValidUrl(arg)) { - configTargets.push({ path: paths.resolveTarget(arg) }); - } - }); - // Consider all packages in the monorepo when loading in config const { packages } = await getPackages(paths.targetDir); @@ -75,25 +63,53 @@ export async function loadCliConfig(options: Options) { noUndeclaredProperties: options.strict, }); - const { appConfigs } = await loadConfig({ - experimentalEnvFunc: options.mockEnv + const source = ConfigSources.default({ + allowMissingDefaultConfig: true, + substitutionFunc: options.mockEnv ? async name => process.env[name] || 'x' : undefined, - configRoot: paths.targetRoot, - configTargets: configTargets, - watch: options.watch && { - onChange(newAppConfigs) { - const newFrontendAppConfigs = schema.process(newAppConfigs, { - visibility: options.fullVisibility - ? ['frontend', 'backend', 'secret'] - : ['frontend'], - withFilteredKeys: options.withFilteredKeys, - withDeprecatedKeys: options.withDeprecatedKeys, - ignoreSchemaErrors: !options.strict, - }); - options.watch?.(newFrontendAppConfigs); - }, - }, + watch: Boolean(options.watch), + rootDir: paths.targetRoot, + argv: options.args, + }); + + const appConfigs = await new Promise((resolve, reject) => { + async function loadConfigReaderLoop() { + let loaded = false; + + try { + const abortController = new AbortController(); + for await (const { configs } of source.readConfigData({ + signal: abortController.signal, + })) { + if (loaded) { + const newFrontendAppConfigs = schema.process(configs, { + visibility: options.fullVisibility + ? ['frontend', 'backend', 'secret'] + : ['frontend'], + withFilteredKeys: options.withFilteredKeys, + withDeprecatedKeys: options.withDeprecatedKeys, + ignoreSchemaErrors: !options.strict, + }); + options.watch?.(newFrontendAppConfigs); + } else { + resolve(configs); + loaded = true; + + if (!options.watch) { + abortController.abort(); + } + } + } + } catch (error) { + if (loaded) { + console.error(`Failed to reload configuration, ${error}`); + } else { + reject(error); + } + } + } + loadConfigReaderLoop(); }); const configurationLoadedMessage = appConfigs.length From 348087a09636d79e3a2894325b16afef79275ec9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 22 Jul 2024 16:22:29 +0200 Subject: [PATCH 128/225] auth-backend-module-microsoft-provider: cleanup deps Signed-off-by: Patrik Oldsberg --- plugins/auth-backend-module-microsoft-provider/package.json | 2 -- yarn.lock | 2 -- 2 files changed, 4 deletions(-) diff --git a/plugins/auth-backend-module-microsoft-provider/package.json b/plugins/auth-backend-module-microsoft-provider/package.json index 67a898cf80..b67fb8c447 100644 --- a/plugins/auth-backend-module-microsoft-provider/package.json +++ b/plugins/auth-backend-module-microsoft-provider/package.json @@ -38,9 +38,7 @@ "@backstage/plugin-auth-node": "workspace:^", "express": "^4.18.2", "jose": "^5.0.0", - "lodash": "^4.17.21", "node-fetch": "^2.6.7", - "passport": "^0.7.0", "passport-microsoft": "^1.0.0" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index a8ed83a61a..ff5eac7b1f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4861,10 +4861,8 @@ __metadata: "@types/passport-microsoft": ^1.0.0 express: ^4.18.2 jose: ^5.0.0 - lodash: ^4.17.21 msw: ^1.0.0 node-fetch: ^2.6.7 - passport: ^0.7.0 passport-microsoft: ^1.0.0 supertest: ^6.3.3 languageName: unknown From b1f3c44e42a8f880698e2b4e5c59be133c000738 Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 22 Jul 2024 16:31:09 +0200 Subject: [PATCH 129/225] chore: api-reports Signed-off-by: blam --- packages/frontend-plugin-api/api-report.md | 46 +++++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 65445b5c4d..3c082a7694 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -522,9 +522,16 @@ export function createExtensionBlueprint< TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, + TDataRefs extends AnyExtensionDataMap = never, >( - options: CreateExtensionBlueprintOptions, -): ExtensionBlueprint; + options: CreateExtensionBlueprintOptions< + TParams, + TInputs, + TOutput, + TConfig, + TDataRefs + >, +): ExtensionBlueprint; // @public (undocumented) export interface CreateExtensionBlueprintOptions< @@ -532,6 +539,7 @@ export interface CreateExtensionBlueprintOptions< TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, + TDataRefs extends AnyExtensionDataMap, > { // (undocumented) attachTo: { @@ -541,6 +549,8 @@ export interface CreateExtensionBlueprintOptions< // (undocumented) configSchema?: PortableSchema; // (undocumented) + dataRefs?: TDataRefs; + // (undocumented) disabled?: boolean; // (undocumented) factory( @@ -917,7 +927,10 @@ export interface ExtensionBlueprint< TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, + TDataRefs extends AnyExtensionDataMap, > { + // (undocumented) + dataRefs: TDataRefs; // (undocumented) make(args: { namespace?: string; @@ -1085,6 +1098,35 @@ export { gitlabAuthApiRef }; export { googleAuthApiRef }; +// @public (undocumented) +export const IconBundleBlueprint: ExtensionBlueprint< + { + icons: { + [x: string]: IconComponent; + }; + }, + AnyExtensionInputMap, + { + icons: ConfigurableExtensionDataRef< + 'core.icons', + { + [x: string]: IconComponent; + }, + {} + >; + }, + unknown, + { + icons: ConfigurableExtensionDataRef< + 'core.icons', + { + [x: string]: IconComponent; + }, + {} + >; + } +>; + // @public export type IconComponent = ComponentType< | { From 98ea3e2ca9329f6b131e3d3f038ba5101b039beb Mon Sep 17 00:00:00 2001 From: Adam Letizia <43392371+koalaty-code@users.noreply.github.com> Date: Mon, 22 Jul 2024 09:40:18 -0500 Subject: [PATCH 130/225] chore(changeset): new change description Co-authored-by: Ben Lambert Signed-off-by: Adam Letizia <43392371+koalaty-code@users.noreply.github.com> --- .changeset/tough-lies-repair.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tough-lies-repair.md b/.changeset/tough-lies-repair.md index 25b1884bbc..b2f848e292 100644 --- a/.changeset/tough-lies-repair.md +++ b/.changeset/tough-lies-repair.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder': patch --- -RepoUrlPicker: refetch user credentials when a new host is selected +Fix issue with `RepoUrlPicker` not refreshing the credentials for a different host From d0e95a70369cb81bf2c74850a5c3b80250388110 Mon Sep 17 00:00:00 2001 From: Rich Barton-Cooper <58304039+richcooper95@users.noreply.github.com> Date: Fri, 3 May 2024 16:01:05 +0100 Subject: [PATCH 131/225] feat: expose uiSchema and formContext in FormProps This commit exposes two extra fields in the FormProps: `uiSchema` and `formContext`. Signed-off-by: Rich Barton-Cooper Co-authored-by: Chunyi Lyu --- .changeset/flat-plums-grow.md | 6 ++++++ plugins/scaffolder-react/api-report.md | 2 +- plugins/scaffolder-react/src/components/types.ts | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .changeset/flat-plums-grow.md diff --git a/.changeset/flat-plums-grow.md b/.changeset/flat-plums-grow.md new file mode 100644 index 0000000000..b62f3e65a9 --- /dev/null +++ b/.changeset/flat-plums-grow.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder-react': patch +--- + +Add ability to customise form fields in the UI by exposing `uiSchema` and `formContext` in +`FormProps` diff --git a/plugins/scaffolder-react/api-report.md b/plugins/scaffolder-react/api-report.md index a14490b80c..a19a656d17 100644 --- a/plugins/scaffolder-react/api-report.md +++ b/plugins/scaffolder-react/api-report.md @@ -128,7 +128,7 @@ export interface FieldExtensionUiSchema // @public export type FormProps = Pick< FormProps_2, - 'transformErrors' | 'noHtml5Validate' + 'transformErrors' | 'noHtml5Validate' | 'uiSchema' | 'formContext' >; // @public diff --git a/plugins/scaffolder-react/src/components/types.ts b/plugins/scaffolder-react/src/components/types.ts index 597998bac4..d5d317015d 100644 --- a/plugins/scaffolder-react/src/components/types.ts +++ b/plugins/scaffolder-react/src/components/types.ts @@ -32,7 +32,7 @@ export type TemplateGroupFilter = { */ export type FormProps = Pick< SchemaFormProps, - 'transformErrors' | 'noHtml5Validate' + 'transformErrors' | 'noHtml5Validate' | 'uiSchema' | 'formContext' >; /** From 8cdd846abc49841100cdaf5273d4793a821b475e Mon Sep 17 00:00:00 2001 From: Rich Barton-Cooper Date: Mon, 22 Jul 2024 15:55:23 +0100 Subject: [PATCH 132/225] fix: merge uiSchema and formContext in Stepper component This commit ensures that the uiSchema and formContext are merged in the Stepper component form, with the non-props values taking precedence over the props values. The uiSchemas are deep-merged using `lodash.merge` to ensure that nested values are correctly merged. Signed-off-by: Rich Barton-Cooper --- .../next/components/Stepper/Stepper.test.tsx | 60 +++++++++++++++++++ .../src/next/components/Stepper/Stepper.tsx | 15 ++++- 2 files changed, 72 insertions(+), 3 deletions(-) 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 df58d971fb..78aef3428f 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx @@ -549,6 +549,66 @@ describe('Stepper', () => { }); }); + it('should allow overrides to the uiSchema and formContext correctly', async () => { + const manifest: TemplateParameterSchema = { + title: 'Custom Fields', + steps: [ + { + title: 'Test', + schema: { + properties: { + name: { + type: 'string', + 'ui:placeholder': 'Enter your name', + }, + age: { + type: 'number', + 'ui:placeholder': 'Enter your age', + }, + }, + }, + }, + ], + }; + + const uiSchema = { + name: { + 'ui:readonly': true, + 'ui:placeholder': 'Should be overwritten', + }, + }; + + const formContext = { + readOnlyAsDisabled: true, + }; + + const { getByRole } = await renderInTestApp( + + + , + ); + + expect(getByRole('textbox', { name: 'name' })).toHaveValue('Some Name'); + expect(getByRole('textbox', { name: 'name' })).toBeDisabled(); + expect(getByRole('textbox', { name: 'name' })).toHaveAttribute( + 'placeholder', + 'Enter your name', + ); + + expect(getByRole('spinbutton', { name: 'age' })).toHaveValue(40); + expect(getByRole('spinbutton', { name: 'age' })).toBeEnabled(); + expect(getByRole('spinbutton', { name: 'age' })).toHaveAttribute( + 'placeholder', + 'Enter your age', + ); + }); + describe('Scaffolder Layouts', () => { it('should render the step in the scaffolder layout', async () => { const ScaffolderLayout: LayoutTemplate = ({ properties }) => ( diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index 788c7cd715..3a8858144b 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -51,6 +51,7 @@ import { ErrorListTemplate } from './ErrorListTemplate'; import { makeStyles } from '@material-ui/core/styles'; import { PasswordWidget } from '../PasswordWidget/PasswordWidget'; import ajvErrors from 'ajv-errors'; +import { merge } from 'lodash'; const validator = customizeValidator(); ajvErrors(validator.ajv); @@ -193,6 +194,14 @@ export const Stepper = (stepperProps: StepperProps) => { setFormState(current => ({ ...current, ...formData })); }; + const { + formContext: propFormContext, + uiSchema: propUiSchema, + ...restFormProps + } = props.formProps ?? {}; + + const mergedUiSchema = merge({}, propUiSchema, currentStep?.uiSchema); + return ( <> {isValidating && } @@ -229,9 +238,9 @@ export const Stepper = (stepperProps: StepperProps) => { validator={validator} extraErrors={errors as unknown as ErrorSchema} formData={formState} - formContext={{ formData: formState }} + formContext={{ ...propFormContext, formData: formState }} schema={currentStep.schema} - uiSchema={currentStep.uiSchema} + uiSchema={mergedUiSchema} onSubmit={handleNext} fields={fields} showErrorList="top" @@ -241,7 +250,7 @@ export const Stepper = (stepperProps: StepperProps) => { experimental_defaultFormStateBehavior={{ allOf: 'populateDefaults', }} - {...(props.formProps ?? {})} + {...restFormProps} >

yjD;9z)BowS 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 literal 0 HcmV?d00001 diff --git a/docs/getting-started/logging-in.md b/docs/getting-started/logging-in.md index fef8907e07..5386e98798 100644 --- a/docs/getting-started/logging-in.md +++ b/docs/getting-started/logging-in.md @@ -20,6 +20,8 @@ Run your Backstage app with `yarn dev`. Navigate to `http://localhost:3000`. If you're not already logged in, you should see a login screen like this, +![Screenshot of the the login screen](../assets/getting-started/login-screen.png) + To login, you should choose the "Github" provider and click the "Sign in" button. This will redirect you to a Github OAuth page. Verify that the scopes mentioned on that page match the setup you did in [the authentication tutorial](./config/authentication.md). Once you click "Confirm", you will be brought back to the Backstage interface and signed in! If you are already logged in, you will be automatically brought to your Backstage instance. From 708294012180f435dd61c87a827b752b6f3fb974 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Wed, 17 Jul 2024 18:01:01 +0100 Subject: [PATCH 102/225] cli: add support for frontend-dynamic-container role in yarn start Signed-off-by: MT Lewis --- .../cli/src/commands/build/buildFrontend.ts | 27 +----------- packages/cli/src/commands/start/command.ts | 8 ++++ .../cli/src/commands/start/startFrontend.ts | 11 ++++- packages/cli/src/lib/bundler/index.ts | 1 + .../cli/src/lib/bundler/moduleFederation.ts | 44 +++++++++++++++++++ packages/cli/src/lib/bundler/types.ts | 1 + 6 files changed, 65 insertions(+), 27 deletions(-) create mode 100644 packages/cli/src/lib/bundler/moduleFederation.ts diff --git a/packages/cli/src/commands/build/buildFrontend.ts b/packages/cli/src/commands/build/buildFrontend.ts index 62da74e6d3..f29e0bdd38 100644 --- a/packages/cli/src/commands/build/buildFrontend.ts +++ b/packages/cli/src/commands/build/buildFrontend.ts @@ -16,11 +16,9 @@ import fs from 'fs-extra'; import { resolve as resolvePath } from 'path'; -import { buildBundle } from '../../lib/bundler'; +import { buildBundle, getModuleFederationOptions } from '../../lib/bundler'; import { getEnvironmentParallelism } from '../../lib/parallel'; import { loadCliConfig } from '../../lib/config'; -import chalk from 'chalk'; -import { BuildOptions } from '../../lib/bundler/types'; interface BuildAppOptions { targetDir: string; @@ -29,29 +27,6 @@ interface BuildAppOptions { isModuleFederationRemote?: true; } -function getModuleFederationOptions( - name: string, - isRemote?: boolean, -): BuildOptions['moduleFederation'] { - if (!isRemote && !process.env.EXPERIMENTAL_MODULE_FEDERATION) { - return undefined; - } - - console.log( - chalk.yellow( - `⚠️ WARNING: Module federation is experimental and will receive immediate breaking changes in the future.`, - ), - ); - - return { - mode: isRemote ? 'remote' : 'host', - // The default output mode requires the name to be a usable as a code - // symbol, there might be better options here but for now we need to - // sanitize the name. - name: name.replaceAll('@', '').replaceAll('/', '__').replaceAll('-', '_'), - }; -} - export async function buildFrontend(options: BuildAppOptions) { const { targetDir, writeStats, configPaths } = options; const { name } = await fs.readJson(resolvePath(targetDir, 'package.json')); diff --git a/packages/cli/src/commands/start/command.ts b/packages/cli/src/commands/start/command.ts index 2f2af95d85..aa46b717bb 100644 --- a/packages/cli/src/commands/start/command.ts +++ b/packages/cli/src/commands/start/command.ts @@ -15,6 +15,7 @@ */ import { OptionValues } from 'commander'; +import { PackageRole } from '@backstage/cli-node'; import { findRoleFromCommand } from '../../lib/role'; import { startBackend, startBackendPlugin } from './startBackend'; import { startFrontend } from './startFrontend'; @@ -47,6 +48,13 @@ export async function command(opts: OptionValues): Promise { case 'frontend-plugin': case 'frontend-plugin-module': return startFrontend({ entry: 'dev/index', ...options }); + case 'frontend-dynamic-container' as PackageRole: // experimental + return startFrontend({ + entry: 'src/index', + ...options, + skipOpenBrowser: true, + isModuleFederationRemote: true, + }); default: throw new Error( `Start command is not supported for package role '${role}'`, diff --git a/packages/cli/src/commands/start/startFrontend.ts b/packages/cli/src/commands/start/startFrontend.ts index a809473788..627da1bd1a 100644 --- a/packages/cli/src/commands/start/startFrontend.ts +++ b/packages/cli/src/commands/start/startFrontend.ts @@ -14,7 +14,9 @@ * limitations under the License. */ -import { serveBundle } from '../../lib/bundler'; +import { readJson } from 'fs-extra'; +import { getModuleFederationOptions, serveBundle } from '../../lib/bundler'; +import { paths } from '../../lib/paths'; interface StartAppOptions { verifyVersions?: boolean; @@ -22,14 +24,21 @@ interface StartAppOptions { checksEnabled: boolean; configPaths: string[]; + isModuleFederationRemote?: boolean; } export async function startFrontend(options: StartAppOptions) { + const { name } = await readJson(paths.resolveTarget('package.json')); + const waitForExit = await serveBundle({ entry: options.entry, checksEnabled: options.checksEnabled, configPaths: options.configPaths, verifyVersions: options.verifyVersions, + moduleFederation: getModuleFederationOptions( + name, + options.isModuleFederationRemote, + ), }); await waitForExit(); diff --git a/packages/cli/src/lib/bundler/index.ts b/packages/cli/src/lib/bundler/index.ts index a3b584efc4..2219784dc9 100644 --- a/packages/cli/src/lib/bundler/index.ts +++ b/packages/cli/src/lib/bundler/index.ts @@ -16,4 +16,5 @@ export { serveBackend } from './backend'; export { buildBundle } from './bundle'; +export { getModuleFederationOptions } from './moduleFederation'; export { serveBundle } from './server'; diff --git a/packages/cli/src/lib/bundler/moduleFederation.ts b/packages/cli/src/lib/bundler/moduleFederation.ts new file mode 100644 index 0000000000..ae3f9ab048 --- /dev/null +++ b/packages/cli/src/lib/bundler/moduleFederation.ts @@ -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 chalk from 'chalk'; +import { ModuleFederationOptions } from './types'; + +export function getModuleFederationOptions( + name: string, + isModuleFederationRemote?: boolean, +): ModuleFederationOptions | undefined { + if ( + !isModuleFederationRemote && + !process.env.EXPERIMENTAL_MODULE_FEDERATION + ) { + return undefined; + } + + console.log( + chalk.yellow( + `⚠️ WARNING: Module federation is experimental and will receive immediate breaking changes in the future.`, + ), + ); + + return { + mode: isModuleFederationRemote ? 'remote' : 'host', + // The default output mode requires the name to be a usable as a code + // symbol, there might be better options here but for now we need to + // sanitize the name. + name: name.replaceAll('@', '').replaceAll('/', '__').replaceAll('-', '_'), + }; +} diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts index 7291ac0818..60c59f757b 100644 --- a/packages/cli/src/lib/bundler/types.ts +++ b/packages/cli/src/lib/bundler/types.ts @@ -43,6 +43,7 @@ export type ServeOptions = BundlingPathsOptions & { checksEnabled: boolean; configPaths: string[]; verifyVersions?: boolean; + moduleFederation?: ModuleFederationOptions; }; export type BuildOptions = BundlingPathsOptions & { From 94ab20ebfa48ae8197be8180dfa86b7ee4ea6bfa Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Wed, 17 Jul 2024 18:04:36 +0100 Subject: [PATCH 103/225] cli: skip opening browser when serving bundle for packages with frontend-dynamic-container role Signed-off-by: MT Lewis --- packages/cli/src/commands/start/startFrontend.ts | 2 ++ packages/cli/src/lib/bundler/server.ts | 5 ++++- packages/cli/src/lib/bundler/types.ts | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/start/startFrontend.ts b/packages/cli/src/commands/start/startFrontend.ts index 627da1bd1a..20faf4a9ff 100644 --- a/packages/cli/src/commands/start/startFrontend.ts +++ b/packages/cli/src/commands/start/startFrontend.ts @@ -24,6 +24,7 @@ interface StartAppOptions { checksEnabled: boolean; configPaths: string[]; + skipOpenBrowser?: boolean; isModuleFederationRemote?: boolean; } @@ -35,6 +36,7 @@ export async function startFrontend(options: StartAppOptions) { checksEnabled: options.checksEnabled, configPaths: options.configPaths, verifyVersions: options.verifyVersions, + skipOpenBrowser: options.skipOpenBrowser, moduleFederation: getModuleFederationOptions( name, options.isModuleFederationRemote, diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index 1da15cd37e..cf35f65518 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -162,6 +162,7 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be const config = await createConfig(paths, { ...commonConfigOptions, additionalEntryPoints: detectedModulesEntryPoint, + moduleFederation: options.moduleFederation, }); if (process.env.EXPERIMENTAL_VITE) { @@ -278,7 +279,9 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be } }); - openBrowser(url.href); + if (!options.skipOpenBrowser) { + openBrowser(url.href); + } const waitForExit = async () => { for (const signal of ['SIGINT', 'SIGTERM'] as const) { diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts index 60c59f757b..f8e228c5c4 100644 --- a/packages/cli/src/lib/bundler/types.ts +++ b/packages/cli/src/lib/bundler/types.ts @@ -43,6 +43,7 @@ export type ServeOptions = BundlingPathsOptions & { checksEnabled: boolean; configPaths: string[]; verifyVersions?: boolean; + skipOpenBrowser?: boolean; moduleFederation?: ModuleFederationOptions; }; From b7709255e8b3540176b7db675e9ad77e658920cc Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Wed, 17 Jul 2024 18:12:16 +0100 Subject: [PATCH 104/225] cli: pass websocket host and port explicitly in configuration When configuring the ReactRefreshPlugin and the WebpackDevServer, we previously inferred the host and port from the browser location. This is no longer sufficient, since dynamic plugin containers may be served from separate dev servers. https://webpack.js.org/configuration/dev-server/#websocketurl https://github.com/pmmmwh/react-refresh-webpack-plugin/blob/main/docs/API.md#sockhost Signed-off-by: MT Lewis --- packages/cli/src/lib/bundler/config.ts | 30 ++++++++++++++++++++++ packages/cli/src/lib/bundler/server.ts | 12 +++------ packages/cli/src/lib/bundler/transforms.ts | 11 +------- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 9a611a5954..1f08f74a85 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -29,6 +29,7 @@ import { ModuleFederationPlugin } from '@module-federation/enhanced/webpack'; import { LinkedPackageResolvePlugin } from './LinkedPackageResolvePlugin'; import ModuleScopePlugin from 'react-dev-utils/ModuleScopePlugin'; import { RunScriptWebpackPlugin } from 'run-script-webpack-plugin'; +import ReactRefreshPlugin from '@pmmmwh/react-refresh-webpack-plugin'; import { paths as cliPaths } from '../../lib/paths'; import fs from 'fs-extra'; import { getPackages } from '@manypkg/get-packages'; @@ -54,6 +55,21 @@ export function resolveBaseUrl(config: Config): URL { } } +export function resolveEndpoint(config: Config): { + host: string; + port: number; +} { + const url = resolveBaseUrl(config); + + return { + host: config.getOptionalString('app.listen.host') ?? url.hostname, + port: + config.getOptionalNumber('app.listen.port') ?? + Number(url.port) ?? + (url.protocol === 'https:' ? 443 : 80), + }; +} + async function readBuildInfo() { const timestamp = Date.now(); @@ -108,6 +124,20 @@ export async function createConfig( publicPath = `${publicPath}${publicSubPath}`.replace('//', '/'); } + if (isDev) { + const { host, port } = resolveEndpoint(options.frontendConfig); + + plugins.push( + new ReactRefreshPlugin({ + overlay: { + sockProtocol: 'ws', + sockHost: host, + sockPort: port, + }, + }), + ); + } + if (checksEnabled) { plugins.push( new ForkTsCheckerWebpackPlugin({ diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index cf35f65518..cbd76b4ead 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -30,7 +30,7 @@ import { import { paths as libPaths } from '../../lib/paths'; import { loadCliConfig } from '../config'; import { Lockfile } from '../versioning'; -import { createConfig, resolveBaseUrl } from './config'; +import { createConfig, resolveBaseUrl, resolveEndpoint } from './config'; import { createDetectedModulesEntryPoint } from './packageDetection'; import { resolveBundlingPaths, resolveOptionalBundlingPaths } from './paths'; import { ServeOptions } from './types'; @@ -131,13 +131,7 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be const { frontendConfig, fullConfig } = cliConfig; const url = resolveBaseUrl(frontendConfig); - - const host = - frontendConfig.getOptionalString('app.listen.host') || url.hostname; - const port = - frontendConfig.getOptionalNumber('app.listen.port') || - Number(url.port) || - (url.protocol === 'https:' ? 443 : 80); + const { host, port } = resolveEndpoint(frontendConfig); const detectedModulesEntryPoint = await createDetectedModulesEntryPoint({ config: fullConfig, @@ -257,7 +251,7 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be // When the dev server is behind a proxy, the host and public hostname differ allowedHosts: [url.hostname], client: { - webSocketURL: 'auto://0.0.0.0:0/ws', + webSocketURL: { hostname: host, port }, }, }, compiler, diff --git a/packages/cli/src/lib/bundler/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts index da1eec4e11..0900b8e48e 100644 --- a/packages/cli/src/lib/bundler/transforms.ts +++ b/packages/cli/src/lib/bundler/transforms.ts @@ -17,7 +17,6 @@ import { ModuleOptions, WebpackPluginInstance } from 'webpack'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import { svgrTemplate } from '../svgrTemplate'; -import ReactRefreshPlugin from '@pmmmwh/react-refresh-webpack-plugin'; type Transforms = { loaders: ModuleOptions['rules']; @@ -193,15 +192,7 @@ export const transforms = (options: TransformOptions): Transforms => { const plugins = new Array(); - if (isDev) { - if (!isBackend) { - plugins.push( - new ReactRefreshPlugin({ - overlay: { sockProtocol: 'ws' }, - }), - ); - } - } else { + if (!isDev) { plugins.push( new MiniCssExtractPlugin({ filename: 'static/[name].[contenthash:8].css', From be1ee471cf2b09d455256ad6edfa84031ca16489 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Wed, 17 Jul 2024 18:18:20 +0100 Subject: [PATCH 105/225] cli: disable historyApiFallback for module federation remotes Module federation remotes only serve static assets, and aren't intended to be loaded directly in a browser. As such, the historyApiFallback isn't useful and should be disabled in this context. Signed-off-by: MT Lewis --- packages/cli/src/lib/bundler/server.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index cbd76b4ead..a3cbaeacc7 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -227,14 +227,17 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be directory: paths.targetPublic, } : undefined, - historyApiFallback: { - // Paths with dots should still use the history fallback. - // See https://github.com/facebookincubator/create-react-app/issues/387. - disableDotRule: true, + historyApiFallback: + options.moduleFederation?.mode === 'remote' + ? false + : { + // Paths with dots should still use the history fallback. + // See https://github.com/facebookincubator/create-react-app/issues/387. + disableDotRule: true, - // The index needs to be rewritten relative to the new public path, including subroutes. - index: `${config.output?.publicPath}index.html`, - }, + // The index needs to be rewritten relative to the new public path, including subroutes. + index: `${config.output?.publicPath}index.html`, + }, server: url.protocol === 'https:' ? { From f93ae496ce956598c56254d5420438be92faf0c0 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Wed, 17 Jul 2024 18:19:32 +0100 Subject: [PATCH 106/225] cli: add CORS headers for webpack-dev-server content This allows loading static assets from dev-servers for dynamic plugin containers. Signed-off-by: MT Lewis --- packages/cli/src/lib/bundler/server.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index a3cbaeacc7..0421f4d019 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -256,6 +256,12 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be client: { webSocketURL: { hostname: host, port }, }, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': + 'X-Requested-With, content-type, Authorization', + }, }, compiler, ); From e20fe546952f78e8f82faed76c03daf930d821ec Mon Sep 17 00:00:00 2001 From: Ramin G Date: Fri, 19 Jul 2024 15:56:09 -0400 Subject: [PATCH 107/225] Corrected typo in logging-in.md Corrected typo in the description of the image Signed-off-by: Ramin G --- docs/getting-started/logging-in.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started/logging-in.md b/docs/getting-started/logging-in.md index 5386e98798..7d6fb5d55b 100644 --- a/docs/getting-started/logging-in.md +++ b/docs/getting-started/logging-in.md @@ -20,7 +20,7 @@ Run your Backstage app with `yarn dev`. Navigate to `http://localhost:3000`. If you're not already logged in, you should see a login screen like this, -![Screenshot of the the login screen](../assets/getting-started/login-screen.png) +![Screenshot of the login screen](../assets/getting-started/login-screen.png) To login, you should choose the "Github" provider and click the "Sign in" button. This will redirect you to a Github OAuth page. Verify that the scopes mentioned on that page match the setup you did in [the authentication tutorial](./config/authentication.md). Once you click "Confirm", you will be brought back to the Backstage interface and signed in! From c761199ce774a272e303f4493ba4a9c505ee5131 Mon Sep 17 00:00:00 2001 From: rghara Date: Fri, 19 Jul 2024 18:51:44 -0400 Subject: [PATCH 108/225] Replaced login screen image Signed-off-by: rghara --- docs/assets/getting-started/login-screen.png | Bin 108521 -> 53116 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/assets/getting-started/login-screen.png b/docs/assets/getting-started/login-screen.png index 70b56f399a22d19ad863f77db22739f67a352501..1af52cb2c9b0b95cac2b6e715077bf9095ec038c 100755 GIT binary patch literal 53116 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�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