Merge branch 'backstage:master' into tutorial

This commit is contained in:
Ramin G
2024-07-19 13:37:17 -04:00
committed by GitHub
60 changed files with 1311 additions and 117 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Add frontend-dynamic-container role to eslint config factory
+7
View File
@@ -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.
+31
View File
@@ -0,0 +1,31 @@
---
'@backstage/frontend-plugin-api': patch
---
Introduce a new way to encapsulate extension kinds that replaces the extension creator pattern with `createExtensionBlueprint`
This allows the creation of extension instances with the following pattern:
```tsx
// create the extension blueprint which is used to create instances
const EntityCardBlueprint = createExtensionBlueprint({
kind: 'entity-card',
attachTo: { id: 'test', input: 'default' },
output: {
element: coreExtensionData.reactElement,
},
factory(params: { text: string }) {
return {
element: <h1>{params.text}</h1>,
};
},
});
// create an instance of the extension blueprint with params
const testExtension = EntityCardBlueprint.make({
name: 'foo',
params: {
text: 'Hello World',
},
});
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': patch
---
The `ExtensionBoundary` now by default infers whether it's routable from whether it outputs a route path.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-gitlab': patch
---
Allow the `createGitlabProjectVariableAction` to use oauth tokens
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog-backend': patch
'@backstage/plugin-catalog-node': patch
---
Added setAllowedLocationTypes while introducing a new extension point called CatalogLocationsExtensionPoint
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-react': minor
---
Add scaffolder option to display object items in separate rows on review page
+5
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Internal refactor to remove unnecessary `routable` prop in the implementation of the `createEntityContentExtension` alpha export.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-react': patch
---
support `ajv-errors` for scaffolder validation to allow for customizing the error messages
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Fix extra divider displayed on user list picker component
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-aws': patch
---
`AwsOrganizationCloudAccountProcessor` configuration field `roleArn` is deprecated in favor of new field `accountId`
@@ -7,6 +7,7 @@ ADRs
airbrake
Airbrake
Airbrakes
ajv
Alaria
Alef
allowlisted
@@ -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
@@ -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
@@ -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: (
<ExtensionBoundary node={node} routable>
<ExtensionBoundary node={node}>
<ExtensionComponent />
</ExtensionBoundary>
),
@@ -38,33 +38,31 @@ Note that while we use this naming pattern for the plugin instance this is only
| Description | Pattern | Examples |
| ----------- | ------------------------------- | ------------------------------------------------------------------- |
| Creator | `create<Kind>Extension` | `createPageExtension`, `createEntityCardExtension` |
| Blueprint | `<Kind>Blueprint` | `PageBlueprint`, `EntityCardBlueprint` |
| ID | `[<kind>:]<namespace>[/<name>]` | `'core.nav'`, `'page:user-settings'`, `'entity-card:catalog/about'` |
| Symbol | `<namespace>[<Name>][<Kind>]` | `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({
// ...
});
+11
View File
@@ -111,6 +111,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.
@@ -213,6 +213,7 @@ export class AwsCodeCommitUrlReader implements UrlReaderService {
);
const codeCommit = new CodeCommitClient({
customUserAgent: 'backstage-aws-codecommit-url-reader',
region: region,
credentials: credentials,
});
@@ -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,
+1
View File
@@ -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: [
+85 -2
View File
@@ -499,6 +499,51 @@ export function createExtension<
options: CreateExtensionOptions<TOutput, TInputs, TConfig>,
): ExtensionDefinition<TConfig>;
// @public
export function createExtensionBlueprint<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
TConfig,
>(
options: CreateExtensionBlueprintOptions<TParams, TInputs, TOutput, TConfig>,
): ExtensionBlueprint<TParams, TInputs, TOutput, TConfig>;
// @public (undocumented)
export interface CreateExtensionBlueprintOptions<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
TConfig,
> {
// (undocumented)
attachTo: {
id: string;
input: string;
};
// (undocumented)
configSchema?: PortableSchema<TConfig>;
// (undocumented)
disabled?: boolean;
// (undocumented)
factory(
params: TParams,
context: {
node: AppNode;
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
// (undocumented)
inputs?: TInputs;
// (undocumented)
kind: string;
// (undocumented)
namespace?: string;
// (undocumented)
output: TOutput;
}
// @public (undocumented)
export function createExtensionDataRef<TData>(
id: string,
@@ -538,7 +583,7 @@ export interface CreateExtensionOptions<
// (undocumented)
disabled?: boolean;
// (undocumented)
factory(options: {
factory(context: {
node: AppNode;
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
@@ -833,6 +878,45 @@ export interface Extension<TConfig> {
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<TConfig>;
params: TParams;
factory?(
params: TParams,
context: {
node: AppNode;
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
orignalFactory(
params?: TParams,
context?: {
node?: AppNode;
config?: TConfig;
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
},
): Expand<ExtensionDataValues<TOutput>>;
}): ExtensionDefinition<TConfig>;
}
// @public (undocumented)
export function ExtensionBoundary(
props: ExtensionBoundaryProps,
@@ -844,7 +928,6 @@ export interface ExtensionBoundaryProps {
children: ReactNode;
// (undocumented)
node: AppNode;
// (undocumented)
routable?: boolean;
}
@@ -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(<ErrorComponent />)).render();
await waitFor(() => expect(screen.getByText(error)).toBeInTheDocument());
const { error } = await withLogCollector(['error'], async () => {
createExtensionTester(
wrapInBoundaryExtension(<ErrorComponent />),
).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(<Emitter />))
.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' }),
]);
});
});
@@ -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) {
<Suspense fallback={<Progress />}>
<ErrorBoundary plugin={plugin} Fallback={fallback}>
<AnalyticsContext attributes={attributes}>
<RouteTracker disableTracking={!routable}>{children}</RouteTracker>
<RouteTracker disableTracking={!(routable ?? doesOutputRoutePath)}>
{children}
</RouteTracker>
</AnalyticsContext>
</ErrorBoundary>
</Suspense>
@@ -87,7 +87,7 @@ export function createPageExtension<
path: config.path,
routeRef: options.routeRef,
element: (
<ExtensionBoundary node={node} routable>
<ExtensionBoundary node={node}>
<ExtensionComponent />
</ExtensionBoundary>
),
@@ -91,7 +91,7 @@ export interface CreateExtensionOptions<
inputs?: TInputs;
output: TOutput;
configSchema?: PortableSchema<TConfig>;
factory(options: {
factory(context: {
node: AppNode;
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
@@ -115,7 +115,7 @@ export interface InternalExtensionDefinition<TConfig>
readonly version: 'v1';
readonly inputs: AnyExtensionInputMap;
readonly output: AnyExtensionDataMap;
factory(options: {
factory(context: {
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs<any>;
@@ -0,0 +1,105 @@
/*
* 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 { createExtensionBlueprint } from './createExtensionBlueprint';
import { createExtensionTester } from '@backstage/frontend-test-utils';
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(params: { text: string }) {
return {
element: <h1>{params.text}</h1>,
};
},
});
const extension = TestExtensionBlueprint.make({
name: 'my-extension',
params: {
text: 'Hello, world!',
},
});
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!');
});
it('should allow overriding of the default factory', () => {
const TestExtensionBlueprint = createExtensionBlueprint({
kind: 'test-extension',
attachTo: { id: 'test', input: 'default' },
output: {
element: coreExtensionData.reactElement,
},
factory(params: { text: string }) {
return {
element: <h1>{params.text}</h1>,
};
},
});
const extension = TestExtensionBlueprint.make({
name: 'my-extension',
params: {
text: 'Hello, world!',
},
factory(params: { text: string }) {
return {
element: <h2>{params.text}</h2>,
};
},
});
expect(extension).toBeDefined();
const { container } = createExtensionTester(extension).render();
expect(container.querySelector('h2')).toHaveTextContent('Hello, world!');
});
});
@@ -0,0 +1,191 @@
/*
* 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 { AppNode } from '../apis';
import { PortableSchema } from '../schema';
import { Expand } from '../types';
import {
AnyExtensionDataMap,
AnyExtensionInputMap,
ExtensionDataValues,
ExtensionDefinition,
ResolvedExtensionInputs,
createExtension,
} from './createExtension';
/**
* @public
*/
export interface CreateExtensionBlueprintOptions<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
TConfig,
> {
kind: string;
namespace?: string;
attachTo: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
output: TOutput;
configSchema?: PortableSchema<TConfig>;
factory(
params: TParams,
context: {
node: AppNode;
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
}
/**
* @public
*/
export interface ExtensionBlueprint<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
TConfig,
> {
make(args: {
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
output?: TOutput;
configSchema?: PortableSchema<TConfig>;
params: TParams;
factory?(
params: TParams,
context: {
node: AppNode;
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
orignalFactory(
params?: TParams,
context?: {
node?: AppNode;
config?: TConfig;
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
},
): Expand<ExtensionDataValues<TOutput>>;
}): ExtensionDefinition<TConfig>;
}
/**
* @internal
*/
class ExtensionBlueprintImpl<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
TConfig,
> {
constructor(
private readonly options: CreateExtensionBlueprintOptions<
TParams,
TInputs,
TOutput,
TConfig
>,
) {}
public make(args: {
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
output?: TOutput;
configSchema?: PortableSchema<TConfig>;
params: TParams;
factory?(
params: TParams,
context: {
node: AppNode;
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
orignalFactory(
params?: TParams,
context?: {
node?: AppNode;
config?: TConfig;
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
},
): Expand<ExtensionDataValues<TOutput>>;
}): ExtensionDefinition<TConfig> {
return createExtension({
kind: this.options.kind,
namespace: args.namespace ?? this.options.namespace,
name: args.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 (args.factory) {
return args.factory(args.params, {
node,
config,
inputs,
orignalFactory: (
innerParams?: TParams,
innerContext?: {
config?: TConfig;
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
) =>
this.options.factory(innerParams ?? args.params, {
node,
config: innerContext?.config ?? config,
inputs: innerContext?.inputs ?? inputs,
}),
});
}
return this.options.factory(args.params, {
node,
config,
inputs,
});
},
});
}
}
/**
* 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 createExtensionBlueprint<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
TConfig,
>(
options: CreateExtensionBlueprintOptions<TParams, TInputs, TOutput, TConfig>,
): ExtensionBlueprint<TParams, TInputs, TOutput, TConfig> {
return new ExtensionBlueprintImpl(options);
}
@@ -48,3 +48,8 @@ export {
type FeatureFlagConfig,
type FrontendFeature,
} from './types';
export {
type CreateExtensionBlueprintOptions,
type ExtensionBlueprint,
createExtensionBlueprint,
} from './createExtensionBlueprint';
+6
View File
@@ -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;
};
};
};
@@ -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);
});
@@ -22,8 +22,14 @@ import { Config } from '@backstage/config';
export type AwsOrganizationProviderConfig = {
/**
* The role to assume for the processor.
* @deprecated Use `accountId` instead.
*/
roleArn?: string;
/**
* The AWS accountId
*/
accountId?: string;
};
export function readAwsOrganizationConfig(
@@ -32,7 +38,9 @@ export function readAwsOrganizationConfig(
const providerConfig = config.getOptionalConfig('provider');
const roleArn = providerConfig?.getOptionalString('roleArn');
const accountId = providerConfig?.getOptionalString('accountId');
return {
roleArn,
accountId,
};
}
@@ -106,6 +106,7 @@ export class AwsEKSClusterProcessor implements CatalogProcessor {
}
const eksClient = new EKS({
customUserAgent: 'backstage-aws-catalog-eks-cluster-processor',
credentials,
credentialDefaultProvider: providerFunction,
region,
@@ -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,
@@ -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,
@@ -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
@@ -215,6 +215,7 @@ export type MicrosoftGraphProviderConfig = {
groupFilter?: string;
groupSearch?: string;
groupSelect?: string[];
groupIncludeSubGroups?: boolean;
queryMode?: 'basic' | 'advanced';
loadUserPhotos?: boolean;
schedule?: TaskScheduleDefinition;
@@ -263,6 +264,7 @@ export function readMicrosoftGraphOrg(
groupSearch?: string;
groupFilter?: string;
groupSelect?: string[];
groupIncludeSubGroups?: boolean;
queryMode?: 'basic' | 'advanced';
userTransformer?: UserTransformer;
groupTransformer?: GroupTransformer;
+5
View File
@@ -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?: {
@@ -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.
@@ -174,6 +174,7 @@ describe('readProviderConfigs', () => {
expand: 'member',
filter: 'securityEnabled eq false',
select: ['id', 'displayName', 'description'],
includeSubGroups: true,
},
schedule: {
frequency: 'PT30M',
@@ -202,6 +203,7 @@ describe('readProviderConfigs', () => {
groupExpand: 'member',
groupSelect: ['id', 'displayName', 'description'],
groupFilter: 'securityEnabled eq false',
groupIncludeSubGroups: true,
schedule: {
frequency: { minutes: 30 },
timeout: {
@@ -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,
@@ -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<GroupMember> {
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', () => {
@@ -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,
});
@@ -338,6 +338,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,
@@ -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<string>();
setAllowedLocationTypes(locationTypes: Array<string>) {
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 () => {
+8
View File
@@ -34,6 +34,14 @@ export interface CatalogAnalysisExtensionPoint {
// @alpha (undocumented)
export const catalogAnalysisExtensionPoint: ExtensionPoint<CatalogAnalysisExtensionPoint>;
// @alpha (undocumented)
export interface CatalogLocationsExtensionPoint {
setAllowedLocationTypes(locationTypes: Array<string>): void;
}
// @alpha (undocumented)
export const catalogLocationsExtensionPoint: ExtensionPoint<CatalogLocationsExtensionPoint>;
// @alpha (undocumented)
export interface CatalogModelExtensionPoint {
setEntityDataParser(parser: CatalogProcessorParser): void;
+2
View File
@@ -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';
+19
View File
@@ -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<string>): void;
}
/**
* @alpha
*/
export const catalogLocationsExtensionPoint =
createExtensionPoint<CatalogLocationsExtensionPoint>({
id: 'catalog.locations',
});
/**
* @alpha
*/
+1 -1
View File
@@ -166,7 +166,7 @@ export function createEntityContentExtension<
title: config.title,
routeRef: options.routeRef,
element: (
<ExtensionBoundary node={node} routable>
<ExtensionBoundary node={node}>
<ExtensionComponent />
</ExtensionBoundary>
),
@@ -247,11 +247,11 @@ export const UserListPicker = (props: UserListPickerProps) => {
</Typography>
<Card className={classes.groupWrapper}>
<List disablePadding dense role="menu" aria-label={group.name}>
{group.items.map(item => (
{group.items.map((item, index) => (
<MenuItem
role="none presentation"
key={item.id}
divider
divider={index !== group.items.length - 1}
onClick={() => setSelectedUserFilter(item.id)}
selected={item.id === filters.user?.value}
className={classes.menuItem}
@@ -76,6 +76,7 @@ export class AwsSqsConsumingEventPublisher {
};
this.sqs = new SQSClient({
customUserAgent: 'backstage-aws-events-sqs-publisher',
region: config.region,
endpoint: config.endpoint,
});
@@ -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: '*',
},
);
});
@@ -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,
});
},
});
+1
View File
@@ -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",
@@ -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 () => {
@@ -202,4 +253,159 @@ describe('ReviewState', () => {
expect(queryByRole('row', { name: 'Name type4' })).toBeInTheDocument();
});
it('should display object in separate rows', async () => {
const formState = {
name: {
foo: 'type3',
bar: 'type4',
},
};
const schemas: ParsedTemplateSchema[] = [
{
mergedSchema: {
type: 'object',
properties: {
name: {
type: 'object',
properties: {
foo: {
type: 'string',
default: 'type1',
},
bar: {
type: 'string',
default: 'type2',
},
},
},
},
},
schema: {},
title: 'test',
uiSchema: {},
},
];
const { queryByRole } = render(
<ReviewState formState={formState} schemas={schemas} />,
);
expect(queryByRole('row', { name: 'Foo type3' })).toBeInTheDocument();
expect(queryByRole('row', { name: 'Bar type4' })).toBeInTheDocument();
});
it('should display nested objects in separate rows', 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',
default: 'type1',
},
bar: {
type: 'string',
default: 'type2',
},
example: {
type: 'object',
properties: {
test: {
type: 'string',
},
},
},
},
},
},
},
schema: {},
title: 'test',
uiSchema: {},
},
];
const { queryByRole } = render(
<ReviewState formState={formState} schemas={schemas} />,
);
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',
default: 'type1',
},
bar: {
type: 'string',
default: 'type2',
},
example: {
type: 'object',
'ui:backstage': {
review: {
explode: false,
},
},
properties: {
test: {
type: 'string',
},
},
},
},
},
},
},
schema: {},
title: 'test',
uiSchema: {},
},
];
const { queryByRole } = render(
<ReviewState formState={formState} schemas={schemas} />,
);
expect(queryByRole('row', { name: 'Foo type3' })).toBeInTheDocument();
expect(queryByRole('row', { name: 'Bar type4' })).toBeInTheDocument();
expect(queryByRole('row', { name: 'Test type6' })).not.toBeInTheDocument();
});
});
@@ -15,9 +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 { isJsonObject, getLastKey } from './util';
/**
* The props for the {@link ReviewState} component.
@@ -28,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
@@ -35,42 +85,11 @@ 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({
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 (definitionInSchema['ui:widget'] === 'password') {
return [key, '******'];
}
if (definitionInSchema.enum && definitionInSchema.enumNames) {
return [
key,
definitionInSchema.enumNames[
definitionInSchema.enum.indexOf(value)
] || value,
];
}
}
return processSchema(key, value, step, props.formState);
}
return [key, value];
return [[key, value]];
})
.filter(prop => prop.length > 0),
);
@@ -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 { isJsonObject, getLastKey } from './util';
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 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('getLastKey', () => {
it('should return the last part of a simple key', () => {
expect(getLastKey('simple')).toBe('simple');
});
it('should return the last part of a nested key', () => {
expect(getLastKey('parent/child')).toBe('child');
});
it('should return the last part of a deeply nested key', () => {
expect(getLastKey('grandparent/parent/child')).toBe('child');
});
it('should handle keys with trailing slash', () => {
expect(getLastKey('parent/child/')).toBe('');
});
it('should handle empty string', () => {
expect(getLastKey('')).toBe('');
});
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@!#$%^&*()');
});
});
@@ -0,0 +1,27 @@
/*
* 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';
export function isJsonObject(value?: JsonValue): value is JsonObject {
return typeof value === 'object' && !Array.isArray(value);
}
// Helper function to get the last part of the key
export function getLastKey(key: string): string {
const parts = key.split('/');
return parts[parts.length - 1];
}
@@ -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(
<SecretsContextProvider>
<Stepper manifest={manifest} extensions={[]} onCreate={jest.fn()} />
</SecretsContextProvider>,
);
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: [
@@ -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: {
+4 -3
View File
@@ -7113,6 +7113,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
@@ -41326,9 +41327,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