Merge branch 'master' of https://github.com/backstage/backstage into fix-redirect-error-handling

This commit is contained in:
Stephen Glass
2024-10-06 20:22:00 -04:00
83 changed files with 1983 additions and 905 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app-backend': patch
---
Fixed unexpected behaviour where configuration supplied with `APP_CONFIG_*` environment variables where not filtered by the configuration schema.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': minor
---
Adding negation keyword for entity filtering
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-bitbucket-server': patch
---
Add `reviewers` input parameter to `publish:bitbucketServer:pull-request`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-microsoft-provider': patch
---
Add `skipUserProfile` config flag to Microsoft authenticator
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Add translation to the editor toolbar component.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Standardize template editor pages desktop and mobile layouts.
+29
View File
@@ -0,0 +1,29 @@
---
'@backstage/plugin-catalog-backend-module-bitbucket-cloud': minor
---
Fixes the event-based updates at `BitbucketCloudEntityProvider`.
Previously, this entity provider had optional event support for legacy backends
that could be enabled by passing `catalogApi`, `events`, and `tokenManager`.
For the new/current backend system, the `catalogModuleBitbucketCloudEntityProvider`
(`catalog.bitbucket-cloud-entity-provider`), event support was enabled by default.
A recent change removed `tokenManager` as a dependency from the module as well as removed it as input.
While this didn't break the instantiation of the module, it broke the event-based updates,
and led to a runtime misbehavior, accompanied by an info log message.
This change will replace the use of `tokenManager` with the use of `auth` (`AuthService`).
Additionally, to simplify, it will make `catalogApi` and `events` required dependencies.
For the current backend system, this change is transparent and doesn't require any action.
For the legacy backend system, this change will require you to pass those dependencies
if you didn't do it already.
BREAKING CHANGES:
_(For legacy backend users only.)_
Previously optional `catalogApi`, and `events` are required now.
A new required dependency `auth` was added.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-node': patch
---
Updated dependency `@smithy/node-http-handler` to `^3.0.0`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Support `--max-warnings` flag for package linting
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search': patch
---
Updated the default SearchType.Accordion behavior to remain open after result type selection. This is a UX improvement to reduce the number of clicks needed when toggling result type filters.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Add an actions filter on the list actions page and drawer.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-techdocs-module-addons-contrib': patch
'@backstage/plugin-techdocs': patch
---
Use more of the available space for the navigation sidebar.
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/plugin-catalog-graph': patch
---
Added InfoCard `action` attribute for CatalogGraphCard
```tsx
const action = <Button title="Action Button" onClick={handleClickEvent()} />
<CatalogGraphCard action={action} />
```
+9 -8
View File
@@ -185,11 +185,11 @@ Scope: The Backstage Documentation
## Sponsors
| Name | Organization | GitHub | Email |
| ----------------- | ------------ | ------------------------------------------- | ------------------ |
| Niklas Gustavsson | Spotify | [protocol7](https://github.com/protocol7) | ngn@spotify.com |
| Dave Zolotusky | Spotify | [dzolotusky](https://github.com/dzolotusky) | dzolo@spotify.com |
| Helen Greul | Spotify | [helengreul](https://github.com/helengreul) | heleng@spotify.com |
| Name | Organization | GitHub | Email |
| ----------------- | ------------ | ------------------------------------------- | ----------------- |
| Niklas Gustavsson | Spotify | [protocol7](https://github.com/protocol7) | ngn@spotify.com |
| Dave Zolotusky | Spotify | [dzolotusky](https://github.com/dzolotusky) | dzolo@spotify.com |
| Pia Nilsson | Spotify | [pianilsson](https://github.com/pianilsson) | pia@spotify.com |
## Organization Members
@@ -224,9 +224,10 @@ Scope: The Backstage Documentation
## Emeritus End User Sponsors
| Name | Organization | GitHub | Discord |
| --------- | ------------ | ------------------------------------------- | -------------- |
| Lee Mills | Spotify | [leemills83](https://github.com/leemills83) | `.binarypoint` |
| Name | Organization | GitHub | Discord |
| ----------- | ------------ | ------------------------------------------- | -------------- |
| Lee Mills | Spotify | [leemills83](https://github.com/leemills83) | `.binarypoint` |
| Helen Greul | Spotify | [helengreul](https://github.com/helengreul) | `helen_greul` |
## Emeritus Project Area Maintainers
+1
View File
@@ -86,6 +86,7 @@ The Microsoft provider is a structure with three mandatory configuration keys:
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, to be requested in addition to the required ones.
- `skipUserProfile` (optional): If true, skips loading the user profile even if the `User.Read` scope is present. This is a performance optimization during login and can be used with resolvers that only needs the email address in `spec.profile.email` obtained when the `email` OAuth2 scope is present.
### Resolvers
+43 -10
View File
@@ -143,21 +143,54 @@ how highlighted terms look you can follow Backstage's guide on how to
[Customize the look-and-feel of your App](https://backstage.io/docs/getting-started/app-custom-theme)
to create an override with your preferred styling.
For example, the following will result in highlighted terms to be bold & underlined:
For example, using the new MUI V4+V5 unified theming method, the following will result
in highlighted words to be bold & underlined:
```tsx
const highlightOverride = {
BackstageHighlightedSearchResultText: {
highlight: {
color: 'inherit',
backgroundColor: 'inherit',
fontWeight: 'bold',
textDecoration: 'underline',
```typescript jsx title=packages/app/src/theme/theme.ts
import {
createBaseThemeOptions,
createUnifiedTheme,
palettes,
UnifiedTheme,
} from '@backstage/theme';
export const myLightTheme: UnifiedTheme = createUnifiedTheme({
...createBaseThemeOptions({
palette: palettes.light,
}),
defaultPageTheme: 'home',
components: {
/** @ts-ignore This is temporarily necessary until MUI V5 transition is completed. */
BackstageHighlightedSearchResultText: {
styleOverrides: {
highlight: {
color: 'inherit',
backgroundColor: 'inherit',
fontWeight: 'bold',
textDecoration: 'underline',
},
},
},
},
};
});
```
```typescript jsx title= packages/app/src/App.tsx
const app : BackstageApp = createApp({
...
themes: [{
id: 'my-light-theme',
title: 'Light Theme',
variant: 'light',
icon: <LightIcon />,
Provider: ({ children }) => (<UnifiedThemeProvider theme={myLightTheme} children={children } />)
}]
});
```
Obviously if you wanted a dark theme, you would need to provide that as well.
## How to render search results using extensions
Extensions for search results let you customize components used to render search result items, It is possible to provide your own search result item extensions or use the ones provided by plugin packages.
+1 -30
View File
@@ -51,33 +51,6 @@ Further documentation:
### Installation with Legacy Backend System
#### Installation without Events Support
And then add the entity provider to your catalog builder:
```ts title="packages/backend/src/plugins/catalog.ts"
/* highlight-add-next-line */
import { BitbucketCloudEntityProvider } from '@backstage/plugin-catalog-backend-module-bitbucket-cloud';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const builder = await CatalogBuilder.create(env);
/* highlight-add-start */
builder.addEntityProvider(
BitbucketCloudEntityProvider.fromConfig(env.config, {
logger: env.logger,
scheduler: env.scheduler,
}),
);
/* highlight-add-end */
// ..
}
```
#### Installation with Events Support
Please follow the installation instructions at
- <https://github.com/backstage/backstage/tree/master/plugins/events-backend/README.md>
@@ -104,19 +77,17 @@ export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const builder = await CatalogBuilder.create(env);
builder.addProcessor(new ScaffolderEntitiesProcessor());
/* highlight-add-start */
const bitbucketCloudProvider = BitbucketCloudEntityProvider.fromConfig(
env.config,
{
auth: env.auth,
catalogApi: new CatalogClient({ discoveryApi: env.discovery }),
events: env.events,
logger: env.logger,
scheduler: env.scheduler,
tokenManager: env.tokenManager,
},
);
env.eventBroker.subscribe(bitbucketCloudProvider);
builder.addEntityProvider(bitbucketCloudProvider);
/* highlight-add-end */
const { processingEngine, router } = await builder.build();
@@ -0,0 +1,95 @@
---
title: 'Adopter Spotlight: Level-up developer experience with observability and security in context'
author: Johannes Bräuer, Dynatrace
authorURL: https://github.com/johannes-b
authorImageURL: https://avatars.githubusercontent.com/u/729071?v=4
---
**TL;DR**
To enhance the developer experience, Dynatrace adopted Backstage as its central developer portal and enhances Backstage entities with real-time data. This decision, along with the symbiosis between the Dynatrace platform and Backstage, has unlocked two significant opportunities.
- First, centralizing all development-related artifacts and democratizing ownership have reduced onboarding time for our teams.
- Second, we enhanced the developer experience by integrating observability and security data into Backstage, offering seamless entry points to Dynatrace for in-depth analysis.
![Dynatrace adopting Backstage](assets/2024-09-24/com0027.Dynatrace.Adopter.png)
{/* truncate */}
## Why and how Dynatrace rolled out Backstage
A few years ago, Dynatrace developers worked with large monolithic repositories to develop functionality for our platforms agent and server sides. The server component was particularly large, consisting of 260 Gradle projects in a single repository. This setup centralized development processes for the developers, making it easier for them to push the code while versioning, delivery, and hotfixes were handled automatically. However, maintaining the speed and manageability of these processes required a lot of effort.
Dynatrace decided to move towards the current Dynatrace platform model as the next evolutionary step of our product. This decision led to an architectural change of splitting the monolithic repository into multiple projects. The platform is designed to enable the development of apps on top of platform capabilities to unlock faster innovations and decouple them from the release cycles of other components. Based on this decision, it became apparent that the number of platform components and individual apps would increase significantly, eliminating the option of a single repository to unify all processes. Besides, the risk of increasing cognitive load in software development was high due to development being spread across multiple touchpoints, a challenge discussed in research for years ([Sweller, 1988](<https://doi.org/10.1016/0364-0213(88)90023-7>), [Robert, 2008](https://dl.acm.org/doi/10.5555/1388398)). Consequently, the need to standardize project creation became crucial to ensure corporate governance and compliance even before the first commit was pushed.
<!-- References:
[^1]: John Sweller, Cognitive load during problem solving: Effects on learning, Cognitive Science, Volume 12, Issue 2, 1988, Pages 257-285, ISSN 0364-0213, https://doi.org/10.1016/0364-0213(88)90023-7.
[^2]: Robert C. Martin Series, Clean Code: A Handbook of Agile Software Craftsmanship (Robert C. Martin Series), 2008, ISBN 9780132350884.
-->
Therefore, the Dynatrace Platform Engineering team initiated a project to standardize and simplify the process for starting service or application development. This effort was initially named “project initializer” and launched around the same time Backstage joined the CNCF. Although the platform engineering team saw initial success with the project initializer, we quickly realized that there was a greater demand for centralizing development activities and providing appropriate guidelines. For example, we noted that the complexity of integrating new code had shifted from the build phase to the deployment phase, transferring relatively complex integration tasks from continuous integration to continuous deployment. Overall, the main requirements and focal points were:
- **Ownership**: Who is responsible for which service or app? Who owns infrastructure resources?
- **Documentation**: Where is the documentation of a service or app?
- **CI/CD view**: How does the build pipeline in GitHub/Jenkins progress, and what about the ArgoCD deployment?
- **Dependency management**: Do we know which APIs are used?
- **Observability and security**: Where is a service deployed, and is it healthy and secure?
For more details on Dynatrace's adoption of Backstage, please watch the recording, [How We Made Backstage Improve Developer Efficiency of 1000+ Engineers](https://www.youtube.com/watch?v=0or5K_3HieA), BackstageCon, November 6, 2023 in Chicago. Illinois.
## Quick wins with ownership democratization and self-service templates
The discussion on whether to make, buy, or adopt led us to heavily favor Backstage as the chosen solution for our internal developer platform (IDP). We have successfully integrated Backstage within Dynatrace, marking our early wins. This was accomplished by linking it to our internal team management solution. Feeding this data into Backstage resolved the previously lacking transparency concerning the responsibilities and ownership of services and infrastructure. Furthermore, we have contextualized Stack Overflow and CI/CD solutions with entities monitored by Backstage to fulfill developers' requests for a unified view.
Next to establishing a solid software catalog view, providing self-service templates for project and infrastructure creation—initially a focus of the original developer experience—has also been incorporated into Backstage. This enhancement allows the use of a comprehensive set of templates for bootstrapping platform services or applications across different tool stacks. Ultimately, developers can effortlessly use a template to create a ready-to-use repository with observability and security pre-configured.
## Static model enrichment with real-time observability and security
After addressing ownership, documentation, and a centralized CI/CD view, our developers highlighted the necessity of accessing real-time data about their services and applications within the Backstage service catalog. This need arose from the understanding that integrating a code change can be optimally evaluated on a deployed version of their service alongside related components. Specifically, the developers were interested in seeing at a high level:
- Where specific versions of services are deployed,
- How their services perform in the hardening phase and production environments,
- Which inbound and outbound dependencies their service has,
- Whether any problems, security vulnerabilities, or SLO breaches are related to their service, and
- Which recent error logs have been collected.
The open source community already developed a Backstage plugin capable of fetching Dynatrace problems and synthetics into Backstage. However, we chose to develop a new plugin that works with the new Dynatrace platform API. Additionally, it was necessary to support Kubernetes use cases by default to offer, for example, deployment overviews out-of-the-box. Based on the feedback from developers, we identified four essential requirements.
### Coverage of the software development lifecycle
Observing the software development lifecycle necessitates consolidating all development and delivery phases. In Dynatrace, monitoring the development stage is distinct from rolling out new versions to the hardening and production stages. Nonetheless, developers desire an end-to-end view from development to production. To support this, the plugin allows connections to multiple Dynatrace environments to retrieve data for the various rollout phases.
### Kubernetes observability
When using the Backstage Kubernetes plugin, an annotation is required to surface your Kubernetes components as part of an entity (for more information, please refer to [Backstage documentation](https://backstage.io/docs/features/kubernetes/configuration/#surfacing-your-kubernetes-components-as-part-of-an-entity)). The Dynatrace plugin utilizes this convention to automatically populate monitoring data from Kubernetes deployments into the deployment overview in Backstage. Therefore, additional configuration is unnecessary to enrich Backstage entities with real-time observability data from Kubernetes, as shown below. Additionally, deep links open Dynatrace analysis views for more contextual details.
![Kubernetes deployment overview](assets/2024-09-24/backstage_dynatrace_plugin_K8s.png)
### Reliability and security in context
While Kubernetes observability was requested to be provided out of the box, we quickly realized that developers prefer to define the insights they want to see by themselves. To remain flexible and to reduce the customization effort required from the platform engineering teams, the plugin allows for the definition of custom queries within the `config.yaml` file. The Backstage `config.yaml` file requires special attention since platform teams use this file to standardize entity specifications, while developers use it to customize their Backstage views. After releasing this new functionality, we observed developers moving in two distinct directions:
- Querying quality gate validation results of a new version theyre developing
![Release validation overview](assets/2024-09-24/backstage_srg_validations.png)
- Fetching runtime security vulnerability information across different stages
![Security vulnerabilities in context](assets/2024-09-24/backstage_catalog_security_vulnerabilites.png)
### Error logs at hand
Developers love logs. Direct access to logs, especially error logs, was a crucial requirement for the plugin. Our developers want to see the current number of error logs accompanied by a direct link to the raw log line, which provides significant value in problem triaging and fixing. With the plugin, we offer a high-level overview of log statistics, with the rich analysis capabilities of Dynatrace available at a single click.
## Conclusion
To enhance the developer experience, Dynatrace adopted Backstage as its central developer portal and enhances Backstage entities with real-time data. This decision, along with the symbiosis between the Dynatrace platform and Backstage, has unlocked two significant opportunities.
First, centralizing all development-related artifacts and democratizing ownership have reduced onboarding time for our teams. Although it might seem that onboarding time is only relevant for new developers, it is also important when developers move to a different team, pick up a service or application they havent worked on for a while, or replace their laptops. Moreover, a low onboarding time is crucial when quickly kicking off work on a new service or solution app. This became a necessity with the new Dynatrace platform.
Second, we enhanced the developer experience by integrating observability and security data into Backstage, offering seamless entry points to Dynatrace for in-depth analysis. This improvement was crucial as we noted a shift in integration tasks from build to development time, necessitating insights into a later phase of the software lifecycle for our developers. In essence, end-to-end validation of code changes could no longer be accomplished with build pipelines—either in Jenkins or GitHub—but required deployment in production-like environments that facilitate real-world interaction with other components. Consequently, real-time observability data from these environments must be relayed to the developer.
## What`s next?
Backstage is a crucial element for Dynatrace's developer experience, providing out-of-the-box core functionality for every developer and supporting extensibility where needed. Our Backstage extension is maintained as an open-source project and is available for every Backstage user. Don't hesitate to utilize it or to contribute: https://github.com/Dynatrace/backstage-plugin
If you have a great Backstage story to tell, please share your experience with us to the variety of use case areas.
Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+1
View File
@@ -237,6 +237,7 @@ Usage: backstage-cli package lint [options] [directories...]
Options:
--format <format>
--fix
--max-warnings <number>
-h, --help
```
+4
View File
@@ -156,6 +156,10 @@ export function registerScriptCommand(program: Command) {
'eslint-formatter-friendly',
)
.option('--fix', 'Attempt to automatically fix violations')
.option(
'--max-warnings <number>',
'Fail if more than this number of warnings (default: 0)',
)
.description('Lint a package')
.action(lazy(() => import('./lint').then(m => m.default)));
+11 -2
View File
@@ -29,6 +29,13 @@ export default async (directories: string[], opts: OptionValues) => {
directories.length ? directories : ['.'],
);
const maxWarnings = opts.maxWarnings ?? 0;
const failed =
results.some(r => r.errorCount > 0) ||
results.reduce((current, next) => current + next.warningCount, 0) >
maxWarnings;
if (opts.fix) {
await ESLint.outputFixes(results);
}
@@ -39,12 +46,14 @@ export default async (directories: string[], opts: OptionValues) => {
if (opts.format === 'eslint-formatter-friendly') {
process.chdir(paths.targetRoot);
}
const resultText = formatter.format(results);
// If there is any feedback at all, we treat it as a lint failure. This should be
// consistent with our old behavior of passing `--max-warnings=0` when invoking eslint.
if (resultText) {
console.log(resultText);
}
if (failed) {
process.exit(1);
}
};
+1 -51
View File
@@ -27,57 +27,7 @@ import {
import { runParallelWorkers } from '../../lib/parallel';
import { buildFrontend } from '../build/buildFrontend';
import { buildBackend } from '../build/buildBackend';
function createScriptOptionsParser(anyCmd: Command, commandPath: string[]) {
// Regardless of what command instance is passed in we want to find
// the root command and resolve the path from there
let rootCmd = anyCmd;
while (rootCmd.parent) {
rootCmd = rootCmd.parent;
}
// Now find the command that was requested
let targetCmd = rootCmd as Command | undefined;
for (const name of commandPath) {
targetCmd = targetCmd?.commands.find(c => c.name() === name) as
| Command
| undefined;
}
if (!targetCmd) {
throw new Error(
`Could not find package command '${commandPath.join(' ')}'`,
);
}
const cmd = targetCmd;
const expectedScript = `backstage-cli ${commandPath.join(' ')}`;
return (scriptStr?: string) => {
if (!scriptStr || !scriptStr.startsWith(expectedScript)) {
return undefined;
}
const argsStr = scriptStr.slice(expectedScript.length).trim();
// Can't clone or copy or even use commands as prototype, so we mutate
// the necessary members instead, and then reset them once we're done
const currentOpts = (cmd as any)._optionValues;
const currentStore = (cmd as any)._storeOptionsAsProperties;
const result: Record<string, any> = {};
(cmd as any)._storeOptionsAsProperties = false;
(cmd as any)._optionValues = result;
// Triggers the writing of options to the result object
cmd.parseOptions(argsStr.split(' '));
(cmd as any)._storeOptionsAsProperties = currentOpts;
(cmd as any)._optionValues = currentStore;
return result;
};
}
import { createScriptOptionsParser } from './optionsParser';
export async function command(opts: OptionValues, cmd: Command): Promise<void> {
let packages = await PackageGraph.listTargetPackages();
+34 -12
View File
@@ -15,11 +15,12 @@
*/
import chalk from 'chalk';
import { OptionValues } from 'commander';
import { Command, OptionValues } from 'commander';
import { relative as relativePath } from 'path';
import { PackageGraph, BackstagePackageJson } from '@backstage/cli-node';
import { paths } from '../../lib/paths';
import { runWorkerQueueThreads } from '../../lib/parallel';
import { createScriptOptionsParser } from './optionsParser';
function depCount(pkg: BackstagePackageJson) {
const deps = pkg.dependencies ? Object.keys(pkg.dependencies).length : 0;
@@ -29,7 +30,7 @@ function depCount(pkg: BackstagePackageJson) {
return deps + devDeps;
}
export async function command(opts: OptionValues): Promise<void> {
export async function command(opts: OptionValues, cmd: Command): Promise<void> {
let packages = await PackageGraph.listTargetPackages();
if (opts.since) {
@@ -54,22 +55,30 @@ export async function command(opts: OptionValues): Promise<void> {
process.env.FORCE_COLOR = '1';
}
const parseLintScript = createScriptOptionsParser(cmd, ['package', 'lint']);
const resultsList = await runWorkerQueueThreads({
items: packages.map(pkg => ({
fullDir: pkg.dir,
relativeDir: relativePath(paths.targetRoot, pkg.dir),
lintOptions: parseLintScript(pkg.packageJson.scripts?.lint),
})),
workerData: {
fix: Boolean(opts.fix),
format: opts.format as string | undefined,
},
workerFactory: async ({ fix, format }) => {
const { ESLint } = require('eslint');
const { ESLint } = require('eslint') as typeof import('eslint');
return async ({
fullDir,
relativeDir,
}): Promise<{ relativeDir: string; resultText: string }> => {
lintOptions,
}): Promise<{
relativeDir: string;
resultText: string;
failed: boolean;
}> => {
// Bit of a hack to make file resolutions happen from the correct directory
// since some lint rules don't respect the cwd of ESLint
process.cwd = () => fullDir;
@@ -92,21 +101,34 @@ export async function command(opts: OptionValues): Promise<void> {
await ESLint.outputFixes(results);
}
const resultText = formatter.format(results);
const maxWarnings = lintOptions?.maxWarnings ?? 0;
const resultText = formatter.format(results) as string;
const failed =
results.some(r => r.errorCount > 0) ||
results.reduce((current, next) => current + next.warningCount, 0) >
maxWarnings;
return { relativeDir, resultText };
return {
relativeDir,
resultText,
failed,
};
};
},
});
let failed = false;
for (const { relativeDir, resultText } of resultsList) {
if (resultText) {
console.log();
console.log(chalk.red(`Lint failed in ${relativeDir}:`));
console.log(resultText.trimStart());
for (const { relativeDir, resultText, failed: runFailed } of resultsList) {
if (runFailed) {
console.log(chalk.red(`Lint failed in ${relativeDir}`));
failed = true;
// When doing repo lint, only list the results if the lint failed to avoid a log
// dump of all warnings that might be irrelevant
if (resultText) {
console.log();
console.log(resultText.trimStart());
}
}
}
@@ -0,0 +1,70 @@
/*
* 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 { Command } from 'commander';
export function createScriptOptionsParser(
anyCmd: Command,
commandPath: string[],
) {
// Regardless of what command instance is passed in we want to find
// the root command and resolve the path from there
let rootCmd = anyCmd;
while (rootCmd.parent) {
rootCmd = rootCmd.parent;
}
// Now find the command that was requested
let targetCmd = rootCmd as Command | undefined;
for (const name of commandPath) {
targetCmd = targetCmd?.commands.find(c => c.name() === name) as
| Command
| undefined;
}
if (!targetCmd) {
throw new Error(
`Could not find package command '${commandPath.join(' ')}'`,
);
}
const cmd = targetCmd;
const expectedScript = `backstage-cli ${commandPath.join(' ')}`;
return (scriptStr?: string) => {
if (!scriptStr || !scriptStr.startsWith(expectedScript)) {
return undefined;
}
const argsStr = scriptStr.slice(expectedScript.length).trim();
// Can't clone or copy or even use commands as prototype, so we mutate
// the necessary members instead, and then reset them once we're done
const currentOpts = (cmd as any)._optionValues;
const currentStore = (cmd as any)._storeOptionsAsProperties;
const result: Record<string, any> = {};
(cmd as any)._storeOptionsAsProperties = false;
(cmd as any)._optionValues = result;
// Triggers the writing of options to the result object
cmd.parseOptions(argsStr.split(' '));
(cmd as any)._storeOptionsAsProperties = currentOpts;
(cmd as any)._optionValues = currentStore;
return result;
};
}
@@ -0,0 +1,99 @@
/*
* 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 { createMockDirectory } from '@backstage/backend-test-utils';
import { readFrontendConfig } from './readFrontendConfig';
import { ConfigReader } from '@backstage/config';
describe('readFrontendConfig', () => {
const mockDir = createMockDirectory();
afterEach(() => {
mockDir.clear();
});
it('should validate env config', async () => {
mockDir.setContent({
'appDir/.config-schema.json': JSON.stringify({
schemas: [
{
value: {
type: 'object',
properties: {
app: {
type: 'object',
properties: {
secretOfLife: {
type: 'string',
visibility: 'secret',
},
backendConfig: {
type: 'string',
visibility: 'backend',
},
publicValue: {
type: 'string',
visibility: 'frontend',
},
},
},
},
},
},
],
backstageConfigSchemaVersion: 1,
}),
});
const config = new ConfigReader({
app: {
secretOfLife: '42',
backendConfig: 'backend',
publicValue: 'public',
},
});
const frontendConfig = await readFrontendConfig({
env: {
APP_CONFIG_app_secretOfLife: 'ignored',
APP_CONFIG_app_backendConfig: 'ignored',
APP_CONFIG_app_publicValue: 'injected',
},
appDistDir: `${mockDir.path}/appDir`,
config,
});
expect(frontendConfig).toEqual([
{
context: 'env',
data: {
app: {
publicValue: 'injected',
},
},
deprecatedKeys: [],
filteredKeys: undefined,
},
{
context: 'app',
data: { app: { publicValue: 'public' } },
deprecatedKeys: [],
filteredKeys: undefined,
},
]);
});
});
@@ -36,10 +36,9 @@ export async function readFrontendConfig(options: {
}): Promise<AppConfig[]> {
const { env, appDistDir, config } = options;
const appConfigs = readEnvConfig(env);
const schemaPath = resolvePath(appDistDir, '.config-schema.json');
if (await fs.pathExists(schemaPath)) {
const envConfigs = readEnvConfig(env);
const serializedSchema = await fs.readJson(schemaPath);
try {
@@ -49,11 +48,10 @@ export async function readFrontendConfig(options: {
serialized: serializedSchema,
}));
const frontendConfigs = await schema.process(
[{ data: config.get() as JsonObject, context: 'app' }],
return await schema.process(
[...envConfigs, { data: config.get() as JsonObject, context: 'app' }],
{ visibility: ['frontend'], withDeprecatedKeys: true },
);
appConfigs.push(...frontendConfigs);
} catch (error) {
throw new Error(
'Invalid app bundle schema. If this error is unexpected you need to run `yarn build` in the app. ' +
@@ -63,5 +61,5 @@ export async function readFrontendConfig(options: {
}
}
return appConfigs;
return [];
}
@@ -29,6 +29,7 @@ export interface Config {
domainHint?: string;
callbackUrl?: string;
additionalScopes?: string | string[];
skipUserProfile?: boolean;
signIn?: {
resolvers: Array<
| { resolver: 'emailMatchingUserEntityAnnotation' }
@@ -262,7 +262,7 @@ describe('microsoftAuthenticator', () => {
expect(profile.photos).toStrictEqual([{ value: photo }]);
});
it('returns access token for non-microsoft graph scope', async () => {
it('returns access token for non-microsoft graph scope', async () => {
const foreignScope = 'aks-audience/user.read';
const refreshResponse = await microsoftAuthenticator.refresh(
createRefreshRequest(foreignScope),
@@ -274,5 +274,29 @@ describe('microsoftAuthenticator', () => {
microsoftApi.generateAccessToken(foreignScope),
);
});
it('returns access token when skipping user profile load', async () => {
// Replace implementation to set skipUserProfile config
implementation = microsoftAuthenticator.initialize({
callbackUrl: 'https://backstage.test/callback',
config: new ConfigReader({
tenantId: 'tenantId',
clientId: 'clientId',
clientSecret: 'clientSecret',
additionalScopes: ['User.Read.All'],
skipUserProfile: true,
}),
});
const refreshResponse = await microsoftAuthenticator.refresh(
createRefreshRequest(scope),
implementation,
);
expect(refreshResponse.fullProfile).toBeUndefined();
expect(refreshResponse.session.accessToken).toBe(
microsoftApi.generateAccessToken(scope),
);
});
});
});
@@ -45,31 +45,30 @@ export const microsoftAuthenticator = createOAuthAuthenticator({
const clientSecret = config.getString('clientSecret');
const tenantId = config.getString('tenantId');
const domainHint = config.getOptionalString('domainHint');
const skipUserProfile =
config.getOptionalBoolean('skipUserProfile') ?? false;
const helper = PassportOAuthAuthenticatorHelper.from(
new ExtendedMicrosoftStrategy(
{
clientID: clientId,
clientSecret: clientSecret,
callbackURL: callbackUrl,
tenant: tenantId,
},
(
accessToken: string,
refreshToken: string,
params: any,
fullProfile: PassportProfile,
done: PassportOAuthDoneCallback,
) => {
done(
undefined,
{ fullProfile, params, accessToken },
{ refreshToken },
);
},
),
const strategy = new ExtendedMicrosoftStrategy(
{
clientID: clientId,
clientSecret: clientSecret,
callbackURL: callbackUrl,
tenant: tenantId,
},
(
accessToken: string,
refreshToken: string,
params: any,
fullProfile: PassportProfile,
done: PassportOAuthDoneCallback,
) => {
done(undefined, { fullProfile, params, accessToken }, { refreshToken });
},
);
strategy.setSkipUserProfile(skipUserProfile);
const helper = PassportOAuthAuthenticatorHelper.from(strategy);
return {
helper,
domainHint,
@@ -20,6 +20,12 @@ import fetch from 'node-fetch';
import { Strategy as MicrosoftStrategy } from 'passport-microsoft';
export class ExtendedMicrosoftStrategy extends MicrosoftStrategy {
private shouldSkipUserProfile = false;
public setSkipUserProfile(shouldSkipUserProfile: boolean): void {
this.shouldSkipUserProfile = shouldSkipUserProfile;
}
userProfile(
accessToken: string,
done: (err?: unknown, profile?: PassportProfile) => void,
@@ -66,7 +72,7 @@ export class ExtendedMicrosoftStrategy extends MicrosoftStrategy {
private skipUserProfile(accessToken: string): boolean {
try {
return !this.hasGraphReadScope(accessToken);
return this.shouldSkipUserProfile || !this.hasGraphReadScope(accessToken);
} catch {
// If there is any error with checking the scope
// we fall back to not skipping the user profile
@@ -51,7 +51,6 @@
"test": "backstage-cli package test"
},
"dependencies": {
"@backstage/backend-common": "^0.25.0",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/catalog-client": "workspace:^",
"@backstage/catalog-model": "workspace:^",
@@ -3,6 +3,7 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AuthService } from '@backstage/backend-plugin-api';
import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { EntityProvider } from '@backstage/plugin-catalog-node';
@@ -12,7 +13,6 @@ import { EventsService } from '@backstage/plugin-events-node';
import { LoggerService } from '@backstage/backend-plugin-api';
import { SchedulerService } from '@backstage/backend-plugin-api';
import { SchedulerServiceTaskRunner } from '@backstage/backend-plugin-api';
import { TokenManager } from '@backstage/backend-common';
// @public
export class BitbucketCloudEntityProvider implements EntityProvider {
@@ -21,12 +21,12 @@ export class BitbucketCloudEntityProvider implements EntityProvider {
static fromConfig(
config: Config,
options: {
catalogApi?: CatalogApi;
events?: EventsService;
auth: AuthService;
catalogApi: CatalogApi;
events: EventsService;
logger: LoggerService;
schedule?: SchedulerServiceTaskRunner;
scheduler?: SchedulerService;
tokenManager?: TokenManager;
},
): BitbucketCloudEntityProvider[];
getProviderName(): string;
@@ -39,7 +39,7 @@ export class BitbucketCloudEntityProvider implements EntityProvider {
// Warnings were encountered during analysis:
//
// src/providers/BitbucketCloudEntityProvider.d.ts:28:5 - (ae-undocumented) Missing documentation for "fromConfig".
// src/providers/BitbucketCloudEntityProvider.d.ts:44:5 - (ae-undocumented) Missing documentation for "refresh".
// src/providers/BitbucketCloudEntityProvider.d.ts:47:5 - (ae-undocumented) Missing documentation for "onRepoPush".
// src/providers/BitbucketCloudEntityProvider.d.ts:26:5 - (ae-undocumented) Missing documentation for "fromConfig".
// src/providers/BitbucketCloudEntityProvider.d.ts:42:5 - (ae-undocumented) Missing documentation for "refresh".
// src/providers/BitbucketCloudEntityProvider.d.ts:44:5 - (ae-undocumented) Missing documentation for "onRepoPush".
```
@@ -34,6 +34,7 @@ export const catalogModuleBitbucketCloudEntityProvider = createBackendModule({
register(env) {
env.registerInit({
deps: {
auth: coreServices.auth,
catalog: catalogProcessingExtensionPoint,
catalogApi: catalogServiceRef,
config: coreServices.rootConfig,
@@ -41,8 +42,17 @@ export const catalogModuleBitbucketCloudEntityProvider = createBackendModule({
logger: coreServices.logger,
scheduler: coreServices.scheduler,
},
async init({ catalog, catalogApi, config, events, logger, scheduler }) {
async init({
auth,
catalog,
catalogApi,
config,
events,
logger,
scheduler,
}) {
const providers = BitbucketCloudEntityProvider.fromConfig(config, {
auth,
catalogApi,
events,
logger,
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { TokenManager } from '@backstage/backend-common';
import {
SchedulerServiceTaskInvocationDefinition,
SchedulerServiceTaskRunner,
@@ -92,11 +91,6 @@ describe('BitbucketCloudEntityProvider', () => {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const tokenManager = {
getToken: async () => {
return { token: 'fake-token' };
},
} as any as TokenManager;
const repoPushEvent: Events.RepoPushEvent = {
actor: {
type: 'user',
@@ -158,8 +152,14 @@ describe('BitbucketCloudEntityProvider', () => {
});
it('no provider config', () => {
const auth = mockServices.auth.mock();
const catalogApi = catalogServiceMock.mock();
const config = new ConfigReader({});
const events = DefaultEventsService.create({ logger });
const providers = BitbucketCloudEntityProvider.fromConfig(config, {
auth,
catalogApi,
events,
logger,
schedule,
});
@@ -168,7 +168,13 @@ describe('BitbucketCloudEntityProvider', () => {
});
it('single simple provider config', () => {
const auth = mockServices.auth.mock();
const catalogApi = catalogServiceMock.mock();
const events = DefaultEventsService.create({ logger });
const providers = BitbucketCloudEntityProvider.fromConfig(simpleConfig, {
auth,
catalogApi,
events,
logger,
schedule,
});
@@ -180,14 +186,24 @@ describe('BitbucketCloudEntityProvider', () => {
});
it('fail without schedule and scheduler', () => {
const auth = mockServices.auth.mock();
const catalogApi = catalogServiceMock.mock();
const events = DefaultEventsService.create({ logger });
expect(() =>
BitbucketCloudEntityProvider.fromConfig(simpleConfig, {
auth,
catalogApi,
events,
logger,
}),
).toThrow('Either schedule or scheduler must be provided.');
});
it('fail with scheduler but no schedule config', () => {
const auth = mockServices.auth.mock();
const catalogApi = catalogServiceMock.mock();
const events = DefaultEventsService.create({ logger });
const scheduler = mockServices.scheduler.mock();
const config = new ConfigReader({
catalog: {
@@ -201,6 +217,9 @@ describe('BitbucketCloudEntityProvider', () => {
expect(() =>
BitbucketCloudEntityProvider.fromConfig(config, {
auth,
catalogApi,
events,
logger,
scheduler,
}),
@@ -210,6 +229,9 @@ describe('BitbucketCloudEntityProvider', () => {
});
it('single simple provider config with schedule in config', () => {
const auth = mockServices.auth.mock();
const catalogApi = catalogServiceMock.mock();
const events = DefaultEventsService.create({ logger });
const scheduler = mockServices.scheduler.mock();
const config = new ConfigReader({
catalog: {
@@ -226,6 +248,9 @@ describe('BitbucketCloudEntityProvider', () => {
});
const providers = BitbucketCloudEntityProvider.fromConfig(config, {
auth,
catalogApi,
events,
logger,
scheduler,
});
@@ -237,6 +262,8 @@ describe('BitbucketCloudEntityProvider', () => {
});
it('multiple provider configs', () => {
const auth = mockServices.auth.mock();
const catalogApi = catalogServiceMock.mock();
const config = new ConfigReader({
catalog: {
providers: {
@@ -251,7 +278,11 @@ describe('BitbucketCloudEntityProvider', () => {
},
},
});
const events = DefaultEventsService.create({ logger });
const providers = BitbucketCloudEntityProvider.fromConfig(config, {
auth,
catalogApi,
events,
logger,
schedule,
});
@@ -266,7 +297,13 @@ describe('BitbucketCloudEntityProvider', () => {
});
it('apply full update on scheduled execution', async () => {
const auth = mockServices.auth.mock();
const catalogApi = catalogServiceMock.mock();
const events = DefaultEventsService.create({ logger });
const provider = BitbucketCloudEntityProvider.fromConfig(defaultConfig, {
auth,
catalogApi,
events,
logger,
schedule,
})[0];
@@ -436,6 +473,9 @@ describe('BitbucketCloudEntityProvider', () => {
'added-module/catalog-custom.yaml',
);
const auth = mockServices.auth.mock({
getPluginRequestToken: async () => ({ token: 'fake-token' }),
});
const events = DefaultEventsService.create({ logger });
const catalogApi = catalogServiceMock.mock({
getEntities: async (
@@ -457,11 +497,11 @@ describe('BitbucketCloudEntityProvider', () => {
},
});
const provider = BitbucketCloudEntityProvider.fromConfig(defaultConfig, {
auth,
catalogApi,
events,
logger,
schedule,
tokenManager,
})[0];
server.use(
@@ -569,14 +609,15 @@ describe('BitbucketCloudEntityProvider', () => {
});
it('no onRepoPush update on non-matching workspace slug', async () => {
const auth = mockServices.auth.mock();
const catalogApi = catalogServiceMock.mock();
const events = DefaultEventsService.create({ logger });
const provider = BitbucketCloudEntityProvider.fromConfig(defaultConfig, {
auth,
catalogApi,
events,
logger,
schedule,
tokenManager,
})[0];
await provider.connect(entityProviderConnection);
@@ -599,14 +640,15 @@ describe('BitbucketCloudEntityProvider', () => {
});
it('no onRepoPush update on non-matching repo slug', async () => {
const auth = mockServices.auth.mock();
const catalogApi = catalogServiceMock.mock();
const events = DefaultEventsService.create({ logger });
const provider = BitbucketCloudEntityProvider.fromConfig(defaultConfig, {
auth,
catalogApi,
events,
logger,
schedule,
tokenManager,
})[0];
await provider.connect(entityProviderConnection);
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import { TokenManager } from '@backstage/backend-common';
import {
AuthService,
LoggerService,
SchedulerService,
SchedulerServiceTaskRunner,
@@ -66,26 +66,25 @@ interface IngestionTarget {
* @public
*/
export class BitbucketCloudEntityProvider implements EntityProvider {
private readonly auth: AuthService;
private readonly catalogApi: CatalogApi;
private readonly client: BitbucketCloudClient;
private readonly config: BitbucketCloudEntityProviderConfig;
private readonly events: EventsService;
private readonly logger: LoggerService;
private readonly scheduleFn: () => Promise<void>;
private readonly catalogApi?: CatalogApi;
private readonly events?: EventsService;
private readonly tokenManager?: TokenManager;
private connection?: EntityProviderConnection;
private eventConfigErrorThrown = false;
private connection?: EntityProviderConnection;
static fromConfig(
config: Config,
options: {
catalogApi?: CatalogApi;
events?: EventsService;
auth: AuthService;
catalogApi: CatalogApi;
events: EventsService;
logger: LoggerService;
schedule?: SchedulerServiceTaskRunner;
scheduler?: SchedulerService;
tokenManager?: TokenManager;
},
): BitbucketCloudEntityProvider[] {
const integrations = ScmIntegrations.fromConfig(config);
@@ -112,35 +111,35 @@ export class BitbucketCloudEntityProvider implements EntityProvider {
options.scheduler!.createScheduledTaskRunner(providerConfig.schedule!);
return new BitbucketCloudEntityProvider(
options.auth,
options.catalogApi,
providerConfig,
options.events,
integration,
options.logger,
taskRunner,
options.catalogApi,
options.events,
options.tokenManager,
);
});
}
private constructor(
auth: AuthService,
catalogApi: CatalogApi,
config: BitbucketCloudEntityProviderConfig,
events: EventsService,
integration: BitbucketCloudIntegration,
logger: LoggerService,
taskRunner: SchedulerServiceTaskRunner,
catalogApi?: CatalogApi,
events?: EventsService,
tokenManager?: TokenManager,
) {
this.auth = auth;
this.catalogApi = catalogApi;
this.client = BitbucketCloudClient.fromConfig(integration.config);
this.config = config;
this.events = events;
this.logger = logger.child({
target: this.getProviderName(),
});
this.scheduleFn = this.createScheduleFn(taskRunner);
this.catalogApi = catalogApi;
this.events = events;
this.tokenManager = tokenManager;
}
private createScheduleFn(
@@ -185,19 +184,17 @@ export class BitbucketCloudEntityProvider implements EntityProvider {
this.connection = connection;
await this.scheduleFn();
if (this.events) {
await this.events.subscribe({
id: this.getProviderName(),
topics: [TOPIC_REPO_PUSH],
onEvent: async params => {
if (params.topic !== TOPIC_REPO_PUSH) {
return;
}
await this.events.subscribe({
id: this.getProviderName(),
topics: [TOPIC_REPO_PUSH],
onEvent: async params => {
if (params.topic !== TOPIC_REPO_PUSH) {
return;
}
await this.onRepoPush(params.eventPayload as Events.RepoPushEvent);
},
});
}
await this.onRepoPush(params.eventPayload as Events.RepoPushEvent);
},
});
}
async refresh(logger: LoggerService) {
@@ -220,32 +217,12 @@ export class BitbucketCloudEntityProvider implements EntityProvider {
);
}
private canHandleEvents(): boolean {
if (this.catalogApi && this.tokenManager) {
return true;
}
// throw only once
if (!this.eventConfigErrorThrown) {
this.eventConfigErrorThrown = true;
throw new Error(
`${this.getProviderName()} not well configured to handle repo:push. Missing CatalogApi and/or TokenManager.`,
);
}
return false;
}
private enhanceEvent(event: Events.RepoPushEvent): void {
// add missing slug
event.repository.slug = event.repository.full_name!.split('/', 2)[1];
}
async onRepoPush(event: Events.RepoPushEvent): Promise<void> {
if (!this.canHandleEvents()) {
return;
}
if (!this.connection) {
throw new Error('Not initialized');
}
@@ -273,8 +250,7 @@ export class BitbucketCloudEntityProvider implements EntityProvider {
const targets = await this.findCatalogFiles(repoSlug);
const { token } = await this.tokenManager!.getToken();
const existing = await this.findExistingLocations(repoUrl, token);
const existing = await this.findExistingLocations(repoUrl);
const added: DeferredEntity[] = this.toDeferredEntities(
targets.filter(
@@ -321,16 +297,20 @@ export class BitbucketCloudEntityProvider implements EntityProvider {
private async findExistingLocations(
repoUrl: string,
token: string,
): Promise<LocationEntity[]> {
const filter: Record<string, string> = {};
filter.kind = 'Location';
filter[`metadata.annotations.${ANNOTATION_BITBUCKET_CLOUD_REPO_URL}`] =
repoUrl;
return this.catalogApi!.getEntities({ filter }, { token }).then(
result => result.items,
) as Promise<LocationEntity[]>;
const { token } = await this.auth.getPluginRequestToken({
onBehalfOf: await this.auth.getOwnServiceCredentials(),
targetPluginId: 'catalog',
});
return this.catalogApi
.getEntities({ filter }, { token })
.then(result => result.items) as Promise<LocationEntity[]>;
}
private async findCatalogFiles(
+2
View File
@@ -16,6 +16,7 @@ import { JSX as JSX_2 } from 'react';
import { MouseEvent as MouseEvent_2 } from 'react';
import { MouseEventHandler } from 'react';
import { default as React_2 } from 'react';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
// @public
@@ -80,6 +81,7 @@ export const EntityCatalogGraphCard: (
variant?: InfoCardVariants | undefined;
height?: number | undefined;
title?: string | undefined;
action?: ReactNode;
},
) => JSX_2.Element;
@@ -33,6 +33,7 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import { catalogGraphRouteRef } from '../../routes';
import { CatalogGraphCard } from './CatalogGraphCard';
import Button from '@material-ui/core/Button';
describe('<CatalogGraphCard/>', () => {
let entity: Entity;
@@ -127,6 +128,33 @@ describe('<CatalogGraphCard/>', () => {
expect(await screen.findByText('Custom Title')).toBeInTheDocument();
});
test('renders with action attribute', async () => {
catalog.getEntitiesByRefs.mockImplementation(async _ => ({
items: [
{
...entity,
relations: [],
},
],
}));
await renderInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
<CatalogGraphCard action={<Button title="Action Button" />} />
</EntityProvider>
</ApiProvider>,
{
mountedRoutes: {
'/entity/{kind}/{namespace}/{name}': entityRouteRef,
'/catalog-graph': catalogGraphRouteRef,
},
},
);
expect(await screen.findByTitle('Action Button')).toBeInTheDocument();
});
test('renders link to standalone viewer', async () => {
catalog.getEntitiesByRefs.mockImplementation(async _ => ({
items: [
@@ -28,7 +28,7 @@ import {
} from '@backstage/plugin-catalog-react';
import { makeStyles, Theme } from '@material-ui/core/styles';
import qs from 'qs';
import React, { MouseEvent, useCallback } from 'react';
import React, { MouseEvent, ReactNode, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { catalogGraphRouteRef } from '../../routes';
import {
@@ -63,6 +63,7 @@ export const CatalogGraphCard = (
variant?: InfoCardVariants;
height?: number;
title?: string;
action?: ReactNode;
},
) => {
const {
@@ -77,6 +78,7 @@ export const CatalogGraphCard = (
entityFilter,
height,
className,
action,
rootEntityNames,
onNodeClick,
title = 'Relations',
@@ -126,6 +128,7 @@ export const CatalogGraphCard = (
return (
<InfoCard
title={title}
action={action}
cardClassName={classes.card}
variant={variant}
noPadding
@@ -93,6 +93,20 @@ describe('parseFilterExpression', () => {
);
});
it('recognizes negation key', () => {
const component = { kind: 'Component' } as unknown as Entity;
expect(run('not:kind:user')(component)).toBe(true);
});
it('supports negation and affirmative expressions', () => {
const component = {
kind: 'Component',
spec: { type: 'service' },
} as unknown as Entity;
expect(run('not:kind:user type:service')(component)).toBe(true);
expect(run('type:service not:kind:user')(component)).toBe(true);
});
it('rejects unknown keys', () => {
expect(() => run('unknown:foo')).toThrowErrorMatchingInlineSnapshot(
`"'unknown' is not a valid filter expression key, expected one of 'kind','type','is','has'"`,
@@ -123,17 +137,25 @@ describe('splitFilterExpression', () => {
expect(run('')).toEqual([]);
expect(run(' ')).toEqual([]);
expect(run('kind:component')).toEqual([
{ key: 'kind', parameters: ['component'] },
{ key: 'kind', parameters: ['component'], negation: false },
]);
expect(run('kind:component,user')).toEqual([
{ key: 'kind', parameters: ['component', 'user'] },
{ key: 'kind', parameters: ['component', 'user'], negation: false },
]);
expect(run('kind:component,user not:type:foo')).toEqual([
{ key: 'kind', parameters: ['component', 'user'], negation: false },
{ key: 'type', parameters: ['foo'], negation: true },
]);
expect(run('not:type:foo kind:component,user')).toEqual([
{ key: 'type', parameters: ['foo'], negation: true },
{ key: 'kind', parameters: ['component', 'user'], negation: false },
]);
expect(run('kind:component,user type:foo')).toEqual([
{ key: 'kind', parameters: ['component', 'user'] },
{ key: 'type', parameters: ['foo'] },
{ key: 'kind', parameters: ['component', 'user'], negation: false },
{ key: 'type', parameters: ['foo'], negation: false },
]);
expect(run('with:multiple:colons')).toEqual([
{ key: 'with', parameters: ['multiple:colons'] },
{ key: 'with', parameters: ['multiple:colons'], negation: false },
]);
});
@@ -27,6 +27,7 @@ const rootMatcherFactories: Record<
(
parameters: string[],
onParseError: (error: Error) => void,
negation?: boolean,
) => EntityMatcherFn
> = {
kind: createKindMatcher,
@@ -60,9 +61,9 @@ export function parseFilterExpression(expression: string): {
const parts = splitFilterExpression(expression, e =>
expressionParseErrors.push(e),
);
const matchers = parts.flatMap(part => {
const factory = rootMatcherFactories[part.key];
const negation = part.negation;
if (!factory) {
const known = Object.keys(rootMatcherFactories).map(m => `'${m}'`);
expressionParseErrors.push(
@@ -76,7 +77,8 @@ export function parseFilterExpression(expression: string): {
const matcher = factory(part.parameters, e =>
expressionParseErrors.push(e),
);
return [matcher];
return [negation ? (entity: Entity) => !matcher(entity) : matcher];
});
const filterFn = (entity: Entity) =>
@@ -97,16 +99,20 @@ export function parseFilterExpression(expression: string): {
export function splitFilterExpression(
expression: string,
onParseError: (error: Error) => void,
): Array<{ key: string; parameters: string[] }> {
): Array<{ key: string; parameters: string[]; negation: boolean }> {
const words = expression
.split(' ')
.map(w => w.trim())
.filter(Boolean);
const result = new Array<{ key: string; parameters: string[] }>();
const result = new Array<{
key: string;
parameters: string[];
negation: boolean;
}>();
for (const word of words) {
const match = word.match(/^([^:]+):(.+)$/);
const match = word.match(/^(not:)?([^:]+):(.+)$/);
if (!match) {
onParseError(
new InputError(
@@ -115,11 +121,10 @@ export function splitFilterExpression(
);
continue;
}
const key = match[1];
const parameters = match[2].split(',').filter(Boolean); // silently ignore double commas
result.push({ key, parameters });
const key = match[2];
const parameters = match[3].split(',').filter(Boolean); // silently ignore double commas
const negation = Boolean(match[1]);
result.push({ key, parameters, negation });
}
return result;
@@ -44,6 +44,7 @@ export function createPublishBitbucketServerPullRequestAction(options: {
description?: string | undefined;
targetBranch?: string | undefined;
sourceBranch: string;
reviewers?: string[] | undefined;
token?: string | undefined;
gitAuthorName?: string | undefined;
gitAuthorEmail?: string | undefined;
@@ -366,7 +366,7 @@ describe('publish:bitbucketServer:pull-request', () => {
});
it(`should ${examples[4].description}`, async () => {
expect.assertions(7);
expect.assertions(8);
server.use(
rest.get(
'https://no-credentials.bitbucket.com/rest/api/1.0/projects/project/repos/repo/branches',
@@ -389,6 +389,7 @@ describe('publish:bitbucketServer:pull-request', () => {
toRef: { displayId: string };
fromRef: { displayId: string };
description: string;
reviewers: [{ user: { name: string } }];
};
expect(requestBody.title).toBe('My pull request');
expect(requestBody.fromRef.displayId).toBe('my-feature-branch');
@@ -396,6 +397,10 @@ describe('publish:bitbucketServer:pull-request', () => {
expect(requestBody.description).toBe(
'This is a detailed description of my pull request',
);
expect(requestBody.reviewers).toEqual([
{ user: { name: 'reviewer1' } },
{ user: { name: 'reviewer2' } },
]);
expect(req.headers.get('Authorization')).toBe(
`Bearer ${yaml.parse(examples[4].example).steps[0].input.token}`,
);
@@ -107,6 +107,7 @@ export const examples: TemplateExample[] = [
sourceBranch: 'my-feature-branch',
targetBranch: 'development',
description: 'This is a detailed description of my pull request',
reviewers: ['reviewer1', 'reviewer2'],
token: 'my-auth-token',
gitAuthorName: 'test-user',
gitAuthorEmail: 'test-user@sample.com',
@@ -54,6 +54,7 @@ const createPullRequest = async (opts: {
latestChangeset: string;
isDefault: boolean;
};
reviewers?: string[];
authorization: string;
apiBaseUrl: string;
}) => {
@@ -64,6 +65,7 @@ const createPullRequest = async (opts: {
description,
toRef,
fromRef,
reviewers,
authorization,
apiBaseUrl,
} = opts;
@@ -80,6 +82,7 @@ const createPullRequest = async (opts: {
locked: true,
toRef: toRef,
fromRef: fromRef,
reviewers: reviewers?.map(reviewer => ({ user: { name: reviewer } })),
}),
headers: {
Authorization: authorization,
@@ -257,6 +260,7 @@ export function createPublishBitbucketServerPullRequestAction(options: {
description?: string;
targetBranch?: string;
sourceBranch: string;
reviewers?: string[];
token?: string;
gitAuthorName?: string;
gitAuthorEmail?: string;
@@ -292,6 +296,15 @@ export function createPublishBitbucketServerPullRequestAction(options: {
type: 'string',
description: 'Branch of repository to copy changes from',
},
reviewers: {
title: 'Pull Request Reviewers',
type: 'array',
items: {
type: 'string',
},
description:
'The usernames of reviewers that will be added to the pull request',
},
token: {
title: 'Authorization Token',
type: 'string',
@@ -327,6 +340,7 @@ export function createPublishBitbucketServerPullRequestAction(options: {
description,
targetBranch,
sourceBranch,
reviewers,
gitAuthorName,
gitAuthorEmail,
} = ctx.input;
@@ -480,6 +494,7 @@ export function createPublishBitbucketServerPullRequestAction(options: {
description,
toRef,
fromRef,
reviewers,
authorization,
apiBaseUrl,
});
+26 -5
View File
@@ -173,6 +173,7 @@ export type ScaffolderCustomFieldExplorerClassKey =
// @public (undocumented)
export type ScaffolderTemplateEditorClassKey =
| 'root'
| 'toolbar'
| 'browser'
| 'editor'
| 'preview'
@@ -181,6 +182,7 @@ export type ScaffolderTemplateEditorClassKey =
// @public (undocumented)
export type ScaffolderTemplateFormPreviewerClassKey =
| 'root'
| 'toolbar'
| 'controls'
| 'textArea'
| 'preview';
@@ -235,6 +237,7 @@ export const scaffolderTranslationRef: TranslationRef<
readonly 'actionsPage.content.tableCell.name': 'Name';
readonly 'actionsPage.content.tableCell.title': 'Title';
readonly 'actionsPage.content.tableCell.description': 'Description';
readonly 'actionsPage.content.searchFieldPlaceholder': 'Search for an action';
readonly 'actionsPage.content.noRowsDescription': 'No schema defined';
readonly 'actionsPage.title': 'Installed actions';
readonly 'actionsPage.action.input': 'Input';
@@ -274,9 +277,13 @@ export const scaffolderTranslationRef: TranslationRef<
readonly 'ongoingTask.showLogsButtonTitle': 'Show Logs';
readonly 'templateEditorForm.stepper.emptyText': 'There are no spec parameters in the template to preview.';
readonly 'templateTypePicker.title': 'Categories';
readonly 'templateFormPage.title': 'Template Form Playground';
readonly 'templateFormPage.subtitle': 'Edit, preview, and try out templates and template forms';
readonly 'templateEditorPage.title': 'Manage Templates';
readonly 'templateIntroPage.title': 'Manage Templates';
readonly 'templateIntroPage.subtitle': 'Edit, preview, and try out templates, forms, and custom fields';
readonly 'templateFormPage.title': 'Template Editor';
readonly 'templateFormPage.subtitle': 'Edit, preview, and try out templates forms';
readonly 'templateCustomFieldPage.title': 'Custom Field Explorer';
readonly 'templateCustomFieldPage.subtitle': 'Edit, preview, and try out custom fields';
readonly 'templateEditorPage.title': 'Template Editor';
readonly 'templateEditorPage.subtitle': 'Edit, preview, and try out templates and template forms';
readonly 'templateEditorPage.dryRunResults.title': 'Dry-run results';
readonly 'templateEditorPage.dryRunResultsList.title': 'Result {{resultId}}';
@@ -302,12 +309,13 @@ export const scaffolderTranslationRef: TranslationRef<
readonly 'templateEditorPage.templateEditorIntro.createLocal.title': 'Create New Template';
readonly 'templateEditorPage.templateEditorIntro.createLocal.description': 'Create a local template directory, allowing you to both edit and try executing your own template.';
readonly 'templateEditorPage.templateEditorIntro.createLocal.unsupportedTooltip': 'Only supported in some Chromium-based browsers';
readonly 'templateEditorPage.templateEditorIntro.formEditor.title': 'Template playground';
readonly 'templateEditorPage.templateEditorIntro.formEditor.title': 'Template Form Playground';
readonly 'templateEditorPage.templateEditorIntro.formEditor.description': 'Preview and edit a template form, either using a sample template or by loading a template from the catalog.';
readonly 'templateEditorPage.templateEditorIntro.fieldExplorer.title': 'Custom Field Explorer';
readonly 'templateEditorPage.templateEditorIntro.fieldExplorer.description': 'View and play around with available installed custom field extensions.';
readonly 'templateEditorPage.templateEditorTextArea.saveIconTooltip': 'Save file';
readonly 'templateEditorPage.templateEditorTextArea.refreshIconTooltip': 'Reload file';
readonly 'templateEditorPage.templateEditorTextArea.emptyStateParagraph': 'Please select an action on the file menu.';
readonly 'templateEditorPage.templateFormPreviewer.title': 'Load Existing Template';
readonly 'templateListPage.title': 'Create a new component';
readonly 'templateListPage.subtitle': 'Create new software components using standard templates in your organization';
@@ -321,6 +329,19 @@ export const scaffolderTranslationRef: TranslationRef<
readonly 'templateWizardPage.subtitle': 'Create new software components using standard templates in your organization';
readonly 'templateWizardPage.pageTitle': 'Create a new component';
readonly 'templateWizardPage.pageContextMenu.editConfigurationTitle': 'Edit Configuration';
readonly 'templateEditorToolbar.customFieldExplorerTooltip': 'Custom Fields Explorer';
readonly 'templateEditorToolbar.installedActionsDocumentationTooltip': 'Installed Actions Documentation';
readonly 'templateEditorToolbar.addToCatalogButton': 'Publish';
readonly 'templateEditorToolbar.addToCatalogDialogTitle': 'Publish changes';
readonly 'templateEditorToolbar.addToCatalogDialogContent.stepsIntroduction': 'Follow the instructions below to create or update a template:';
readonly 'templateEditorToolbar.addToCatalogDialogContent.stepsListItems': 'Save the template files in a local directory\nCreate a pull request to a new or existing git repository\nIf the template already exists, the changes will be reflected in the software catalog once the pull request gets merged\nBut if you are creating a new template, follow the documentation linked below to register the new template repository in software catalog';
readonly 'templateEditorToolbar.addToCatalogDialogActions.documentationUrl': 'https://backstage.io/docs/features/software-templates/adding-templates/';
readonly 'templateEditorToolbar.addToCatalogDialogActions.documentationButton': 'Go to the documentation';
readonly 'templateEditorToolbarFileMenu.button': 'File';
readonly 'templateEditorToolbarFileMenu.options.openDirectory': 'Open template directory';
readonly 'templateEditorToolbarFileMenu.options.createDirectory': 'Create template directory';
readonly 'templateEditorToolbarFileMenu.options.closeEditor': 'Close template editor';
readonly 'templateEditorToolbarTemplatesMenu.button': 'Templates';
}
>;
@@ -361,7 +382,7 @@ export type TemplateWizardPageProps = {
// Warnings were encountered during analysis:
//
// src/alpha/components/TemplateEditorPage/CustomFieldExplorer.d.ts:4:1 - (ae-undocumented) Missing documentation for "ScaffolderCustomFieldExplorerClassKey".
// src/alpha/components/TemplateEditorPage/TemplateEditor.d.ts:6:1 - (ae-undocumented) Missing documentation for "ScaffolderTemplateEditorClassKey".
// src/alpha/components/TemplateEditorPage/TemplateEditor.d.ts:5:1 - (ae-undocumented) Missing documentation for "ScaffolderTemplateEditorClassKey".
// src/alpha/components/TemplateEditorPage/TemplateFormPreviewer.d.ts:4:1 - (ae-undocumented) Missing documentation for "ScaffolderTemplateFormPreviewerClassKey".
// src/alpha/components/TemplateListPage/TemplateListPage.d.ts:7:1 - (ae-undocumented) Missing documentation for "TemplateListPageProps".
// src/alpha/components/TemplateWizardPage/TemplateWizardPage.d.ts:6:1 - (ae-undocumented) Missing documentation for "TemplateWizardPageProps".
@@ -19,13 +19,7 @@ import Button from '@material-ui/core/Button';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import CardHeader from '@material-ui/core/CardHeader';
import FormControl from '@material-ui/core/FormControl';
import IconButton from '@material-ui/core/IconButton';
import InputLabel from '@material-ui/core/InputLabel';
import MenuItem from '@material-ui/core/MenuItem';
import Select from '@material-ui/core/Select';
import { makeStyles } from '@material-ui/core/styles';
import CloseIcon from '@material-ui/icons/Close';
import CodeMirror from '@uiw/react-codemirror';
import React, { useCallback, useMemo, useState } from 'react';
import yaml from 'yaml';
@@ -35,6 +29,10 @@ import validator from '@rjsf/validator-ajv8';
import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import InputAdornment from '@material-ui/core/InputAdornment';
import TextField from '@material-ui/core/TextField';
import SearchIcon from '@material-ui/icons/Search';
import Autocomplete from '@material-ui/lab/Autocomplete';
/** @public */
export type ScaffolderCustomFieldExplorerClassKey =
@@ -48,25 +46,35 @@ const useStyles = makeStyles(
root: {
gridArea: 'pageContent',
display: 'grid',
gridGap: theme.spacing(2),
gridTemplateAreas: `
"controls"
"fieldForm"
"preview"
`,
[theme.breakpoints.up('md')]: {
gridTemplateAreas: `
"controls controls"
"fieldForm preview"
`,
gridTemplateRows: 'auto 1fr',
gridTemplateColumns: '1fr 1fr',
gridTemplateRows: 'auto 1fr',
gridTemplateColumns: '1fr 1fr',
},
},
controls: {
gridArea: 'controls',
display: 'flex',
flexFlow: 'row nowrap',
alignItems: 'center',
margin: theme.spacing(1),
},
fieldForm: {
gridArea: 'fieldForm',
},
preview: {
gridArea: 'preview',
display: 'grid',
gridGap: theme.spacing(2),
alignContent: 'start',
},
}),
{ name: 'ScaffolderCustomFieldExplorer' },
@@ -74,10 +82,8 @@ const useStyles = makeStyles(
export const CustomFieldExplorer = ({
customFieldExtensions = [],
onClose,
}: {
customFieldExtensions?: FieldExtensionOptions<any, any>[];
onClose?: () => void;
}) => {
const classes = useStyles();
const { t } = useTranslationRef(scaffolderTranslationRef);
@@ -131,29 +137,39 @@ export const CustomFieldExplorer = ({
return (
<main className={classes.root}>
<div className={classes.controls}>
<FormControl variant="outlined" size="small" fullWidth>
<InputLabel id="select-field-label">
{t('templateEditorPage.customFieldExplorer.selectFieldLabel')}
</InputLabel>
<Select
value={selectedField}
label={t('templateEditorPage.customFieldExplorer.selectFieldLabel')}
labelId="select-field-label"
onChange={e =>
handleSelectionChange(e.target.value as FieldExtensionOptions)
<Autocomplete
id="custom-fields-autocomplete"
value={selectedField}
options={fieldOptions}
getOptionLabel={option => option.name}
renderInput={params => (
<TextField
{...params}
aria-label={t(
'templateEditorPage.customFieldExplorer.selectFieldLabel',
)}
placeholder={t(
'templateEditorPage.customFieldExplorer.selectFieldLabel',
)}
variant="outlined"
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
)}
onChange={(_event, option) => {
if (option) {
handleSelectionChange(option);
}
>
{fieldOptions.map((option, idx) => (
<MenuItem key={idx} value={option as any}>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
<IconButton size="medium" onClick={onClose} aria-label="Close">
<CloseIcon />
</IconButton>
}}
disableClearable
fullWidth
/>
</div>
<div className={classes.fieldForm}>
<Card>
@@ -22,16 +22,16 @@ import { StreamLanguage } from '@codemirror/language';
import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml';
import { makeStyles } from '@material-ui/core/styles';
import FormControl from '@material-ui/core/FormControl';
import InputLabel from '@material-ui/core/InputLabel';
import MenuItem from '@material-ui/core/MenuItem';
import Select from '@material-ui/core/Select';
import Accordion from '@material-ui/core/Accordion';
import AccordionSummary from '@material-ui/core/AccordionSummary';
import AccordionDetails from '@material-ui/core/AccordionDetails';
import Autocomplete from '@material-ui/lab/Autocomplete';
import TextField from '@material-ui/core/TextField';
import Button from '@material-ui/core/Button';
import InputAdornment from '@material-ui/core/InputAdornment';
import Typography from '@material-ui/core/Typography';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import SearchIcon from '@material-ui/icons/Search';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { Form } from '@backstage/plugin-scaffolder-react/alpha';
@@ -48,7 +48,7 @@ const useStyles = makeStyles(
gridTemplateRows: 'auto 1fr',
},
controls: {
marginBottom: theme.spacing(2),
marginBottom: theme.spacing(3),
},
code: {
width: '100%',
@@ -114,25 +114,39 @@ export const CustomFieldPlaygroud = ({
return (
<main className={classes.root}>
<div className={classes.controls}>
<FormControl variant="outlined" fullWidth>
<InputLabel id="select-field-label">
{t('templateEditorPage.customFieldExplorer.selectFieldLabel')}
</InputLabel>
<Select
value={selectedField}
label={t('templateEditorPage.customFieldExplorer.selectFieldLabel')}
labelId="select-field-label"
onChange={e =>
handleSelectionChange(e.target.value as FieldExtensionOptions)
<Autocomplete
id="custom-fields-autocomplete"
value={selectedField}
options={fieldOptions}
getOptionLabel={option => option.name}
renderInput={params => (
<TextField
{...params}
aria-label={t(
'templateEditorPage.customFieldExplorer.selectFieldLabel',
)}
placeholder={t(
'templateEditorPage.customFieldExplorer.selectFieldLabel',
)}
variant="outlined"
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
)}
onChange={(_event, option) => {
if (option) {
handleSelectionChange(option);
}
>
{fieldOptions.map((option, idx) => (
<MenuItem key={idx} value={option as any}>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
}}
disableClearable
fullWidth
/>
</div>
<div>
<Accordion defaultExpanded>
@@ -14,8 +14,7 @@
* limitations under the License.
*/
import React, { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import React from 'react';
import { Page, Header, Content } from '@backstage/core-components';
import { useRouteRef } from '@backstage/core-plugin-api';
@@ -32,25 +31,19 @@ interface CustomFieldsPageProps {
}
export function CustomFieldsPage(props: CustomFieldsPageProps) {
const navigate = useNavigate();
const editLink = useRouteRef(editRouteRef);
const { t } = useTranslationRef(scaffolderTranslationRef);
const handleClose = useCallback(() => {
navigate(editLink());
}, [navigate, editLink]);
return (
<Page themeId="home">
<Header
title={t('templateEditorPage.title')}
subtitle={t('templateEditorPage.subtitle')}
title={t('templateCustomFieldPage.title')}
subtitle={t('templateCustomFieldPage.subtitle')}
type={t('templateIntroPage.title')}
typeLink={editLink()}
/>
<Content>
<CustomFieldExplorer
customFieldExtensions={props.fieldExtensions}
onClose={handleClose}
/>
<CustomFieldExplorer customFieldExtensions={props.fieldExtensions} />
</Content>
</Page>
);
@@ -209,7 +209,11 @@ export function DirectoryEditorProvider(props: DirectoryEditorProviderProps) {
const { directory } = props;
const [{ result, error }, { execute }] = useAsync(
async (dir: TemplateDirectoryAccess) => {
async (dir?: TemplateDirectoryAccess) => {
if (!dir) {
return undefined;
}
const manager = new DirectoryEditorManager(dir);
await manager.reload();
@@ -223,9 +227,7 @@ export function DirectoryEditorProvider(props: DirectoryEditorProviderProps) {
);
useEffect(() => {
if (directory) {
execute(directory);
}
execute(directory);
}, [execute, directory]);
if (error) {
@@ -13,126 +13,84 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { makeStyles } from '@material-ui/core/styles';
import React, { useState } from 'react';
import Paper from '@material-ui/core/Paper';
import type {
FormProps,
LayoutOptions,
} from '@backstage/plugin-scaffolder-react';
import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
import { TemplateDirectoryAccess } from '../../../lib/filesystem';
import { DirectoryEditorProvider } from './DirectoryEditorContext';
import {
TemplateEditorLayout,
TemplateEditorLayoutToolbar,
TemplateEditorLayoutBrowser,
TemplateEditorLayoutFiles,
TemplateEditorLayoutPreview,
TemplateEditorLayoutConsole,
} from './TemplateEditorLayout';
import { TemplateEditorToolbar } from './TemplateEditorToolbar';
import { TemplateEditorToolbarFileMenu } from './TemplateEditorToolbarFileMenu';
import { TemplateEditorBrowser } from './TemplateEditorBrowser';
import { DryRunProvider } from './DryRunContext';
import { TemplateEditorTextArea } from './TemplateEditorTextArea';
import { TemplateEditorForm } from './TemplateEditorForm';
import { DryRunResults } from './DryRunResults';
import { useTemplateDirectory } from './useTemplateDirectory';
/** @public */
export type ScaffolderTemplateEditorClassKey =
| 'root'
| 'toolbar'
| 'browser'
| 'editor'
| 'preview'
| 'results';
const useStyles = makeStyles(
theme => ({
// Reset and fix sizing to make sure scrolling behaves correctly
root: {
height: '100%',
gridArea: 'pageContent',
display: 'grid',
gridTemplateAreas: `
"toolbar toolbar toolbar"
"browser editor preview"
"results results results"
`,
gridTemplateColumns: '1fr 3fr 2fr',
gridTemplateRows: 'auto 1fr auto',
},
toolbar: {
gridArea: 'toolbar',
},
browser: {
gridArea: 'browser',
overflow: 'auto',
},
editor: {
gridArea: 'editor',
overflow: 'auto',
borderLeft: `1px solid ${theme.palette.divider}`,
},
preview: {
gridArea: 'preview',
position: 'relative',
borderLeft: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.default,
},
scroll: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
padding: theme.spacing(1),
overflow: 'auto',
},
results: {
gridArea: 'results',
},
}),
{ name: 'ScaffolderTemplateEditor' },
);
export const TemplateEditor = (props: {
directory?: TemplateDirectoryAccess;
fieldExtensions?: FieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
onClose?: () => void;
formProps?: FormProps;
onLoad?: () => void;
fieldExtensions?: FieldExtensionOptions<any, any>[];
}) => {
const classes = useStyles();
const { layouts, formProps, fieldExtensions } = props;
const [errorText, setErrorText] = useState<string>();
const {
directory,
handleOpenDirectory,
handleCreateDirectory,
handleCloseDirectory,
} = useTemplateDirectory();
return (
<DirectoryEditorProvider directory={props.directory}>
<DirectoryEditorProvider directory={directory}>
<DryRunProvider>
<Paper
className={classes.root}
component="main"
variant="outlined"
square
>
<section className={classes.toolbar}>
<TemplateEditorToolbar fieldExtensions={props.fieldExtensions} />
</section>
<section className={classes.browser}>
<TemplateEditorBrowser onClose={props.onClose} />
</section>
<section className={classes.editor}>
<TemplateEditorTextArea.DirectoryEditor
errorText={errorText}
onLoad={props.onLoad}
/>
</section>
<section className={classes.preview}>
<div className={classes.scroll}>
<TemplateEditorForm.DirectoryEditorDryRun
setErrorText={setErrorText}
fieldExtensions={props.fieldExtensions}
layouts={props.layouts}
formProps={props.formProps}
<TemplateEditorLayout>
<TemplateEditorLayoutToolbar>
<TemplateEditorToolbar fieldExtensions={fieldExtensions}>
<TemplateEditorToolbarFileMenu
onOpenDirectory={handleOpenDirectory}
onCloseDirectory={handleCloseDirectory}
onCreateDirectory={handleCreateDirectory}
/>
</div>
</section>
<section className={classes.results}>
</TemplateEditorToolbar>
</TemplateEditorLayoutToolbar>
<TemplateEditorLayoutBrowser>
<TemplateEditorBrowser onClose={handleCloseDirectory} />
</TemplateEditorLayoutBrowser>
<TemplateEditorLayoutFiles>
<TemplateEditorTextArea.DirectoryEditor errorText={errorText} />
</TemplateEditorLayoutFiles>
<TemplateEditorLayoutPreview>
<TemplateEditorForm.DirectoryEditorDryRun
setErrorText={setErrorText}
fieldExtensions={fieldExtensions}
layouts={layouts}
formProps={formProps}
/>
</TemplateEditorLayoutPreview>
<TemplateEditorLayoutConsole>
<DryRunResults />
</section>
</Paper>
</TemplateEditorLayoutConsole>
</TemplateEditorLayout>
</DryRunProvider>
</DirectoryEditorProvider>
);
@@ -64,6 +64,10 @@ export function TemplateEditorBrowser(props: { onClose?: () => void }) {
props.onClose();
};
if (!directoryEditor) {
return null;
}
return (
<>
<Grid className={classes.grid} container spacing={0} alignItems="center">
@@ -72,8 +76,8 @@ export function TemplateEditorBrowser(props: { onClose?: () => void }) {
>
<IconButton
size="small"
disabled={directoryEditor?.files.every(file => !file.dirty)}
onClick={() => directoryEditor?.save()}
disabled={directoryEditor.files.every(file => !file.dirty)}
onClick={() => directoryEditor.save()}
>
<SaveIcon />
</IconButton>
@@ -83,7 +87,7 @@ export function TemplateEditorBrowser(props: { onClose?: () => void }) {
'templateEditorPage.templateEditorBrowser.reloadIconTooltip',
)}
>
<IconButton size="small" onClick={() => directoryEditor?.reload()}>
<IconButton size="small" onClick={() => directoryEditor.reload()}>
<RefreshIcon />
</IconButton>
</Tooltip>
@@ -101,9 +105,9 @@ export function TemplateEditorBrowser(props: { onClose?: () => void }) {
</Grid>
<Divider />
<FileBrowser
selected={directoryEditor?.selectedFile?.path ?? ''}
onSelect={directoryEditor?.setSelectedFile}
filePaths={directoryEditor?.files.map(file => file.path) ?? []}
selected={directoryEditor.selectedFile?.path ?? ''}
onSelect={directoryEditor.setSelectedFile}
filePaths={directoryEditor.files.map(file => file.path) ?? []}
/>
</>
);
@@ -239,7 +239,11 @@ export function TemplateEditorFormDirectoryEditorDryRun(
? selectedFile.content
: undefined;
return directoryEditor ? (
if (!directoryEditor) {
return null;
}
return (
<TemplateEditorForm
onDryRun={handleDryRun}
fieldExtensions={fieldExtensions}
@@ -248,7 +252,7 @@ export function TemplateEditorFormDirectoryEditorDryRun(
layouts={layouts}
formProps={props.formProps}
/>
) : null;
);
}
TemplateEditorForm.DirectoryEditorDryRun =
@@ -40,13 +40,13 @@ const useStyles = makeStyles(theme => ({
justifyContent: 'center',
},
cardGrid: {
display: 'grid',
maxWidth: 1000,
gridTemplateColumns: '1fr 1fr',
gridAutoRows: '1fr 1fr',
gap: '1rem',
[theme.breakpoints.down('sm')]: {
gridAutoFlow: 'row',
display: 'grid',
gridGap: theme.spacing(2),
gridAutoFlow: 'row',
[theme.breakpoints.up('md')]: {
gridTemplateRows: '1fr 1fr',
gridTemplateColumns: '1fr 1fr',
},
},
card: {
@@ -167,6 +167,15 @@ export function TemplateEditorIntro(props: EditorIntroProps) {
Icon={CreateNewFolderIcon}
/>
<ActionCard
title={t('templateEditorPage.templateEditorIntro.formEditor.title')}
description={t(
'templateEditorPage.templateEditorIntro.formEditor.description',
)}
Icon={ListAltIcon}
action={() => props.onSelect?.('form')}
/>
<ActionCard
title={t(
'templateEditorPage.templateEditorIntro.fieldExplorer.title',
@@ -177,15 +186,6 @@ export function TemplateEditorIntro(props: EditorIntroProps) {
Icon={FormatListBulletedIcon}
action={() => props.onSelect?.('field-explorer')}
/>
<ActionCard
title={t('templateEditorPage.templateEditorIntro.formEditor.title')}
description={t(
'templateEditorPage.templateEditorIntro.formEditor.description',
)}
Icon={ListAltIcon}
action={() => props.onSelect?.('form')}
/>
</div>
</div>
</div>
@@ -0,0 +1,126 @@
/*
* 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, { PropsWithChildren } from 'react';
import { WithStyles, withStyles } from '@material-ui/core/styles';
export const TemplateEditorLayout = withStyles(
theme => ({
root: {
height: '100%',
gridArea: 'pageContent',
display: 'grid',
gridTemplateAreas: `
"toolbar"
"browser"
"editor"
"preview"
"results"
`,
[theme.breakpoints.up('md')]: {
gridTemplateAreas: `
"toolbar toolbar toolbar"
"browser editor preview"
"results results results"
`,
gridTemplateColumns: '1fr 3fr 2fr',
gridTemplateRows: 'auto 1fr auto',
},
},
}),
{ name: 'ScaffolderTemplateEditorLayout' },
)(({ children, classes }: PropsWithChildren<WithStyles>) => (
<main className={classes.root}>{children}</main>
));
export const TemplateEditorLayoutToolbar = withStyles(
{
root: {
gridArea: 'toolbar',
},
},
{ name: 'ScaffolderTemplateEditorLayoutToolbar' },
)(({ children, classes }: PropsWithChildren<WithStyles>) => (
<section className={classes.root}>{children}</section>
));
export const TemplateEditorLayoutBrowser = withStyles(
theme => ({
root: {
gridArea: 'browser',
overflow: 'auto',
[theme.breakpoints.up('md')]: {
borderRight: `1px solid ${theme.palette.divider}`,
},
},
}),
{ name: 'ScaffolderTemplateEditorLayoutBrowser' },
)(({ children, classes }: PropsWithChildren<WithStyles>) => (
<section className={classes.root}>{children}</section>
));
export const TemplateEditorLayoutFiles = withStyles(
{
root: {
gridArea: 'editor',
overflow: 'auto',
},
},
{ name: 'ScaffolderTemplateEditorLayoutFiles' },
)(({ children, classes }: PropsWithChildren<WithStyles>) => (
<section className={classes.root}>{children}</section>
));
export const TemplateEditorLayoutPreview = withStyles(
theme => ({
root: {
gridArea: 'preview',
position: 'relative',
backgroundColor: theme.palette.background.default,
[theme.breakpoints.up('md')]: {
borderLeft: `1px solid ${theme.palette.divider}`,
},
},
scroll: {
height: '100%',
padding: theme.spacing(1),
[theme.breakpoints.up('md')]: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
overflow: 'auto',
},
},
}),
{ name: 'ScaffolderTemplateEditorLayoutPreview' },
)(({ children, classes }: PropsWithChildren<WithStyles>) => (
<section className={classes.root}>
<div className={classes.scroll}>{children}</div>
</section>
));
export const TemplateEditorLayoutConsole = withStyles(
{
root: {
gridArea: 'results',
},
},
{ name: 'ScaffolderTemplateEditorLayoutConsole' },
)(({ children, classes }: PropsWithChildren<WithStyles>) => (
<section className={classes.root}>{children}</section>
));
@@ -1,5 +1,5 @@
/*
* Copyright 2022 The Backstage Authors
* 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.
@@ -14,63 +14,53 @@
* limitations under the License.
*/
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import { Content, Header, Page } from '@backstage/core-components';
import { WebFileSystemAccess } from '../../../lib/filesystem';
import { TemplateEditorIntro } from './TemplateEditorIntro';
import { useNavigate } from 'react-router-dom';
import { useRouteRef } from '@backstage/core-plugin-api';
import {
editorRouteRef,
customFieldsRouteRef,
rootRouteRef,
templateFormRouteRef,
} from '../../../routes';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import {
FormProps,
FieldExtensionOptions,
type LayoutOptions,
} from '@backstage/plugin-scaffolder-react';
import { scaffolderTranslationRef } from '../../../translation';
import { WebFileSystemStore } from '../../../lib/filesystem/WebFileSystemAccess';
import { createExampleTemplate } from '../../../lib/filesystem/createExampleTemplate';
import { editRouteRef } from '../../../routes';
import { TemplateEditor } from './TemplateEditor';
export function TemplateEditorPage() {
const navigate = useNavigate();
const createLink = useRouteRef(rootRouteRef);
const editorLink = useRouteRef(editorRouteRef);
const customFieldsLink = useRouteRef(customFieldsRouteRef);
const templateFormLink = useRouteRef(templateFormRouteRef);
const useStyles = makeStyles(
{
content: {
padding: 0,
},
},
{ name: 'ScaffolderTemplateEditorToolbar' },
);
interface TemplatePageProps {
defaultPreviewTemplate?: string;
fieldExtensions?: FieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
formProps?: FormProps;
}
export function TemplateEditorPage(props: TemplatePageProps) {
const classes = useStyles();
const editLink = useRouteRef(editRouteRef);
const { t } = useTranslationRef(scaffolderTranslationRef);
return (
<Page themeId="home">
<Header
title={t('templateEditorPage.title')}
type="Scaffolder"
typeLink={createLink()}
subtitle={t('templateEditorPage.subtitle')}
type={t('templateIntroPage.title')}
typeLink={editLink()}
/>
<Content>
<TemplateEditorIntro
onSelect={option => {
if (option === 'local') {
WebFileSystemAccess.requestDirectoryAccess()
.then(directory => WebFileSystemStore.setDirectory(directory))
.then(() => navigate(editorLink()))
.catch(() => {});
} else if (option === 'create-template') {
WebFileSystemAccess.requestDirectoryAccess()
.then(directory => {
createExampleTemplate(directory).then(() => {
WebFileSystemStore.setDirectory(directory);
navigate(editorLink());
});
})
.catch(() => {});
} else if (option === 'form') {
navigate(templateFormLink());
} else if (option === 'field-explorer') {
navigate(customFieldsLink());
}
}}
<Content className={classes.content}>
<TemplateEditor
layouts={props.layouts}
formProps={props.formProps}
fieldExtensions={props.fieldExtensions}
/>
</Content>
</Page>
@@ -20,7 +20,6 @@ import { showPanel } from '@codemirror/view';
import IconButton from '@material-ui/core/IconButton';
import Paper from '@material-ui/core/Paper';
import Tooltip from '@material-ui/core/Tooltip';
import Link from '@material-ui/core/Link';
import Typography from '@material-ui/core/Typography';
import { makeStyles } from '@material-ui/core/styles';
import RefreshIcon from '@material-ui/icons/Refresh';
@@ -45,11 +44,14 @@ const useStyles = makeStyles(theme => ({
verticalAlign: 'top',
},
codeMirror: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
height: '100%',
[theme.breakpoints.up('md')]: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
},
},
errorPanel: {
color: theme.palette.error.main,
@@ -150,10 +152,10 @@ export function TemplateEditorTextArea(props: {
/** A version of the TemplateEditorTextArea that is connected to the DirectoryEditor context */
export function TemplateEditorDirectoryEditorTextArea(props: {
errorText?: string;
onLoad?: () => void;
}) {
const classes = useStyles();
const directoryEditor = useDirectoryEditor();
const { t } = useTranslationRef(scaffolderTranslationRef);
if (!directoryEditor) {
return (
@@ -162,16 +164,7 @@ export function TemplateEditorDirectoryEditorTextArea(props: {
color="textSecondary"
align="center"
>
Please{' '}
<Link
className={classes.button}
component="button"
variant="body1"
onClick={props.onLoad}
>
load
</Link>{' '}
a template directory.
{t('templateEditorPage.templateEditorTextArea.emptyStateParagraph')}
</Typography>
);
}
@@ -0,0 +1,130 @@
/*
* 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 { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
import {
ScaffolderApi,
scaffolderApiRef,
} from '@backstage/plugin-scaffolder-react';
import { ApiProvider } from '@backstage/core-app-api';
import { DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS } from '../../../extensions/default';
import { TemplateEditorToolbar } from './TemplateEditorToolbar';
describe('TemplateEditorToolbar', () => {
const fieldExtensions = DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS;
const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
scaffold: jest.fn(),
cancelTask: jest.fn(),
getTemplateParameterSchema: jest.fn(),
getIntegrationsList: jest.fn(),
getTask: jest.fn(),
streamLogs: jest.fn(),
listActions: jest.fn(),
listTasks: jest.fn(),
autocomplete: jest.fn(),
};
scaffolderApiMock.listActions.mockResolvedValue([
{
id: 'action:example',
description: 'Example description',
schema: {
input: {
type: 'object',
required: ['title'],
properties: {
title: {
title: 'Inform the title',
type: 'string',
},
},
},
},
},
]);
const apis = TestApiRegistry.from([scaffolderApiRef, scaffolderApiMock]);
beforeEach(() => {
jest.clearAllMocks();
});
it('should show instructions for publishing changes', async () => {
await renderInTestApp(<TemplateEditorToolbar />);
await userEvent.click(screen.getByRole('button', { name: 'Publish' }));
expect(
screen.getByRole('heading', { name: 'Publish changes' }),
).toBeInTheDocument();
expect(
screen.getByText(
'Follow the instructions below to create or update a template:',
),
).toBeInTheDocument();
});
it('should open the custom fields explorer', async () => {
await renderInTestApp(
<TemplateEditorToolbar fieldExtensions={fieldExtensions} />,
);
await userEvent.click(
screen.getByRole('button', { name: 'Custom Fields Explorer' }),
);
expect(
screen.getByPlaceholderText('Choose Custom Field Extension'),
).toHaveValue('EntityPicker');
expect(
screen.getByRole('heading', { name: 'Template Spec' }),
).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'Field Preview' }),
).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'Field Options' }),
).toBeInTheDocument();
});
it('should open the installed actions documentation', async () => {
await renderInTestApp(
<ApiProvider apis={apis}>
<TemplateEditorToolbar />
</ApiProvider>,
);
await userEvent.click(
screen.getByRole('button', { name: 'Installed Actions Documentation' }),
);
expect(screen.getByLabelText('Search for an action')).toBeInTheDocument();
expect(screen.getByText('action:example')).toBeInTheDocument();
expect(screen.getByText('Example description')).toBeInTheDocument();
expect(screen.getByText('Inform the title')).toBeInTheDocument();
});
it('should accept custom toolbar actions', async () => {
await renderInTestApp(
<TemplateEditorToolbar>
<button>Custom action</button>
</TemplateEditorToolbar>,
);
expect(
screen.getByRole('button', { name: 'Custom action' }),
).toBeInTheDocument();
});
});
@@ -31,18 +31,25 @@ import DialogActions from '@material-ui/core/DialogActions';
import ExtensionIcon from '@material-ui/icons/Extension';
import DescriptionIcon from '@material-ui/icons/Description';
import { Link } from '@backstage/core-components';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
import { ActionPageContent } from '../../../components/ActionsPage/ActionsPage';
import { scaffolderTranslationRef } from '../../../translation';
import { CustomFieldPlaygroud } from './CustomFieldPlaygroud';
const useStyles = makeStyles(
theme => ({
paper: {
width: '40%',
width: '90%',
padding: theme.spacing(2),
backgroundColor: theme.palette.background.default,
[theme.breakpoints.up('sm')]: {
width: '70%',
},
[theme.breakpoints.up('md')]: {
width: '50%',
},
},
appbar: {
zIndex: 1,
@@ -73,6 +80,7 @@ export function TemplateEditorToolbar(props: {
}) {
const { children, fieldExtensions } = props;
const classes = useStyles();
const { t } = useTranslationRef(scaffolderTranslationRef);
const [showFieldsDrawer, setShowFieldsDrawer] = useState(false);
const [showActionsDrawer, setShowActionsDrawer] = useState(false);
const [showPublishModal, setShowPublishModal] = useState(false);
@@ -81,22 +89,26 @@ export function TemplateEditorToolbar(props: {
<AppBar className={classes.appbar} position="relative">
<Toolbar className={classes.toolbar}>
<div className={classes.toolbarCustomActions}>{children}</div>
<ButtonGroup
className={classes.toolbarDefaultActions}
variant="outlined"
color="primary"
>
<Tooltip title="Custom Fields Explorer">
<ButtonGroup className={classes.toolbarDefaultActions} variant="text">
<Tooltip
title={t('templateEditorToolbar.customFieldExplorerTooltip')}
>
<Button onClick={() => setShowFieldsDrawer(true)}>
<ExtensionIcon />
</Button>
</Tooltip>
<Tooltip title="Installed Actions Documentation">
<Tooltip
title={t(
'templateEditorToolbar.installedActionsDocumentationTooltip',
)}
>
<Button onClick={() => setShowActionsDrawer(true)}>
<DescriptionIcon />
</Button>
</Tooltip>
<Button onClick={() => setShowPublishModal(true)}>Publish</Button>
<Button onClick={() => setShowPublishModal(true)}>
{t('templateEditorToolbar.addToCatalogButton')}
</Button>
</ButtonGroup>
<Drawer
classes={{ paper: classes.paper }}
@@ -120,32 +132,36 @@ export function TemplateEditorToolbar(props: {
aria-labelledby="publish-dialog-title"
aria-describedby="publish-dialog-description"
>
<DialogTitle id="publish-dialog-title">Publish changes</DialogTitle>
<DialogTitle id="publish-dialog-title">
{t('templateEditorToolbar.addToCatalogDialogTitle')}
</DialogTitle>
<DialogContent dividers>
<DialogContentText id="publish-dialog-slide-description">
Follow the instructions below to create or update a template:
<ol>
<li>Save the template files in a local directory</li>
<li>
Create a pull request to a new or existing git repository
</li>
<li>
If the template already exists, the changes will be reflected
in the software catalog once the pull request gets merged
</li>
<li>
But if you are creating a new template, follow this{' '}
<Link to="https://backstage.io/docs/features/software-templates/adding-templates/">
documentation
</Link>{' '}
to register the new template repository in software catalog
</li>
</ol>
{t(
'templateEditorToolbar.addToCatalogDialogContent.stepsIntroduction',
)}
<ul>
{t(
'templateEditorToolbar.addToCatalogDialogContent.stepsListItems',
)
.split('\n')
.map((step, index) => (
<li key={index}>{step}</li>
))}
</ul>
</DialogContentText>
</DialogContent>
<DialogActions>
<Button color="primary" onClick={() => setShowPublishModal(false)}>
Close
<Button
color="primary"
href={t(
'templateEditorToolbar.addToCatalogDialogActions.documentationUrl',
)}
target="_blank"
>
{t(
'templateEditorToolbar.addToCatalogDialogActions.documentationButton',
)}
</Button>
</DialogActions>
</Dialog>
@@ -0,0 +1,109 @@
/*
* 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, { MouseEvent, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Button from '@material-ui/core/Button';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import { useRouteRef } from '@backstage/core-plugin-api';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { editRouteRef } from '../../../routes';
import { scaffolderTranslationRef } from '../../../translation';
export function TemplateEditorToolbarFileMenu(props: {
onOpenDirectory?: () => void;
onCreateDirectory?: () => void;
onCloseDirectory?: () => void;
}) {
const { onOpenDirectory, onCreateDirectory, onCloseDirectory } = props;
const navigate = useNavigate();
const editLink = useRouteRef(editRouteRef);
const { t } = useTranslationRef(scaffolderTranslationRef);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const handleOpenMenu = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
},
[setAnchorEl],
);
const handleCloseMenu = useCallback(() => {
setAnchorEl(null);
}, [setAnchorEl]);
const handleOpenDirectory = useCallback(() => {
handleCloseMenu();
onOpenDirectory?.();
}, [handleCloseMenu, onOpenDirectory]);
const handleCreateDirectory = useCallback(() => {
handleCloseMenu();
onCreateDirectory?.();
}, [handleCloseMenu, onCreateDirectory]);
const handleCloseEditor = useCallback(() => {
handleCloseMenu();
onCloseDirectory?.();
navigate(editLink());
}, [handleCloseMenu, onCloseDirectory, navigate, editLink]);
return (
<>
<Button
aria-controls="file-menu"
aria-haspopup="true"
onClick={handleOpenMenu}
>
{t('templateEditorToolbarFileMenu.button')}
</Button>
<Menu
id="file-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleCloseMenu}
getContentAnchorEl={null}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
keepMounted
>
<MenuItem onClick={handleOpenDirectory} disabled={!onOpenDirectory}>
{t('templateEditorToolbarFileMenu.options.openDirectory')}
</MenuItem>
<MenuItem
onClick={handleCreateDirectory}
disabled={!onCreateDirectory}
divider
>
{t('templateEditorToolbarFileMenu.options.createDirectory')}
</MenuItem>
<MenuItem onClick={handleCloseEditor}>
{t('templateEditorToolbarFileMenu.options.closeEditor')}
</MenuItem>
</Menu>
</>
);
}
@@ -0,0 +1,110 @@
/*
* 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, { MouseEvent, useCallback, useState } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import { Entity } from '@backstage/catalog-model';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { scaffolderTranslationRef } from '../../../translation';
const ITEM_HEIGHT = 48;
const useStyles = makeStyles({
menu: {
maxHeight: ITEM_HEIGHT * 5,
},
});
export type TemplateOption = {
label: string;
value: Entity;
};
export function TemplateEditorToolbarTemplatesMenu(props: {
options: TemplateOption[];
selectedOption?: TemplateOption;
onSelectOption: (option: TemplateOption) => void;
}) {
const { options, selectedOption, onSelectOption } = props;
const classes = useStyles();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const { t } = useTranslationRef(scaffolderTranslationRef);
const handleOpenMenu = useCallback(
(event: MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
},
[setAnchorEl],
);
const handleCloseMenu = useCallback(() => {
setAnchorEl(null);
}, [setAnchorEl]);
const handleSelectOption = useCallback(
(option: TemplateOption) => {
handleCloseMenu();
onSelectOption(option);
},
[handleCloseMenu, onSelectOption],
);
return (
<>
<Button
aria-controls="templates-menu"
aria-haspopup="true"
onClick={handleOpenMenu}
>
{t('templateEditorToolbarTemplatesMenu.button')}
</Button>
<Menu
id="templates-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleCloseMenu}
getContentAnchorEl={null}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
PaperProps={{
className: classes.menu,
}}
keepMounted
>
{options.map((option, index) => (
<MenuItem
key={index}
selected={!!selectedOption && selectedOption === option}
onClick={() => handleSelectOption(option)}
>
{option.label}
</MenuItem>
))}
</Menu>
</>
);
}
@@ -61,6 +61,8 @@ export function TemplateFormPage(props: TemplateFormPageProps) {
<Header
title={t('templateFormPage.title')}
subtitle={t('templateFormPage.subtitle')}
type={t('templateIntroPage.title')}
typeLink={editLink()}
/>
<Content className={classes.root}>
<TemplateFormPreviewer
@@ -14,22 +14,12 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
import {
catalogApiRef,
humanizeEntityRef,
} from '@backstage/plugin-catalog-react';
import LinearProgress from '@material-ui/core/LinearProgress';
import Paper from '@material-ui/core/Paper';
import FormControl from '@material-ui/core/FormControl';
import Input from '@material-ui/core/Input';
import Select from '@material-ui/core/Select';
import Tooltip from '@material-ui/core/Tooltip';
import IconButton from '@material-ui/core/IconButton';
import MenuItem from '@material-ui/core/MenuItem';
import { makeStyles } from '@material-ui/core/styles';
import CloseIcon from '@material-ui/icons/Close';
import React, { useCallback, useState } from 'react';
import useAsync from 'react-use/esm/useAsync';
import yaml from 'yaml';
@@ -38,11 +28,20 @@ import {
FieldExtensionOptions,
FormProps,
} from '@backstage/plugin-scaffolder-react';
import {
TemplateEditorLayout,
TemplateEditorLayoutToolbar,
TemplateEditorLayoutFiles,
TemplateEditorLayoutPreview,
} from './TemplateEditorLayout';
import { TemplateEditorToolbar } from './TemplateEditorToolbar';
import { TemplateEditorToolbarFileMenu } from './TemplateEditorToolbarFileMenu';
import {
TemplateOption,
TemplateEditorToolbarTemplatesMenu,
} from './TemplateEditorToolbarTemplatesMenu';
import { TemplateEditorForm } from './TemplateEditorForm';
import { TemplateEditorTextArea } from './TemplateEditorTextArea';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
const EXAMPLE_TEMPLATE_PARAMS_YAML = `# Edit the template parameters below to see how they will render in the scaffolder form UI
parameters:
@@ -83,14 +82,10 @@ steps:
name: \${{parameters.name}}
`;
type TemplateOption = {
label: string;
value: Entity;
};
/** @public */
export type ScaffolderTemplateFormPreviewerClassKey =
| 'root'
| 'toolbar'
| 'controls'
| 'textArea'
| 'preview';
@@ -102,36 +97,21 @@ const useStyles = makeStyles(
gridArea: 'pageContent',
display: 'grid',
gridTemplateAreas: `
"toolbar"
"textArea"
"preview"
`,
[theme.breakpoints.up('md')]: {
gridTemplateAreas: `
"toolbar toolbar"
"textArea preview"
`,
gridTemplateRows: 'auto 1fr',
gridTemplateColumns: '1fr 1fr',
gridTemplateRows: 'auto 1fr',
gridTemplateColumns: '1fr 1fr',
},
},
toolbar: {
gridArea: 'toolbar',
},
textArea: {
files: {
gridArea: 'textArea',
height: '100%',
},
preview: {
gridArea: 'preview',
position: 'relative',
borderLeft: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.default,
},
scroll: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
padding: theme.spacing(1),
},
formControl: {
minWidth: 120,
maxWidth: 300,
},
}),
{ name: 'ScaffolderTemplateFormPreviewer' },
@@ -140,7 +120,6 @@ const useStyles = makeStyles(
export const TemplateFormPreviewer = ({
defaultPreviewTemplate = EXAMPLE_TEMPLATE_PARAMS_YAML,
customFieldExtensions = [],
onClose,
layouts = [],
formProps,
}: {
@@ -151,16 +130,14 @@ export const TemplateFormPreviewer = ({
formProps?: FormProps;
}) => {
const classes = useStyles();
const { t } = useTranslationRef(scaffolderTranslationRef);
const alertApi = useApi(alertApiRef);
const catalogApi = useApi(catalogApiRef);
const [errorText, setErrorText] = useState<string>();
const [selectedTemplate, setSelectedTemplate] =
useState<TemplateOption | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<TemplateOption>();
const [templateOptions, setTemplateOptions] = useState<TemplateOption[]>([]);
const [templateYaml, setTemplateYaml] = useState(defaultPreviewTemplate);
const { loading } = useAsync(
useAsync(
() =>
catalogApi
.getEntities({
@@ -196,76 +173,42 @@ export const TemplateFormPreviewer = ({
const handleSelectChange = useCallback(
// TODO(Rugvip): Afaik this should be Entity, but didn't want to make runtime changes while fixing types
(selected: any) => {
(selected: TemplateOption) => {
setSelectedTemplate(selected);
setTemplateYaml(yaml.stringify(selected.spec));
setTemplateYaml(yaml.stringify(selected.value.spec));
},
[setTemplateYaml],
[setSelectedTemplate, setTemplateYaml],
);
return (
<>
{loading && <LinearProgress />}
<Paper
className={classes.root}
component="main"
variant="outlined"
square
>
<div className={classes.toolbar}>
<TemplateEditorToolbar fieldExtensions={customFieldExtensions}>
<Tooltip title="Close editor">
<IconButton onClick={onClose}>
<CloseIcon />
</IconButton>
</Tooltip>
<FormControl className={classes.formControl}>
<Select
displayEmpty
value={selectedTemplate}
onChange={e => handleSelectChange(e.target.value)}
input={<Input />}
renderValue={selected => {
if (!selected) {
return t('templateEditorPage.templateFormPreviewer.title');
}
return (selected as Entity).metadata.title;
}}
inputProps={{
'aria-label': t(
'templateEditorPage.templateFormPreviewer.title',
),
}}
>
{templateOptions.map((option, index) => (
<MenuItem key={index} value={option.value as any}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
</TemplateEditorToolbar>
</div>
<div className={classes.textArea}>
<TemplateEditorTextArea
content={templateYaml}
onUpdate={setTemplateYaml}
errorText={errorText}
<TemplateEditorLayout classes={{ root: classes.root }}>
<TemplateEditorLayoutToolbar>
<TemplateEditorToolbar fieldExtensions={customFieldExtensions}>
<TemplateEditorToolbarFileMenu />
<TemplateEditorToolbarTemplatesMenu
options={templateOptions}
selectedOption={selectedTemplate}
onSelectOption={handleSelectChange}
/>
</div>
<div className={classes.preview}>
<div className={classes.scroll}>
<TemplateEditorForm
content={templateYaml}
contentIsSpec
fieldExtensions={customFieldExtensions}
setErrorText={setErrorText}
layouts={layouts}
formProps={formProps}
/>
</div>
</div>
</Paper>
</>
</TemplateEditorToolbar>
</TemplateEditorLayoutToolbar>
<TemplateEditorLayoutFiles classes={{ root: classes.files }}>
<TemplateEditorTextArea
content={templateYaml}
onUpdate={setTemplateYaml}
errorText={errorText}
/>
</TemplateEditorLayoutFiles>
<TemplateEditorLayoutPreview>
<TemplateEditorForm
content={templateYaml}
contentIsSpec
fieldExtensions={customFieldExtensions}
setErrorText={setErrorText}
layouts={layouts}
formProps={formProps}
/>
</TemplateEditorLayoutPreview>
</TemplateEditorLayout>
);
};
@@ -0,0 +1,78 @@
/*
* 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 React from 'react';
import { Content, Header, Page } from '@backstage/core-components';
import { WebFileSystemAccess } from '../../../lib/filesystem';
import { TemplateEditorIntro } from './TemplateEditorIntro';
import { useNavigate } from 'react-router-dom';
import { useRouteRef } from '@backstage/core-plugin-api';
import {
rootRouteRef,
editorRouteRef,
templateFormRouteRef,
customFieldsRouteRef,
} from '../../../routes';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import { WebFileSystemStore } from '../../../lib/filesystem/WebFileSystemAccess';
import { createExampleTemplate } from '../../../lib/filesystem/createExampleTemplate';
export function TemplateIntroPage() {
const navigate = useNavigate();
const createLink = useRouteRef(rootRouteRef);
const editorLink = useRouteRef(editorRouteRef);
const templateFormLink = useRouteRef(templateFormRouteRef);
const customFieldsLink = useRouteRef(customFieldsRouteRef);
const { t } = useTranslationRef(scaffolderTranslationRef);
return (
<Page themeId="home">
<Header
title={t('templateIntroPage.title')}
type="Scaffolder"
typeLink={createLink()}
subtitle={t('templateIntroPage.subtitle')}
/>
<Content>
<TemplateEditorIntro
onSelect={option => {
if (option === 'local') {
WebFileSystemAccess.requestDirectoryAccess()
.then(directory => WebFileSystemStore.setDirectory(directory))
.then(() => navigate(editorLink()))
.catch(() => {});
} else if (option === 'create-template') {
WebFileSystemAccess.requestDirectoryAccess()
.then(directory => {
createExampleTemplate(directory).then(() => {
WebFileSystemStore.setDirectory(directory);
navigate(editorLink());
});
})
.catch(() => {});
} else if (option === 'form') {
navigate(templateFormLink());
} else if (option === 'field-explorer') {
navigate(customFieldsLink());
}
}}
/>
</Content>
</Page>
);
}
@@ -1,95 +0,0 @@
/*
* 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, { useCallback } from 'react';
import useAsyncRetry from 'react-use/esm/useAsyncRetry';
import { Page, Header, Content, Progress } from '@backstage/core-components';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import {
FormProps,
FieldExtensionOptions,
type LayoutOptions,
} from '@backstage/plugin-scaffolder-react';
import { scaffolderTranslationRef } from '../../../translation';
import {
WebFileSystemAccess,
WebFileSystemStore,
} from '../../../lib/filesystem';
import { TemplateEditor } from './TemplateEditor';
import { makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles(
{
content: {
padding: 0,
},
},
{ name: 'ScaffolderTemplateEditorToolbar' },
);
interface TemplatePageProps {
defaultPreviewTemplate?: string;
fieldExtensions?: FieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
formProps?: FormProps;
}
export function TemplatePage(props: TemplatePageProps) {
const classes = useStyles();
const { t } = useTranslationRef(scaffolderTranslationRef);
const { value, loading, retry } = useAsyncRetry(async () => {
const directory = await WebFileSystemStore.getDirectory();
if (!directory) return undefined;
return WebFileSystemAccess.fromHandle(directory);
}, []);
const handleLoadDirectory = useCallback(() => {
WebFileSystemAccess.requestDirectoryAccess()
.then(WebFileSystemStore.setDirectory)
.then(retry);
}, [retry]);
const handleCloseDirectory = useCallback(() => {
WebFileSystemStore.setDirectory(undefined).then(retry);
}, [retry]);
return (
<Page themeId="home">
<Header
title={t('templateEditorPage.title')}
subtitle={t('templateEditorPage.subtitle')}
/>
<Content className={classes.content}>
{loading ? (
<Progress />
) : (
<TemplateEditor
directory={value}
layouts={props.layouts}
formProps={props.formProps}
fieldExtensions={props.fieldExtensions}
onClose={handleCloseDirectory}
onLoad={handleLoadDirectory}
/>
)}
</Content>
</Page>
);
}
@@ -14,9 +14,9 @@
* limitations under the License.
*/
export { TemplatePage } from './TemplatePage';
export { TemplateFormPage } from './TemplateFormPage';
export { TemplateEditorPage } from './TemplateEditorPage';
export { TemplateFormPage } from './TemplateFormPage';
export { TemplateIntroPage } from './TemplateIntroPage';
export { CustomFieldsPage } from './CustomFieldsPage';
export type { ScaffolderCustomFieldExplorerClassKey } from './CustomFieldExplorer';
export type { ScaffolderTemplateEditorClassKey } from './TemplateEditor';
@@ -0,0 +1,66 @@
/*
* 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 { useCallback } from 'react';
import useAsyncRetry from 'react-use/esm/useAsyncRetry';
import {
WebFileSystemAccess,
WebFileSystemStore,
WebDirectoryAccess,
} from '../../../lib/filesystem';
import { createExampleTemplate } from '../../../lib/filesystem/createExampleTemplate';
export function useTemplateDirectory(): {
directory?: WebDirectoryAccess;
loading: boolean;
error?: Error;
handleOpenDirectory: () => void;
handleCreateDirectory: () => void;
handleCloseDirectory: () => void;
} {
const { value, loading, error, retry } = useAsyncRetry(async () => {
const directory = await WebFileSystemStore.getDirectory();
if (!directory) return undefined;
return WebFileSystemAccess.fromHandle(directory);
}, []);
const handleOpenDirectory = useCallback(() => {
WebFileSystemAccess.requestDirectoryAccess()
.then(WebFileSystemStore.setDirectory)
.then(retry);
}, [retry]);
const handleCreateDirectory = useCallback(() => {
WebFileSystemAccess.requestDirectoryAccess()
.then(createExampleTemplate)
.then(WebFileSystemStore.setDirectory)
.then(retry);
}, [retry]);
const handleCloseDirectory = useCallback(() => {
WebFileSystemStore.setDirectory(undefined).then(retry);
}, [retry]);
return {
directory: value,
loading,
error,
handleOpenDirectory,
handleCreateDirectory,
handleCloseDirectory,
};
}
@@ -509,4 +509,81 @@ describe('TemplatePage', () => {
expect(rendered.getByText('array(unknown)')).toBeInTheDocument();
});
it('should filter an action', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
id: 'githut:repo:create',
description: 'Create a new Github repository',
schema: {
input: {
type: 'object',
required: ['name'],
properties: {
name: {
title: 'Repository name',
type: 'string',
},
},
},
},
},
{
id: 'githut:repo:push',
description: 'Push to a Github repository',
schema: {
input: {
type: 'object',
required: ['url'],
properties: {
url: {
title: 'Repository url',
type: 'string',
},
},
},
},
},
]);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
mountedRoutes: {
'/create/actions': rootRouteRef,
},
},
);
expect(
rendered.getByRole('heading', { name: 'githut:repo:create' }),
).toBeInTheDocument();
expect(
rendered.getByRole('heading', { name: 'githut:repo:push' }),
).toBeInTheDocument();
// should filter actions when searching
await userEvent.type(
rendered.getByPlaceholderText('Search for an action'),
'create',
);
await userEvent.keyboard('[ArrowDown][Enter]');
expect(
rendered.getByRole('heading', { name: 'githut:repo:create' }),
).toBeInTheDocument();
expect(
rendered.queryByRole('heading', { name: 'githut:repo:push' }),
).not.toBeInTheDocument();
// should show all actions when clearing the search
await userEvent.click(rendered.getByTitle('Clear'));
expect(
rendered.getByRole('heading', { name: 'githut:repo:create' }),
).toBeInTheDocument();
expect(
rendered.getByRole('heading', { name: 'githut:repo:push' }),
).toBeInTheDocument();
});
});
@@ -16,6 +16,7 @@
import React, { Fragment, useEffect, useState } from 'react';
import useAsync from 'react-use/esm/useAsync';
import {
Action,
ActionExample,
scaffolderApiRef,
} from '@backstage/plugin-scaffolder-react';
@@ -39,6 +40,10 @@ import classNames from 'classnames';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import ExpandLessIcon from '@material-ui/icons/ExpandLess';
import LinkIcon from '@material-ui/icons/Link';
import Autocomplete from '@material-ui/lab/Autocomplete';
import TextField from '@material-ui/core/TextField';
import InputAdornment from '@material-ui/core/InputAdornment';
import SearchIcon from '@material-ui/icons/Search';
import { useApi, useRouteRef } from '@backstage/core-plugin-api';
import {
@@ -125,14 +130,19 @@ export const ActionPageContent = () => {
const { t } = useTranslationRef(scaffolderTranslationRef);
const classes = useStyles();
const { loading, value, error } = useAsync(async () => {
const {
loading,
value = [],
error,
} = useAsync(async () => {
return api.listActions();
}, [api]);
const [selectedAction, setSelectedAction] = useState<Action | null>(null);
const [isExpanded, setIsExpanded] = useState<{ [key: string]: boolean }>({});
useEffect(() => {
if (value && window.location.hash) {
if (value.length && window.location.hash) {
document.querySelector(window.location.hash)?.scrollIntoView();
}
}, [value]);
@@ -295,71 +305,107 @@ export const ActionPageContent = () => {
);
};
return value?.map(action => {
if (action.id.startsWith('legacy:')) {
return undefined;
}
const oneOf = renderTables(
'oneOf',
`${action.id}.input`,
action.schema?.input?.oneOf,
);
return (
<Box pb={4} key={action.id}>
<Typography
id={action.id.replaceAll(':', '-')}
variant="h4"
component="h2"
className={classes.code}
>
{action.id}
</Typography>
<Link
className={classes.link}
to={`#${action.id.replaceAll(':', '-')}`}
>
<LinkIcon />
</Link>
{action.description && <MarkdownContent content={action.description} />}
{action.schema?.input && (
<Box pb={2}>
<Typography variant="h5" component="h3">
{t('actionsPage.action.input')}
</Typography>
{renderTable(
formatRows(`${action.id}.input`, action?.schema?.input),
)}
{oneOf}
</Box>
)}
{action.schema?.output && (
<Box pb={2}>
<Typography variant="h5" component="h3">
{t('actionsPage.action.output')}
</Typography>
{renderTable(
formatRows(`${action.id}.output`, action?.schema?.output),
)}
</Box>
)}
{action.examples && (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h5" component="h3">
{t('actionsPage.action.examples')}
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box pb={2}>
<ExamplesTable examples={action.examples} />
</Box>
</AccordionDetails>
</Accordion>
)}
return (
<>
<Box pb={3}>
<Autocomplete
id="actions-autocomplete"
options={value}
loading={loading}
getOptionLabel={option => option.id}
renderInput={params => (
<TextField
{...params}
aria-label={t('actionsPage.content.searchFieldPlaceholder')}
placeholder={t('actionsPage.content.searchFieldPlaceholder')}
variant="outlined"
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
)}
onChange={(_event, option) => {
setSelectedAction(option);
}}
fullWidth
/>
</Box>
);
});
{(selectedAction ? [selectedAction] : value).map(action => {
if (action.id.startsWith('legacy:')) {
return undefined;
}
const oneOf = renderTables(
'oneOf',
`${action.id}.input`,
action.schema?.input?.oneOf,
);
return (
<Box pb={3} key={action.id}>
<Box display="flex" alignItems="center">
<Typography
id={action.id.replaceAll(':', '-')}
variant="h5"
component="h2"
className={classes.code}
>
{action.id}
</Typography>
<Link
className={classes.link}
to={`#${action.id.replaceAll(':', '-')}`}
>
<LinkIcon />
</Link>
</Box>
{action.description && (
<MarkdownContent content={action.description} />
)}
{action.schema?.input && (
<Box pb={2}>
<Typography variant="h6" component="h3">
{t('actionsPage.action.input')}
</Typography>
{renderTable(
formatRows(`${action.id}.input`, action?.schema?.input),
)}
{oneOf}
</Box>
)}
{action.schema?.output && (
<Box pb={2}>
<Typography variant="h5" component="h3">
{t('actionsPage.action.output')}
</Typography>
{renderTable(
formatRows(`${action.id}.output`, action?.schema?.output),
)}
</Box>
)}
{action.examples && (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6" component="h3">
{t('actionsPage.action.examples')}
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box pb={2}>
<ExamplesTable examples={action.examples} />
</Box>
</AccordionDetails>
</Accordion>
)}
</Box>
);
})}
</>
);
};
export const ActionsPage = () => {
const navigate = useNavigate();
@@ -54,8 +54,8 @@ import {
import { TemplateListPage, TemplateWizardPage } from '../../alpha/components';
import { OngoingTask } from '../OngoingTask';
import {
TemplatePage,
TemplateFormPage,
TemplateIntroPage,
TemplateEditorPage,
CustomFieldsPage,
} from '../../alpha/components/TemplateEditorPage';
@@ -171,7 +171,7 @@ export const Router = (props: PropsWithChildren<RouterProps>) => {
path={editRouteRef.path}
element={
<SecretsContextProvider>
<TemplateEditorPage />
<TemplateIntroPage />
</SecretsContextProvider>
}
/>
@@ -205,7 +205,7 @@ export const Router = (props: PropsWithChildren<RouterProps>) => {
path={editorRouteRef.path}
element={
<SecretsContextProvider>
<TemplatePage
<TemplateEditorPage
layouts={customLayouts}
formProps={props.formProps}
fieldExtensions={fieldExtensions}
@@ -53,7 +53,8 @@ class WebFileAccess implements TemplateFileAccess {
}
}
class WebDirectoryAccess implements TemplateDirectoryAccess {
/** @internal */
export class WebDirectoryAccess implements TemplateDirectoryAccess {
constructor(private readonly handle: IterableDirectoryHandle) {}
async listFiles(): Promise<TemplateFileAccess[]> {
@@ -91,4 +91,5 @@ export async function createExampleTemplate(
for (const [name, data] of Object.entries(files)) {
await directory.createFile({ name, data });
}
return directory;
}
@@ -16,4 +16,8 @@
export type { TemplateFileAccess, TemplateDirectoryAccess } from './types';
export { blobToBase64 } from './helpers';
export { WebFileSystemAccess, WebFileSystemStore } from './WebFileSystemAccess';
export {
WebDirectoryAccess,
WebFileSystemAccess,
WebFileSystemStore,
} from './WebFileSystemAccess';
+43 -4
View File
@@ -29,6 +29,7 @@ export const scaffolderTranslationRef = createTranslationRef({
description:
'There are no actions installed or there was an issue communicating with backend.',
},
searchFieldPlaceholder: 'Search for an action',
tableCell: {
name: 'Name',
title: 'Title',
@@ -194,12 +195,21 @@ export const scaffolderTranslationRef = createTranslationRef({
templateTypePicker: {
title: 'Categories',
},
templateIntroPage: {
title: 'Manage Templates',
subtitle:
'Edit, preview, and try out templates, forms, and custom fields',
},
templateFormPage: {
title: 'Template Form Playground',
subtitle: 'Edit, preview, and try out templates and template forms',
title: 'Template Editor',
subtitle: 'Edit, preview, and try out templates forms',
},
templateCustomFieldPage: {
title: 'Custom Field Explorer',
subtitle: 'Edit, preview, and try out custom fields',
},
templateEditorPage: {
title: 'Manage Templates',
title: 'Template Editor',
subtitle: 'Edit, preview, and try out templates and template forms',
dryRunResults: {
title: 'Dry-run results',
@@ -253,7 +263,7 @@ export const scaffolderTranslationRef = createTranslationRef({
unsupportedTooltip: 'Only supported in some Chromium-based browsers',
},
formEditor: {
title: 'Template playground',
title: 'Template Form Playground',
description:
'Preview and edit a template form, either using a sample template or by loading a template from the catalog.',
},
@@ -266,6 +276,7 @@ export const scaffolderTranslationRef = createTranslationRef({
templateEditorTextArea: {
saveIconTooltip: 'Save file',
refreshIconTooltip: 'Reload file',
emptyStateParagraph: 'Please select an action on the file menu.',
},
templateFormPreviewer: {
title: 'Load Existing Template',
@@ -298,5 +309,33 @@ export const scaffolderTranslationRef = createTranslationRef({
editConfigurationTitle: 'Edit Configuration',
},
},
templateEditorToolbar: {
customFieldExplorerTooltip: 'Custom Fields Explorer',
installedActionsDocumentationTooltip: 'Installed Actions Documentation',
addToCatalogButton: 'Publish',
addToCatalogDialogTitle: 'Publish changes',
addToCatalogDialogContent: {
stepsIntroduction:
'Follow the instructions below to create or update a template:',
stepsListItems:
'Save the template files in a local directory\nCreate a pull request to a new or existing git repository\nIf the template already exists, the changes will be reflected in the software catalog once the pull request gets merged\nBut if you are creating a new template, follow the documentation linked below to register the new template repository in software catalog',
},
addToCatalogDialogActions: {
documentationButton: 'Go to the documentation',
documentationUrl:
'https://backstage.io/docs/features/software-templates/adding-templates/',
},
},
templateEditorToolbarFileMenu: {
button: 'File',
options: {
openDirectory: 'Open template directory',
createDirectory: 'Create template directory',
closeEditor: 'Close template editor',
},
},
templateEditorToolbarTemplatesMenu: {
button: 'Templates',
},
},
});
@@ -140,18 +140,6 @@ describe('SearchType.Accordion', () => {
expect(setPageCursorMock).toHaveBeenCalledWith(undefined);
});
it('should collapse when a new type is selected', async () => {
const { getByText, queryByText } = render(
<Wrapper>
<SearchType.Accordion name={expectedLabel} types={[expectedType]} />
</Wrapper>,
);
await user.click(getByText(expectedType.name));
expect(queryByText('Collapse')).not.toBeInTheDocument();
});
it('should show result counts if enabled', async () => {
const { getAllByText } = render(
<Wrapper>
@@ -96,7 +96,6 @@ export const SearchTypeAccordion = (props: SearchTypeAccordionProps) => {
return () => {
setTypes(type !== '' ? [type] : []);
setPageCursor(undefined);
setExpanded(false);
};
};
@@ -30,8 +30,9 @@ const EXPANDABLE_NAVIGATION_LOCAL_STORAGE =
const StyledButton = withStyles({
root: {
position: 'absolute',
left: '220px',
left: '13.7rem', // Sidebar inner width (15.1em) minus the different margins/paddings
top: '19px',
zIndex: 2,
padding: 0,
minWidth: 0,
},
+1 -1
View File
@@ -62,7 +62,7 @@
"@backstage/plugin-search-common": "workspace:^",
"@backstage/plugin-techdocs-common": "workspace:^",
"@google-cloud/storage": "^7.0.0",
"@smithy/node-http-handler": "^2.1.7",
"@smithy/node-http-handler": "^3.0.0",
"@trendyol-js/openstack-swift-sdk": "^0.0.7",
"@types/express": "^4.17.6",
"dockerode": "^4.0.0",
@@ -86,9 +86,14 @@ export default ({ theme, sidebar }: RuleOptions) => `
scrollbar-width: thin;
}
.md-sidebar .md-sidebar__scrollwrap {
width: calc(12.1rem);
width: calc(16rem);
overflow-y: hidden;
}
@supports selector(::-webkit-scrollbar) {
[dir=ltr] .md-sidebar__inner {
padding-right: calc(100% - 15.1rem);
}
}
.md-sidebar--secondary {
right: ${theme.spacing(3)}px;
}
@@ -202,18 +207,22 @@ export default ({ theme, sidebar }: RuleOptions) => `
height: 100%;
}
.md-sidebar--primary {
width: 12.1rem !important;
width: 16rem !important;
z-index: 200;
left: ${
sidebar.isPinned
? `calc(-12.1rem + ${SIDEBAR_WIDTH})`
: 'calc(-12.1rem + 72px)'
? `calc(-16rem + ${SIDEBAR_WIDTH})`
: 'calc(-16rem + 72px)'
} !important;
}
.md-sidebar--secondary:not([hidden]) {
display: none;
}
[data-md-toggle=drawer]:checked~.md-container .md-sidebar--primary {
transform: translateX(16rem);
}
.md-content {
max-width: 100%;
margin-left: 0;
@@ -241,8 +250,8 @@ export default ({ theme, sidebar }: RuleOptions) => `
@media screen and (max-width: 600px) {
.md-sidebar--primary {
left: -12.1rem !important;
width: 12.1rem;
left: -16rem !important;
width: 16rem;
}
}
+25 -84
View File
@@ -5581,7 +5581,6 @@ __metadata:
version: 0.0.0-use.local
resolution: "@backstage/plugin-catalog-backend-module-bitbucket-cloud@workspace:plugins/catalog-backend-module-bitbucket-cloud"
dependencies:
"@backstage/backend-common": ^0.25.0
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/catalog-client": "workspace:^"
@@ -8276,7 +8275,7 @@ __metadata:
"@backstage/plugin-search-common": "workspace:^"
"@backstage/plugin-techdocs-common": "workspace:^"
"@google-cloud/storage": ^7.0.0
"@smithy/node-http-handler": ^2.1.7
"@smithy/node-http-handler": ^3.0.0
"@trendyol-js/openstack-swift-sdk": ^0.0.7
"@types/express": ^4.17.6
"@types/fs-extra": ^11.0.0
@@ -8756,8 +8755,8 @@ __metadata:
linkType: hard
"@changesets/cli@npm:^2.14.0":
version: 2.27.8
resolution: "@changesets/cli@npm:2.27.8"
version: 2.27.9
resolution: "@changesets/cli@npm:2.27.9"
dependencies:
"@changesets/apply-release-plan": ^7.0.5
"@changesets/assemble-release-plan": ^6.0.4
@@ -8774,14 +8773,12 @@ __metadata:
"@changesets/types": ^6.0.0
"@changesets/write": ^0.3.2
"@manypkg/get-packages": ^1.1.3
"@types/semver": ^7.5.0
ansi-colors: ^4.1.3
ci-info: ^3.7.0
enquirer: ^2.3.0
external-editor: ^3.1.0
fs-extra: ^7.0.1
mri: ^1.2.0
outdent: ^0.5.0
p-limit: ^2.2.0
package-manager-detector: ^0.2.0
picocolors: ^1.1.0
@@ -8791,7 +8788,7 @@ __metadata:
term-size: ^2.1.0
bin:
changeset: bin.js
checksum: b58386716b337976d5797debd4a418fb257bec1e6c9932b99eac7725dd5a76fceff32691c625f897fd4baa78aa2bfba9747fbacf7d8193197f9246b36d31c013
checksum: 4bd36c152f9f93716b001f3ed849717588d2a9eb97f058e86f95ba6a43d8e4311073174251150aabb96f0a1ab5f8ab5ee6a32f85fc9248363f92b3826227cb9d
languageName: node
linkType: hard
@@ -8977,8 +8974,8 @@ __metadata:
linkType: hard
"@codemirror/language@npm:^6.0.0":
version: 6.10.2
resolution: "@codemirror/language@npm:6.10.2"
version: 6.10.3
resolution: "@codemirror/language@npm:6.10.3"
dependencies:
"@codemirror/state": ^6.0.0
"@codemirror/view": ^6.23.0
@@ -8986,7 +8983,7 @@ __metadata:
"@lezer/highlight": ^1.0.0
"@lezer/lr": ^1.0.0
style-mod: ^4.0.0
checksum: 4e60afb75fb56519f59d9d85e0aa03f0c8d017e0da0f3f8f321baf35a776801fcec9787f3d0c029eba12aa766fba98b0fe86fc3111b43e0812b554184c0e8d67
checksum: 53fb72299500f63706f78c888d6b5fd81043ea11ea2fa4c72c13c6d4794bb6f4ec29450208c56b4f40e839984b3dc73505262803fa61416baf588da389a7c577
languageName: node
linkType: hard
@@ -9041,13 +9038,13 @@ __metadata:
linkType: hard
"@codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.23.0":
version: 6.33.0
resolution: "@codemirror/view@npm:6.33.0"
version: 6.34.1
resolution: "@codemirror/view@npm:6.34.1"
dependencies:
"@codemirror/state": ^6.4.0
style-mod: ^4.1.0
w3c-keyname: ^2.2.4
checksum: e28896a7fb40df8e7221fbebfc2cd92c10c6963948e20f3a4300e99c897fbddd091f4fc90cc30eeaf90d07c61dcf6170cd3c164810606fa07337ffb970ffdac2
checksum: 5c7bf199f0b45a3cc192f08c2ac89e5ab972f313cb4f2c979edf6e05b27bccd60c6cb42d5dacb6813ef3a928d75476eb0a00ffdeffd7431c8e9f44bab4f6e12e
languageName: node
linkType: hard
@@ -15291,16 +15288,6 @@ __metadata:
languageName: node
linkType: hard
"@smithy/abort-controller@npm:^2.2.0":
version: 2.2.0
resolution: "@smithy/abort-controller@npm:2.2.0"
dependencies:
"@smithy/types": ^2.12.0
tslib: ^2.6.2
checksum: d0d7fcaa7b67b04c9ad825017110cc294ff06af07f8054ac3b75d8de88ff5fbef1d08f5c1ae672db1839d14ce25f277c459d2b7b7263cbe9e6c3d4518a19230e
languageName: node
linkType: hard
"@smithy/abort-controller@npm:^3.1.2":
version: 3.1.2
resolution: "@smithy/abort-controller@npm:3.1.2"
@@ -15591,20 +15578,7 @@ __metadata:
languageName: node
linkType: hard
"@smithy/node-http-handler@npm:^2.1.7":
version: 2.5.0
resolution: "@smithy/node-http-handler@npm:2.5.0"
dependencies:
"@smithy/abort-controller": ^2.2.0
"@smithy/protocol-http": ^3.3.0
"@smithy/querystring-builder": ^2.2.0
"@smithy/types": ^2.12.0
tslib: ^2.6.2
checksum: 2e63fafdac5bef62181994af2ec065b0f7f04eaed88fb2990a21a9925226fead5013cf4f232b527f3f4d9ffb68ccbe8cd263ad22a7351d36b0dc23e975929a0c
languageName: node
linkType: hard
"@smithy/node-http-handler@npm:^3.2.0":
"@smithy/node-http-handler@npm:^3.0.0, @smithy/node-http-handler@npm:^3.2.0":
version: 3.2.0
resolution: "@smithy/node-http-handler@npm:3.2.0"
dependencies:
@@ -15627,16 +15601,6 @@ __metadata:
languageName: node
linkType: hard
"@smithy/protocol-http@npm:^3.3.0":
version: 3.3.0
resolution: "@smithy/protocol-http@npm:3.3.0"
dependencies:
"@smithy/types": ^2.12.0
tslib: ^2.6.2
checksum: 6c1aaaee9f6ecfb841766938312268f30cbda253f172de7467463aae7d7bfea19a801ab570f3737334e992d2d0ee7446e6af6a6fd82b08533790c489289dff76
languageName: node
linkType: hard
"@smithy/protocol-http@npm:^4.1.1":
version: 4.1.1
resolution: "@smithy/protocol-http@npm:4.1.1"
@@ -15647,17 +15611,6 @@ __metadata:
languageName: node
linkType: hard
"@smithy/querystring-builder@npm:^2.2.0":
version: 2.2.0
resolution: "@smithy/querystring-builder@npm:2.2.0"
dependencies:
"@smithy/types": ^2.12.0
"@smithy/util-uri-escape": ^2.2.0
tslib: ^2.6.2
checksum: db492903302a694a0e982c37b9a74314160c5ee485742f24f8b6d0da66f121e7ff8588742a3a1964f6b983c15cacd52b883c5efa714882a754f575da7a7e014d
languageName: node
linkType: hard
"@smithy/querystring-builder@npm:^3.0.4":
version: 3.0.4
resolution: "@smithy/querystring-builder@npm:3.0.4"
@@ -15737,15 +15690,6 @@ __metadata:
languageName: node
linkType: hard
"@smithy/types@npm:^2.12.0":
version: 2.12.0
resolution: "@smithy/types@npm:2.12.0"
dependencies:
tslib: ^2.6.2
checksum: 2dd93746624d87afbf51c22116fc69f82e95004b78cf681c4a283d908155c22a2b7a3afbd64a3aff7deefb6619276f186e212422ad200df3b42c32ef5330374e
languageName: node
linkType: hard
"@smithy/types@npm:^3.4.0":
version: 3.4.0
resolution: "@smithy/types@npm:3.4.0"
@@ -15909,15 +15853,6 @@ __metadata:
languageName: node
linkType: hard
"@smithy/util-uri-escape@npm:^2.2.0":
version: 2.2.0
resolution: "@smithy/util-uri-escape@npm:2.2.0"
dependencies:
tslib: ^2.6.2
checksum: bade35312d75d1c84226f2a81b70dfef91766c02ecb6c6854b6f920cddb423e01963f7d0c183d523b5991f8e7ca93bcf73f8b3c6923979152b8350c9f3c24fd6
languageName: node
linkType: hard
"@smithy/util-uri-escape@npm:^3.0.0":
version: 3.0.0
resolution: "@smithy/util-uri-escape@npm:3.0.0"
@@ -18136,9 +18071,9 @@ __metadata:
linkType: hard
"@types/lodash@npm:^4.14.151":
version: 4.17.9
resolution: "@types/lodash@npm:4.17.9"
checksum: 6d1bf3e77f0a54d97532755a74260d402d8972259c5451b74612c16cb983b73e0760e5bfe4f9e68ab15051511c867812b40715a01f9805afe6bc36c7dd676378
version: 4.17.10
resolution: "@types/lodash@npm:4.17.10"
checksum: 4600f2f25270c8fee6953e363d318149a5f0f1b1bb820aa2f42d7ada6e4f7de31848bb5ffc2c687b40bd73aa982167bdd6e6d8d456e72abe0c660ec77d1fa7e9
languageName: node
linkType: hard
@@ -35229,11 +35164,9 @@ __metadata:
linkType: hard
"node-mocks-http@npm:^1.0.0":
version: 1.16.0
resolution: "node-mocks-http@npm:1.16.0"
version: 1.16.1
resolution: "node-mocks-http@npm:1.16.1"
dependencies:
"@types/express": ^4.17.21
"@types/node": "*"
accepts: ^1.3.7
content-disposition: ^0.5.3
depd: ^1.1.0
@@ -35244,7 +35177,15 @@ __metadata:
parseurl: ^1.3.3
range-parser: ^1.2.0
type-is: ^1.6.18
checksum: 21ccf1ecaaa6ee0f7c061a7063f59d59c9793a485a907c09c83ff73b12f205ef87537022a4ba8ad937d07454ee0450290093d4a66a4df21e7e8b3696d23e1a7d
peerDependencies:
"@types/express": ^4.17.21 || ^5.0.0
"@types/node": "*"
peerDependenciesMeta:
"@types/express":
optional: true
"@types/node":
optional: true
checksum: 198030725eac236062eb3547a7d5cec2d6f2d44bf05efab0ea621e90f671d6966dca2873faaea9bcbb5dde92a222aff0610a01d453c2f76b0bc5dbdf4bd8057a
languageName: node
linkType: hard