Merge branch 'master' into canon-styles

This commit is contained in:
Charles de Dreuille
2025-03-17 12:44:26 +00:00
163 changed files with 3525 additions and 1132 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': minor
---
Added a new Tooltip component to Canon.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend-module-techdocs': patch
---
Added an extension point that allows for custom entity filtering during document collation.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/config-loader': patch
---
Added `@types/minimist` to `devDependencies`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/integration': patch
---
Gerrit integration now exports `getGitilesAuthenticationUrl`. This enables its usage by the `GerritUrlReader`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': patch
---
Fix Button types that was preventing the use of native attributes like onClick.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-github': patch
---
Made "github:pages:enable" action idempotent
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-defaults': patch
---
Remove unused dependencies
+22
View File
@@ -0,0 +1,22 @@
---
'@backstage/plugin-catalog-backend-module-gerrit': minor
---
**BREAKING** The optional `branch` configuration parameter now defaults to the default branch of the project (where `HEAD` points to).
This parameter was previously using `master` as the default value. In most cases this change should be transparent as Gerrit defaults to using `master`.
This change also allow to specify a custom `catalogPath` in the `catalog.providers.gerrit` configuration.
If not set, it defaults to `catalog-info.yaml` files at the root of repositories. This default was the value before this change.
With the changes made in the `GerritUrlReader`, `catalogPath` allows to use `minimatch`'s glob-patterns.
```diff
catalog:
providers:
gerrit:
all: # identifies your dataset / provider independent of config changes
host: gerrit.company.com
query: 'state=ACTIVE&type=CODE'
+ # This will search for catalog manifests anywhere in the repositories
+ catalogPath: '**/catalog-info.{yml,yaml}'
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-gitlab': patch
---
Made "gitlab:pipeline:trigger" action idempotent
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': minor
---
We added a new IconButton component with fixed sizes showcasing a single icon.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-permission-node': minor
---
**BREAKING** The `ServerPermissionClient` can no longer be instantiated with a `tokenManager` and must instead be instantiated with an `auth` service. If you are still on the legacy backend system, use `createLegacyAuthAdapters()` from `@backstage/backend-common` to create a compatible `auth` service.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-gitlab': patch
---
Made gitlab:repo:push action idempotent.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-gitlab': patch
---
Made "gitlab:projectDeployToken:create" action idempotent
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-github': patch
---
Made "github:issues:label" action idempotent
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-test-utils': patch
---
Added support for PostgreSQL version 17
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-gitlab': patch
---
Made "gitlab:projectVariable:create" action idempotent
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-github': patch
---
Made "github:repo:push" action idempotent
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-gitlab': patch
---
Made "gitlab:projectAccessToken:create" action idempotent
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': minor
---
Added a new Menu component to Canon.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/core-components': patch
---
Avoid Layout Shift for `DismissableBanner` when using a `storageApi` with latency (e.g. `user-settings-backend`)
Properly handle the `unknown` state of the `storageApi`. There's a trade-off: this may lead to some Layout Shift if the banner has not been dismissed, but once it has been dismissed, you won't have any.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/backend-defaults': patch
---
`GerritUrlReader` is now able to `search` files matching a given pattern URL (using `minimatch` glob patterns).
This allows the Gerrit Discovery to find all Backstage manifests inside a repository using the `**/catalog-info.yaml` pattern.
+1 -1
View File
@@ -2,4 +2,4 @@
'@backstage/plugin-techdocs': patch
---
Excludes svg styling from sanitization
Excludes SVG styling from sanitization
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': minor
---
Updating styles for Text and Link components as well as global surface tokens.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-github': patch
---
Made "github:repo:create" action idempotent
+1 -1
View File
@@ -2,4 +2,4 @@
'@backstage/plugin-techdocs': patch
---
Adds the page name of techdoc to the document's title.
Adds the page name of techdocs to the document's title.
+1 -1
View File
@@ -2,4 +2,4 @@
'@backstage/plugin-techdocs': patch
---
Updates logic to check for SVG sources when inlining svgs.
Updates logic to check for SVG sources when inlining them.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': patch
---
Remove unused dependencies
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-common': patch
---
Add optional `lifecycle` property to the spec field of Template entities.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-gitlab': patch
---
Made "gitlab:group:migrate" action idempotent
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-permission-node': patch
---
Deprecated `createPermissionIntegrationRouter` and related types, which has been replaced by `PermissionRegistryService`. For more information, including how to migrate existing plugins, see the [service docs](https://backstage.io/docs/backend-system/core-services/permissions-registry).
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': minor
---
Added a new ScrollArea component for Canon.
@@ -190,6 +190,7 @@ Indal
indexable
ingestors
inlined
inlining
inlinehilite
integrator's
interop
+6 -6
View File
@@ -161,8 +161,8 @@ jobs:
name: Test ${{ matrix.node-version }}
services:
postgres16:
image: postgres:16
postgres17:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
options: >-
@@ -172,8 +172,8 @@ jobs:
--health-retries 5
ports:
- 5432/tcp
postgres12:
image: postgres:12
postgres13:
image: postgres:13
env:
POSTGRES_PASSWORD: postgres
options: >-
@@ -253,8 +253,8 @@ jobs:
run: yarn backstage-cli repo test --maxWorkers=3 --workerIdleMemoryLimit=1300M --since origin/master --successCache --successCacheDir .cache/backstage-cli
env:
BACKSTAGE_TEST_DISABLE_DOCKER: 1
BACKSTAGE_TEST_DATABASE_POSTGRES16_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres16.ports[5432] }}
BACKSTAGE_TEST_DATABASE_POSTGRES12_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres12.ports[5432] }}
BACKSTAGE_TEST_DATABASE_POSTGRES17_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres17.ports[5432] }}
BACKSTAGE_TEST_DATABASE_POSTGRES13_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres13.ports[5432] }}
BACKSTAGE_TEST_DATABASE_MYSQL8_CONNECTION_STRING: mysql://root:root@localhost:${{ job.services.mysql8.ports[3306] }}/ignored
BACKSTAGE_TEST_CACHE_REDIS7_CONNECTION_STRING: redis://localhost:${{ job.services.redis.ports[6379] }}
+1 -1
View File
@@ -59,7 +59,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 # v3.8.0
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Build and push
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0
+6 -6
View File
@@ -16,8 +16,8 @@ jobs:
node-version: [20.x, 22.x]
services:
postgres16:
image: postgres:16
postgres17:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
options: >-
@@ -27,8 +27,8 @@ jobs:
--health-retries 5
ports:
- 5432/tcp
postgres12:
image: postgres:12
postgres13:
image: postgres:13
env:
POSTGRES_PASSWORD: postgres
options: >-
@@ -122,8 +122,8 @@ jobs:
yarn backstage-cli repo test --maxWorkers=3 --workerIdleMemoryLimit=1300M --coverage --successCache --successCacheDir .cache/backstage-cli
env:
BACKSTAGE_TEST_DISABLE_DOCKER: 1
BACKSTAGE_TEST_DATABASE_POSTGRES16_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres16.ports[5432] }}
BACKSTAGE_TEST_DATABASE_POSTGRES12_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres12.ports[5432] }}
BACKSTAGE_TEST_DATABASE_POSTGRES17_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres17.ports[5432] }}
BACKSTAGE_TEST_DATABASE_POSTGRES13_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres13.ports[5432] }}
BACKSTAGE_TEST_DATABASE_MYSQL8_CONNECTION_STRING: mysql://root:root@localhost:${{ job.services.mysql8.ports[3306] }}/ignored
BACKSTAGE_TEST_CACHE_REDIS7_CONNECTION_STRING: redis://localhost:${{ job.services.redis.ports[6379] }}
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
services:
postgres:
image: postgres:12
image: postgres:13
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
-1
View File
@@ -132,7 +132,6 @@ Scope: Tooling and Community Repo Maintainers for the Backstage [Community Plugi
| André Wanlin | Spotify | [awanlin](https://github.com/awanlin) | `ahhhndre` |
| Bethany Griggs | Red Hat | [BethGriggs](https://github.com/BethGriggs) | `bethgriggs` |
| Kashish Mittal | Red Hat | [04kash](https://github.com/04kash) | `kashh._.` |
| Nick Boldt | Red Hat | [nickboldt](https://github.com/nickboldt) | `nboldt` |
| Vincenzo Scamporlino | Spotify | [vinzscam](https://github.com/vinzscam) | `vinzscam` |
### Events
+33 -24
View File
@@ -154,30 +154,36 @@ export function createGithubRepoCreateAction(options: {
username: owner,
});
await ctx.checkpoint('repo.creation', async () => {
const repoCreationPromise =
user.data.type === 'Organization'
? client.rest.repos.createInOrg({
name: repo,
org: owner,
})
: client.rest.repos.createForAuthenticatedUser({
name: repo,
});
const { repoUrl } = await repoCreationPromise;
return { repoUrl };
await ctx.checkpoint({
key: 'repo.creation.v1',
fn: async () => {
const repoCreationPromise =
user.data.type === 'Organization'
? client.rest.repos.createInOrg({
name: repo,
org: owner,
})
: client.rest.repos.createForAuthenticatedUser({
name: repo,
});
const { repoUrl } = await repoCreationPromise;
return { repoUrl };
},
});
if (secrets) {
await ctx.checkpoint('repo.create.variables', async () => {
for (const [key, value] of Object.entries(repoVariables ?? {})) {
await client.rest.actions.createRepoVariable({
owner,
repo,
name: key,
value: value,
});
}
await ctx.checkpoint({
key: 'repo.create.variables',
fn: async () => {
for (const [key, value] of Object.entries(repoVariables ?? {})) {
await client.rest.actions.createRepoVariable({
owner,
repo,
name: key,
value: value,
});
}
},
});
}
@@ -202,9 +208,12 @@ Checkpoints will allow action authors to create actions where code paths are ign
This will be provided on a context object and action of author provide a key and a callback.
```typescript
await ctx.checkpoint('repo.creation', async () => {
const { repoUrl } = await client.rest.Repository.create({});
return { repoUrl };
await ctx.checkpoint({
key: 'repo.creation',
fn: async () => {
const { repoUrl } = await client.rest.Repository.create({});
return { repoUrl };
},
});
```
@@ -0,0 +1,111 @@
import { PropsTable } from '@/components/PropsTable';
import { Snippet } from '@/components/Snippet';
import { Tabs } from '@/components/Tabs';
import { CodeBlock } from '@/components/CodeBlock';
import {
IconButtonPreview,
IconButtonSizes,
IconButtonDisabled,
IconButtonResponsive,
} from '@/snippets/icon-button';
import { iconButtonVariants } from '@/snippets/_snippets';
import { iconButtonPropDefs } from '../../../../../../packages/canon/src/components/IconButton/IconButton.props';
# Icon Button
A button component with a single icon that can be used to trigger actions.
<Snippet
align="center"
py={4}
preview={<IconButtonPreview />}
code={`<Flex align="center">
<IconButton icon="cloud" variant="primary" />
<IconButton icon="cloud" variant="secondary" />
</Flex>`}
/>
<Tabs.Root>
<Tabs.List>
<Tabs.Tab>Usage</Tabs.Tab>
<Tabs.Tab>Theming</Tabs.Tab>
</Tabs.List>
<Tabs.Panel>
<CodeBlock
code={`import { IconButton } from '@backstage/canon';
<IconButton />
`}
/>
</Tabs.Panel>
<Tabs.Panel>
We recommend starting with our [global tokens](/theme/theming) to customize the library and align it with
your brand. For additional flexibility, you can use the provided class names for each element listed below.
<CodeBlock
code={`<IconButton className="canon-IconButton" />`}
/>
</Tabs.Panel>
</Tabs.Root>
## API reference
<PropsTable
data={{
...iconButtonPropDefs,
icon: { values: 'icon', responsive: false },
}}
/>
## Examples
### Variants
Here's a view when buttons have different variants.
<Snippet
align="center"
py={4}
open
preview={<IconButtonPreview />}
code={iconButtonVariants}
/>
### Sizes
Here's a view when buttons have different sizes.
<Snippet
align="center"
py={4}
open
preview={<IconButtonSizes />}
code={`<Flex align="center">
<IconButton icon="cloud" size="small" />
<IconButton icon="cloud" size="medium" />
</Flex>`}
/>
### Disabled
Here's a view when buttons are disabled.
<Snippet
align="center"
py={4}
open
preview={<IconButtonDisabled />}
code={`<IconButton icon="cloud" disabled />`}
/>
### Responsive
Here's a view when buttons are responsive.
<Snippet
align="center"
py={4}
open
preview={<IconButtonResponsive />}
code={`<IconButton icon="cloud" variant={{ initial: 'primary', lg: 'secondary' }} />`}
/>
@@ -53,7 +53,7 @@ color of your app.
</Table.Row>
<Table.Row>
<Table.Cell>
<Chip head>--canon-bg-elevated</Chip>
<Chip head>--canon-bg-surface-1</Chip>
</Table.Cell>
<Table.Cell>Use for any panels or elevated surfaces.</Table.Cell>
</Table.Row>
@@ -189,7 +189,7 @@ are prefixed with `fg` to make it easier to identify.
<Chip head>--canon-fg-primary</Chip>
</Table.Cell>
<Table.Cell>
It should be used on top of `--canon-bg-app` or `--canon-bg-elevated`.
It should be used on top of `--canon-bg-app` or `--canon-bg-surface-1`.
</Table.Cell>
</Table.Row>
<Table.Row>
@@ -197,7 +197,7 @@ are prefixed with `fg` to make it easier to identify.
<Chip head>--canon-fg-secondary</Chip>
</Table.Cell>
<Table.Cell>
It should be used on top of `--canon-bg-app` or `--canon-bg-elevated`.
It should be used on top of `--canon-bg-app` or `--canon-bg-surface-1`.
</Table.Cell>
</Table.Row>
<Table.Row>
@@ -205,7 +205,7 @@ are prefixed with `fg` to make it easier to identify.
<Chip head>--canon-fg-link</Chip>
</Table.Cell>
<Table.Cell>
It should be used on top of `--canon-bg-app` or `--canon-bg-elevated`.
It should be used on top of `--canon-bg-app` or `--canon-bg-surface-1`.
</Table.Cell>
</Table.Row>
<Table.Row>
@@ -213,7 +213,7 @@ are prefixed with `fg` to make it easier to identify.
<Chip head>--canon-fg-link-hover</Chip>
</Table.Cell>
<Table.Cell>
It should be used on top of `--canon-bg-app` or `--canon-bg-elevated`.
It should be used on top of `--canon-bg-app` or `--canon-bg-surface-1`.
</Table.Cell>
</Table.Row>
<Table.Row>
@@ -267,7 +267,7 @@ low contrast to help as a separator with the different background colors.
<Chip head>--canon-border</Chip>
</Table.Cell>
<Table.Cell>
It should be used on top of `--canon-bg-elevated`.
It should be used on top of `--canon-bg-surface-1`.
</Table.Cell>
</Table.Row>
<Table.Row>
@@ -17,7 +17,7 @@ const defaultTheme = `:root {
const myTheme = createTheme({
theme: 'light',
settings: {
background: 'var(--canon-bg-elevated)',
background: 'var(--canon-bg-surface-1)',
backgroundImage: '',
foreground: '#6182B8',
caret: '#5d00ff',
@@ -19,10 +19,6 @@ export const PropsTable = <T extends Record<string, PropData>>({
}) => {
const completeData = {
...data,
children: {
values: 'ReactNode',
responsive: false,
},
className: {
values: 'string',
responsive: false,
+1 -4
View File
@@ -31,10 +31,7 @@ export const Tab = (props: React.ComponentProps<typeof TabsPrimitive.Tab>) => (
<Text
variant="subtitle"
weight="bold"
{...rest}
style={{
color: state.selected ? 'var(--primary)' : 'var(--secondary)',
}}
color={state.selected ? 'primary' : 'secondary'}
>
{children}
</Text>
+10 -4
View File
@@ -5,7 +5,7 @@ export const customTheme = `:root {
--canon-font-weight-regular: 400;
--canon-font-weight-bold: 600;
--canon-bg: #f8f8f8;
--canon-bg-elevated: #fff;
--canon-bg-surface-1: #fff;
/* ... other CSS variables */
/* Add your custom components styles here */
@@ -20,7 +20,7 @@ export const customTheme = `:root {
--canon-font-weight-regular: 400;
--canon-font-weight-bold: 600;
--canon-bg: #f8f8f8;
--canon-bg-elevated: #fff;
--canon-bg-surface-1: #fff;
/* ... other CSS variables */
/* Add your custom components styles here */
@@ -38,7 +38,7 @@ export const grid = `import { Grid } from '@backstage/canon';
</Grid>
`;
export const buttonVariants = `<Inline alignY="center">
export const buttonVariants = `<Flex align="center">
<Button iconStart="cloud" variant="primary">
Button
</Button>
@@ -48,7 +48,13 @@ export const buttonVariants = `<Inline alignY="center">
<Button iconStart="cloud" variant="tertiary">
Button
</Button>
</Inline>
</Flex>
`;
export const iconButtonVariants = `<Flex align="center">
<IconButton icon="cloud" variant="primary" />
<IconButton icon="cloud" variant="secondary" />
</Flex>
`;
export const flexFAQ1 = `<Grid columns={3} gap="4">
+57
View File
@@ -0,0 +1,57 @@
'use client';
import { IconButton, Flex, ButtonProps, Text } from '../../../packages/canon';
export const IconButtonPreview = () => {
return (
<Flex align="center">
<IconButton icon="cloud" variant="primary" />
<IconButton icon="cloud" variant="secondary" />
</Flex>
);
};
export const IconButtonSizes = () => {
return (
<Flex align="center">
<IconButton icon="cloud" size="medium" />
<IconButton icon="cloud" size="small" />
</Flex>
);
};
export const IconButtonDisabled = () => {
return <IconButton icon="cloud" disabled />;
};
export const IconButtonResponsive = () => {
return (
<IconButton
icon="cloud"
variant={{ initial: 'primary', lg: 'secondary' }}
/>
);
};
export const IconButtonPlayground = () => {
const variants: string[] = ['primary', 'secondary'];
return (
<Flex direction="column">
{variants.map(variant => (
<Flex direction="column" key={variant}>
<Text>{variant}</Text>
{['small', 'medium'].map(size => (
<Flex align="center" key={size}>
<IconButton
icon="cloud"
variant={variant as ButtonProps['variant']}
size={size as ButtonProps['size']}
/>
</Flex>
))}
</Flex>
))}
</Flex>
);
};
+5
View File
@@ -87,6 +87,11 @@ export const components: Page[] = [
slug: 'icon',
status: 'alpha',
},
{
title: 'IconButton',
slug: 'icon-button',
status: 'alpha',
},
{
title: 'Input',
slug: 'input',
@@ -190,7 +190,7 @@ describe('MyDatabaseClass', () => {
// "physical" databases to test against is much costlier than creating the
// "logical" databases within them that the individual tests use.
const databases = TestDatabases.create({
ids: ['POSTGRES_16', 'POSTGRES_12', 'SQLITE_3', 'MYSQL_8'],
ids: ['POSTGRES_17', 'POSTGRES_13', 'SQLITE_3', 'MYSQL_8'],
});
// Just an example of how to conveniently bundle up the setup code
@@ -236,8 +236,8 @@ your CI environment is able to supply databases natively, the `TestDatabases`
support custom connection strings through the use of environment variables that
it'll take into account when present.
- `BACKSTAGE_TEST_DATABASE_POSTGRES17_CONNECTION_STRING`
- `BACKSTAGE_TEST_DATABASE_POSTGRES13_CONNECTION_STRING`
- `BACKSTAGE_TEST_DATABASE_POSTGRES9_CONNECTION_STRING`
- `BACKSTAGE_TEST_DATABASE_MYSQL8_CONNECTION_STRING`
## Testing Service Factories
@@ -1,5 +1,5 @@
---
id: permissions
id: permissions-registry
title: Permissions Registry Service
sidebar_label: Permissions Registry
description: Documentation for the Permissions Registry service
+32
View File
@@ -134,6 +134,38 @@ search:
timeout: { minutes: 3 }
```
### Filtering through the catalog collator
The TechDocs collator by default filters through catalog entities where the annotation `metadata.annotations.backstage.io/techdocs-ref` exists. If you wish to further filter out entities, there are two ways to do so through the `techDocsCollatorEntityFilterExtensionPoint`.
```typescript
export const exampleCustomCatalogFiltering = createBackendModule({
pluginId: 'search',
moduleId: 'search-techdocs-collator-entity-filter',
register(reg) {
reg.registerInit({
deps: {
customCollatorFilter: techDocsCollatorEntityFilterExtensionPoint,
},
async init({ customCollatorFilter }) {
/* filtering by catalog params */
customCollatorFilter.setCustomCatalogApiFilters([
{ kind: ['API', 'Component', ...] },
{ metadata: ['...more filters'] },
]);
/* filtering by a custom function */
customCollatorFilter.setEntityFilterFunction((entities: Entity[]) =>
entities.filter(
entity => entity.metadata?.annotations?.abc === 'xyz',
),
);
},
});
},
});
```
## Community Collators
Here are some of the known Search Collators available in from the Backstage Community:
@@ -19,9 +19,11 @@ array when registering your custom actions, as seen below.
## Streamlining Custom Action Creation with Backstage CLI
The creation of custom actions in Backstage has never been easier thanks to the Backstage CLI. This tool streamlines the setup process, allowing you to focus on your actions' unique functionality.
The creation of custom actions in Backstage has never been easier thanks to the Backstage CLI. This tool streamlines the
setup process, allowing you to focus on your actions' unique functionality.
Start by using the `yarn backstage-cli new` command to generate a scaffolder module. This command sets up the necessary boilerplate code, providing a smooth start:
Start by using the `yarn backstage-cli new` command to generate a scaffolder module. This command sets up the necessary
boilerplate code, providing a smooth start:
```
$ yarn backstage-cli new
@@ -34,13 +36,19 @@ $ yarn backstage-cli new
You can find a [list](../../tooling/cli/03-commands.md) of all commands provided by the Backstage CLI.
When prompted, select the option to generate a scaffolder module. This creates a solid foundation for your custom action. Enter the name of the module you wish to create, and the CLI will generate the required files and directory structure.
When prompted, select the option to generate a scaffolder module. This creates a solid foundation for your custom
action. Enter the name of the module you wish to create, and the CLI will generate the required files and directory
structure.
## Writing your Custom Action
After running the command, the CLI will create a new directory with your new scaffolder module. This directory will be the working directory for creating the custom action. It will contain all the necessary files and boilerplate code to get started.
After running the command, the CLI will create a new directory with your new scaffolder module. This directory will be
the working directory for creating the custom action. It will contain all the necessary files and boilerplate code to
get started.
Let's create a simple action that adds a new file and some contents that are passed as `input` to the function. Within the generated directory, locate the file at `src/actions/example/example.ts`. Feel free to rename this file along with its generated unit test. We will replace the existing placeholder code with our custom action code as follows:
Let's create a simple action that adds a new file and some contents that are passed as `input` to the function. Within
the generated directory, locate the file at `src/actions/example/example.ts`. Feel free to rename this file along with
its generated unit test. We will replace the existing placeholder code with our custom action code as follows:
```ts title="With Zod"
import { resolveSafeChildPath } from '@backstage/backend-plugin-api';
@@ -82,7 +90,8 @@ The `createTemplateAction` takes an object which specifies the following:
- `id` - A unique ID for your custom action. We encourage you to namespace these
in some way so that they won't collide with future built-in actions that we
may ship with the `scaffolder-backend` plugin.
- `description` - An optional field to describe the purpose of the action. This will populate in the `/create/actions` endpoint.
- `description` - An optional field to describe the purpose of the action. This will populate in the `/create/actions`
endpoint.
- `schema.input` - A `zod` or JSON schema object for input values to your function
- `schema.output` - A `zod` or JSON schema object for values which are output from the
function using `ctx.output`
@@ -132,18 +141,24 @@ export const createNewFileAction = () => {
### Naming Conventions
Try to keep names consistent for both your own custom actions, and any actions contributed to open source. We've found that a separation of `:` and using a verb as the last part of the name works well.
We follow `provider:entity:verb` or as close to this as possible for our built in actions. For example, `github:actions:create` or `github:repo:create`.
Try to keep names consistent for both your own custom actions, and any actions contributed to open source. We've found
that a separation of `:` and using a verb as the last part of the name works well.
We follow `provider:entity:verb` or as close to this as possible for our built in actions. For example,
`github:actions:create` or `github:repo:create`.
Also feel free to use your company name to namespace them if you prefer too, for example `acme:file:create` like above.
Prefer to use `camelCase` over `snake_case` or `kebab-case` for these actions if possible, which leads to better reading and writing of template entity definitions.
Prefer to use `camelCase` over `snake_case` or `kebab-case` for these actions if possible, which leads to better reading
and writing of template entity definitions.
> We're aware that there are some exceptions to this, but try to follow as close as possible. We'll be working on migrating these in the repository over time too.
> We're aware that there are some exceptions to this, but try to follow as close as possible. We'll be working on
> migrating these in the repository over time too.
### Adding a TemplateExample
A TemplateExample is a predefined structure that can be used to create custom actions in your software templates. It serves as a blueprint for users to understand how to use a specific action and its fields as well as to ensure consistency and standardization across different custom actions.
A TemplateExample is a predefined structure that can be used to create custom actions in your software templates. It
serves as a blueprint for users to understand how to use a specific action and its fields as well as to ensure
consistency and standardization across different custom actions.
#### Define a TemplateExample and add to your Custom Action
@@ -199,7 +214,8 @@ argument. It looks like the following:
## Registering Custom Actions
To register your new custom action in the Backend System you will need to create a backend module. Here is a very simplified example of how to do that:
To register your new custom action in the Backend System you will need to create a backend module. Here is a very
simplified example of how to do that:
```ts title="packages/backend/src/index.ts"
/* highlight-add-start */
@@ -233,7 +249,8 @@ backend.add(import('@backstage/plugin-scaffolder-backend'));
backend.add(scaffolderModuleCustomExtensions);
```
If your custom action requires core services such as `config` or `cache` they can be imported in the dependencies and passed to the custom action function.
If your custom action requires core services such as `config` or `cache` they can be imported in the dependencies and
passed to the custom action function.
```ts title="packages/backend/src/index.ts"
import {
@@ -243,17 +260,17 @@ import {
...
env.registerInit({
deps: {
scaffolder: scaffolderActionsExtensionPoint,
cache: coreServices.cache,
config: coreServices.rootConfig,
},
async init({ scaffolder, cache, config }) {
scaffolder.addActions(
customActionNeedingCacheAndConfig({ cache: cache, config: config }),
);
})
env.registerInit({
deps: {
scaffolder: scaffolderActionsExtensionPoint,
cache: coreServices.cache,
config: coreServices.rootConfig,
},
async init({scaffolder, cache, config}) {
scaffolder.addActions(
customActionNeedingCacheAndConfig({cache: cache, config: config}),
);
})
```
### Using Checkpoints in Custom Actions (Experimental)
@@ -281,6 +298,13 @@ You have to define the unique key in scope of the scaffolder task for your check
will check if the checkpoint with such key was already executed or not, if yes, and the run was successful, the callback
will be skipped and instead the stored value will be returned.
Whenever you change the return type of the checkpoint, we encourage you to change the ID.
For example, you can embed the versioning or another indicator for that (instead of using key `create.projects`, it can
be `create.projects.v1`).
If you'll preserve the same key, and you'll try to restart the affected task, it will fail on this checkpoint.
The cached result will not match with the expected updated return type.
By changing the key, you'll invalidate the cache of the checkpoint.
### Register Custom Actions with the Legacy Backend System
Once you have your Custom Action ready for usage with the scaffolder, you'll
+7 -3
View File
@@ -45,8 +45,9 @@ catalog:
gerrit:
yourProviderId: # identifies your dataset / provider independent of config changes
host: gerrit-your-company.com
branch: master # Optional
branch: master # Optional, defaults to the repository's default branch
query: 'state=ACTIVE&prefix=webapps'
catalogPath: 'catalog-info.yaml' # Optional, defaults to catalog-info.yaml
schedule:
# supports cron, ISO duration, "human duration" as used in code
frequency: { minutes: 30 }
@@ -56,12 +57,15 @@ catalog:
host: gerrit-your-company.com
branch: master # Optional
query: 'state=ACTIVE&prefix=backend'
# catalogPath can be a glob-pattern supported by the minimatch library
catalogPath: '{**/catalog-info.{yml,yaml},**/.catalog-info/*.{yml,yaml}}'
```
The provider configuration is composed of three parts:
The provider configuration consists of the following parts:
- **`host`**: the host of the Gerrit integration to use.
- **`branch`** _(optional)_: the branch where we will look for catalog entities (defaults to "master").
- **`branch`** _(optional)_: the branch where we will look for catalog entities (defaults to the repository's default branch).
- **`query`**: this string is directly used as the argument to the "List Project" API.
Typically, you will want to have some filter here to exclude projects that will
never contain any catalog files.
- **`catalogPath`**: path relative to the root of the repository where the Backstage manifests are stored. It can also be a glob pattern supported by [`minimatch`](https://github.com/isaacs/minimatch) to load multiple files.
+2 -2
View File
@@ -106,7 +106,7 @@ be part of the `1.12` Backstage release.
The following versioning policy applies to all packages:
- Breaking changes are noted in the changelog, and documentation is updated.
- Breaking changes are prefixed with `**BREAKING**: ` in the changelog.
- Breaking changes are prefixed with `**BREAKING**:` in the changelog.
- All public exports are considered stable and will have an entry in the
changelog.
- Breaking changes are recommended to document a clear upgrade path in the
@@ -195,4 +195,4 @@ The Backstage project recommends and supports using PostgreSQL for persistent st
The PostgreSQL [versioning policy](https://www.postgresql.org/support/versioning/) is to release a new major version every year with new features which is then supported for 5 years after its initial release.
Our policy mirrors the PostgreSQL versioning policy - we will support the last 5 major versions. We will also test the newest and oldest versions in that range. For example, if the range we support is currently 12 to 16, then we would only test 12 and 16 explicitly.
Our policy mirrors the PostgreSQL versioning policy - we will support the last 5 major versions. We will also test the newest and oldest versions in that range. For example, if the range we support is currently 13 to 17, then we would only test 13 and 17 explicitly.
+1
View File
@@ -450,6 +450,7 @@ export default {
'backend-system/core-services/lifecycle',
'backend-system/core-services/logger',
'backend-system/core-services/permissions',
'backend-system/core-services/permissions-registry',
'backend-system/core-services/plugin-metadata',
'backend-system/core-services/root-config',
'backend-system/core-services/root-health',
+49 -49
View File
@@ -1627,12 +1627,12 @@ __metadata:
linkType: hard
"@babel/runtime-corejs3@npm:^7.22.6":
version: 7.23.1
resolution: "@babel/runtime-corejs3@npm:7.23.1"
version: 7.26.10
resolution: "@babel/runtime-corejs3@npm:7.26.10"
dependencies:
core-js-pure: ^3.30.2
regenerator-runtime: ^0.14.0
checksum: 5d52b0cc8b5d243e67cf29c584d15acdc0c89b64de4a3fe1cb8a83b84b64a5621802e36931f93ca696cb637884abd11c8514615d890a4edf057ec4464f73915d
checksum: 771d986d824fb84503c348d24f71e50c4ff078a2b213412761f0ffc5c83257a2d149caa73db86a70d642fa0e7e57968bbbccc1dd8d0cb21dcca0389d0d39c8bd
languageName: node
linkType: hard
@@ -2961,90 +2961,90 @@ __metadata:
languageName: node
linkType: hard
"@swc/core-darwin-arm64@npm:1.11.8":
version: 1.11.8
resolution: "@swc/core-darwin-arm64@npm:1.11.8"
"@swc/core-darwin-arm64@npm:1.11.9":
version: 1.11.9
resolution: "@swc/core-darwin-arm64@npm:1.11.9"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@swc/core-darwin-x64@npm:1.11.8":
version: 1.11.8
resolution: "@swc/core-darwin-x64@npm:1.11.8"
"@swc/core-darwin-x64@npm:1.11.9":
version: 1.11.9
resolution: "@swc/core-darwin-x64@npm:1.11.9"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@swc/core-linux-arm-gnueabihf@npm:1.11.8":
version: 1.11.8
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.11.8"
"@swc/core-linux-arm-gnueabihf@npm:1.11.9":
version: 1.11.9
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.11.9"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"@swc/core-linux-arm64-gnu@npm:1.11.8":
version: 1.11.8
resolution: "@swc/core-linux-arm64-gnu@npm:1.11.8"
"@swc/core-linux-arm64-gnu@npm:1.11.9":
version: 1.11.9
resolution: "@swc/core-linux-arm64-gnu@npm:1.11.9"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@swc/core-linux-arm64-musl@npm:1.11.8":
version: 1.11.8
resolution: "@swc/core-linux-arm64-musl@npm:1.11.8"
"@swc/core-linux-arm64-musl@npm:1.11.9":
version: 1.11.9
resolution: "@swc/core-linux-arm64-musl@npm:1.11.9"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@swc/core-linux-x64-gnu@npm:1.11.8":
version: 1.11.8
resolution: "@swc/core-linux-x64-gnu@npm:1.11.8"
"@swc/core-linux-x64-gnu@npm:1.11.9":
version: 1.11.9
resolution: "@swc/core-linux-x64-gnu@npm:1.11.9"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@swc/core-linux-x64-musl@npm:1.11.8":
version: 1.11.8
resolution: "@swc/core-linux-x64-musl@npm:1.11.8"
"@swc/core-linux-x64-musl@npm:1.11.9":
version: 1.11.9
resolution: "@swc/core-linux-x64-musl@npm:1.11.9"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@swc/core-win32-arm64-msvc@npm:1.11.8":
version: 1.11.8
resolution: "@swc/core-win32-arm64-msvc@npm:1.11.8"
"@swc/core-win32-arm64-msvc@npm:1.11.9":
version: 1.11.9
resolution: "@swc/core-win32-arm64-msvc@npm:1.11.9"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@swc/core-win32-ia32-msvc@npm:1.11.8":
version: 1.11.8
resolution: "@swc/core-win32-ia32-msvc@npm:1.11.8"
"@swc/core-win32-ia32-msvc@npm:1.11.9":
version: 1.11.9
resolution: "@swc/core-win32-ia32-msvc@npm:1.11.9"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@swc/core-win32-x64-msvc@npm:1.11.8":
version: 1.11.8
resolution: "@swc/core-win32-x64-msvc@npm:1.11.8"
"@swc/core-win32-x64-msvc@npm:1.11.9":
version: 1.11.9
resolution: "@swc/core-win32-x64-msvc@npm:1.11.9"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@swc/core@npm:^1.3.46":
version: 1.11.8
resolution: "@swc/core@npm:1.11.8"
version: 1.11.9
resolution: "@swc/core@npm:1.11.9"
dependencies:
"@swc/core-darwin-arm64": 1.11.8
"@swc/core-darwin-x64": 1.11.8
"@swc/core-linux-arm-gnueabihf": 1.11.8
"@swc/core-linux-arm64-gnu": 1.11.8
"@swc/core-linux-arm64-musl": 1.11.8
"@swc/core-linux-x64-gnu": 1.11.8
"@swc/core-linux-x64-musl": 1.11.8
"@swc/core-win32-arm64-msvc": 1.11.8
"@swc/core-win32-ia32-msvc": 1.11.8
"@swc/core-win32-x64-msvc": 1.11.8
"@swc/core-darwin-arm64": 1.11.9
"@swc/core-darwin-x64": 1.11.9
"@swc/core-linux-arm-gnueabihf": 1.11.9
"@swc/core-linux-arm64-gnu": 1.11.9
"@swc/core-linux-arm64-musl": 1.11.9
"@swc/core-linux-x64-gnu": 1.11.9
"@swc/core-linux-x64-musl": 1.11.9
"@swc/core-win32-arm64-msvc": 1.11.9
"@swc/core-win32-ia32-msvc": 1.11.9
"@swc/core-win32-x64-msvc": 1.11.9
"@swc/counter": ^0.1.3
"@swc/types": ^0.1.19
peerDependencies:
@@ -3073,7 +3073,7 @@ __metadata:
peerDependenciesMeta:
"@swc/helpers":
optional: true
checksum: 53aac457ec10ad931e4a15d14be87d4cb53432d2fc2433244835ceb1396e5560f52155e24eadc67bb964bd675b74eea0d99a7570062cd915b02d0336152bf36c
checksum: 2c89cc4630f569f9b6e2e0dcc29b168d3c14c730d5f04b2923b4eb0d517be327b22bf8b64eb50fb0412e2960f43ab0ccb4e99f6792ec409a9d24e7a2a85fa12f
languageName: node
linkType: hard
@@ -5902,8 +5902,8 @@ __metadata:
linkType: hard
"docusaurus-plugin-openapi-docs@npm:^4.3.0":
version: 4.3.6
resolution: "docusaurus-plugin-openapi-docs@npm:4.3.6"
version: 4.3.7
resolution: "docusaurus-plugin-openapi-docs@npm:4.3.7"
dependencies:
"@apidevtools/json-schema-ref-parser": ^11.5.4
"@redocly/openapi-core": ^1.10.5
@@ -5925,7 +5925,7 @@ __metadata:
"@docusaurus/utils": ^3.5.0
"@docusaurus/utils-validation": ^3.5.0
react: ^16.8.4 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: ed80d72f16cf03fc38cc9c6b9916287c80b4bf289040eacbd5a4bacabeed428a9293be974fb1bbede8595e2288820b7d86ca15aaf21366d043edf7e58c45aeae
checksum: 714d91c40ea00217ef654b690569dc3249a5048b83532da08be0c552e13f42b326cf1262490063519286e2cd30ad1c1bbd08f678d09c7341f945be9d8cd8e864
languageName: node
linkType: hard
+9 -9
View File
@@ -1,11 +1,6 @@
{
"name": "root",
"version": "1.37.0-next.2",
"private": true,
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage"
},
"backstage": {
"cli": {
"new": {
@@ -16,6 +11,11 @@
}
}
},
"private": true,
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage"
},
"workspaces": {
"packages": [
"packages/*",
@@ -51,11 +51,11 @@
"snyk:test": "npx snyk test --yarn-workspaces --strict-out-of-sync=false",
"snyk:test:package": "yarn snyk:test --include",
"start": "yarn workspace example-app start",
"start-backend": "yarn workspace example-backend start",
"start-backend:legacy": "yarn workspace example-backend-legacy start",
"start:lighthouse": "yarn workspaces foreach -A --include example-backend --include example-app --parallel --jobs unlimited -v -i run start",
"start:microsite": "cd microsite/ && yarn start",
"start:next": "yarn workspace example-app-next start",
"start-backend": "yarn workspace example-backend start",
"start-backend:legacy": "yarn workspace example-backend-legacy start",
"storybook": "yarn ./storybook run storybook",
"techdocs-cli": "node scripts/techdocs-cli.js",
"techdocs-cli:dev": "cross-env TECHDOCS_CLI_DEV_MODE=true node scripts/techdocs-cli.js",
@@ -103,9 +103,9 @@
"@material-ui/pickers@^3.3.10": "patch:@material-ui/pickers@npm%3A3.3.11#./.yarn/patches/@material-ui-pickers-npm-3.3.11-1c8f68ea20.patch",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"jest-haste-map@^29.7.0": "patch:jest-haste-map@npm%3A29.7.0#./.yarn/patches/jest-haste-map-npm-29.7.0-e3be419eff.patch",
"ast-types@0.14.2": "patch:ast-types@npm%3A0.14.2#./.yarn/patches/ast-types-npm-0.14.2-43c4ac4b0d.patch",
"ast-types@^0.14.1": "patch:ast-types@npm%3A0.14.2#./.yarn/patches/ast-types-npm-0.14.2-43c4ac4b0d.patch",
"ast-types@0.14.2": "patch:ast-types@npm%3A0.14.2#./.yarn/patches/ast-types-npm-0.14.2-43c4ac4b0d.patch"
"jest-haste-map@^29.7.0": "patch:jest-haste-map@npm%3A29.7.0#./.yarn/patches/jest-haste-map-npm-29.7.0-e3be419eff.patch"
},
"dependencies": {
"@backstage/errors": "workspace:^",
+1 -42
View File
@@ -1,49 +1,8 @@
# Knip report
## Unused dependencies (27)
| Name | Location | Severity |
| :-------------------------------- | :----------- | :------- |
| @backstage/plugin-permission-node | package.json | error |
| @backstage/plugin-auth-node | package.json | error |
| @backstage/config-loader | package.json | error |
| @backstage/cli-common | package.json | error |
| @manypkg/get-packages | package.json | error |
| @backstage/config | package.json | error |
| winston-transport | package.json | error |
| @backstage/types | package.json | error |
| path-to-regexp | package.json | error |
| compression | package.json | error |
| triple-beam | package.json | error |
| node-forge | package.json | error |
| selfsigned | package.json | error |
| minimatch | package.json | error |
| stoppable | package.json | error |
| minimist | package.json | error |
| logform | package.json | error |
| winston | package.json | error |
| cookie | package.json | error |
| helmet | package.json | error |
| lodash | package.json | error |
| morgan | package.json | error |
| luxon | package.json | error |
| cors | package.json | error |
| jose | package.json | error |
| knex | package.json | error |
| uuid | package.json | error |
## Unused devDependencies (10)
## Unused devDependencies (1)
| Name | Location | Severity |
| :-------------------------- | :----------- | :------- |
| @backstage/backend-defaults | package.json | error |
| @types/compression | package.json | error |
| @types/http-errors | package.json | error |
| @types/node-forge | package.json | error |
| @types/stoppable | package.json | error |
| @types/minimist | package.json | error |
| @types/morgan | package.json | error |
| http-errors | package.json | error |
| supertest | package.json | error |
| msw | package.json | error |
+2 -37
View File
@@ -47,48 +47,13 @@
},
"dependencies": {
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/cli-common": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/config-loader": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-permission-node": "workspace:^",
"@backstage/types": "workspace:^",
"@manypkg/get-packages": "^1.1.3",
"compression": "^1.7.4",
"cookie": "^0.7.0",
"cors": "^2.8.5",
"helmet": "^6.0.0",
"jose": "^5.0.0",
"knex": "^3.0.0",
"lodash": "^4.17.21",
"logform": "^2.3.2",
"luxon": "^3.0.0",
"minimatch": "^9.0.0",
"minimist": "^1.2.5",
"morgan": "^1.10.0",
"node-forge": "^1.3.1",
"path-to-regexp": "^8.0.0",
"selfsigned": "^2.0.0",
"stoppable": "^1.1.0",
"triple-beam": "^1.4.1",
"uuid": "^11.0.0",
"winston": "^3.2.1",
"winston-transport": "^4.5.0"
"@backstage/errors": "workspace:^"
},
"devDependencies": {
"@backstage/backend-defaults": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@types/compression": "^1.7.0",
"@types/http-errors": "^2.0.0",
"@types/minimist": "^1.2.0",
"@types/morgan": "^1.9.0",
"@types/node-forge": "^1.3.0",
"@types/stoppable": "^1.1.0",
"http-errors": "^2.0.0",
"msw": "^1.0.0",
"supertest": "^7.0.0"
"@backstage/cli": "workspace:^"
},
"configSchema": "config.d.ts"
}
+5 -15
View File
@@ -1,21 +1,11 @@
# Knip report
## Unused dependencies (6)
## Unused dependencies (2)
| Name | Location | Severity |
| :-------------------- | :----------- | :------- |
| @backstage/cli-common | package.json | error |
| @azure/identity | package.json | error |
| better-sqlite3 | package.json | error |
| isomorphic-git | package.json | error |
| minimist | package.json | error |
| mysql2 | package.json | error |
## Unused devDependencies (1)
| Name | Location | Severity |
| :--------------- | :----------- | :------- |
| @types/stoppable | package.json | error |
| Name | Location | Severity |
| :------------- | :----------- | :------- |
| better-sqlite3 | package.json | error |
| mysql2 | package.json | error |
## Referenced optional peerDependencies (1)
+1 -5
View File
@@ -127,12 +127,10 @@
"@aws-sdk/client-s3": "^3.350.0",
"@aws-sdk/credential-providers": "^3.350.0",
"@aws-sdk/types": "^3.347.0",
"@azure/identity": "^4.0.0",
"@azure/storage-blob": "^12.5.0",
"@backstage/backend-app-api": "workspace:^",
"@backstage/backend-dev-utils": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/cli-common": "workspace:^",
"@backstage/cli-node": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/config-loader": "workspace:^",
@@ -164,7 +162,6 @@
"fs-extra": "^11.2.0",
"git-url-parse": "^15.0.0",
"helmet": "^6.0.0",
"isomorphic-git": "^1.23.0",
"jose": "^5.0.0",
"keyv": "^5.2.1",
"knex": "^3.0.0",
@@ -172,7 +169,6 @@
"logform": "^2.3.2",
"luxon": "^3.0.0",
"minimatch": "^9.0.0",
"minimist": "^1.2.5",
"mysql2": "^3.0.0",
"node-fetch": "^2.7.0",
"node-forge": "^1.3.1",
@@ -201,11 +197,11 @@
"@google-cloud/cloud-sql-connector": "^1.4.0",
"@types/archiver": "^6.0.0",
"@types/base64-stream": "^1.0.2",
"@types/compression": "^1.7.5",
"@types/concat-stream": "^2.0.0",
"@types/http-errors": "^2.0.0",
"@types/node-forge": "^1.3.0",
"@types/pg-format": "^1.0.5",
"@types/stoppable": "^1.1.0",
"@types/yauzl": "^2.10.0",
"aws-sdk-client-mock": "^4.0.0",
"http-errors": "^2.0.0",
@@ -33,7 +33,7 @@ jest.setTimeout(60_000);
describe('PluginTaskManagerImpl', () => {
const addShutdownHook = jest.fn();
const databases = TestDatabases.create({
ids: ['POSTGRES_16', 'POSTGRES_12', 'SQLITE_3'],
ids: ['POSTGRES_17', 'POSTGRES_13', 'SQLITE_3'],
});
beforeAll(async () => {
@@ -39,8 +39,8 @@ describe('PluginTaskSchedulerJanitor', () => {
const databases = TestDatabases.create({
ids: [
/* 'MYSQL_8' not supported yet */
'POSTGRES_16',
'POSTGRES_12',
'POSTGRES_17',
'POSTGRES_13',
'SQLITE_3',
'MYSQL_8',
],
@@ -423,6 +423,58 @@ describe.skip('GerritUrlReader', () => {
describe('search', () => {
const responseBuffer = Buffer.from('Apache License');
const branchAPIUrl =
'https://gerrit.com/projects/app%2Fweb/branches/master';
const branchAPIresponse = fs.readFileSync(
path.resolve(__dirname, '__fixtures__/gerrit/branch-info-response.txt'),
);
const searchUrl =
'https://gerrit.com/gitiles/app/web/+/refs/heads/master/**/catalog-info.yaml';
const etag = '52432507a70b677b5674b019c9a46b2e9f29d0a1';
const treeRecursiveResponse = fs.readFileSync(
path.resolve(
__dirname,
'__fixtures__/gerrit/tree-recursive-response.txt',
),
);
beforeEach(async () => {
worker.use(
rest.get(
'https://gerrit.com/projects/app%2Fweb/branches/master/files/catalog-info.yaml/content',
(_, res, ctx) => {
return res(
ctx.status(200),
ctx.body(Buffer.from('Backstage manifest').toString('base64')),
);
},
),
);
worker.use(
rest.get(
'https://gerrit.com/gitiles/app/web/\\+/refs/heads/master/',
(req, res, ctx) => {
if (
req.url.searchParams.has('format', 'JSON') &&
req.url.searchParams.has('recursive')
) {
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.set('content-disposition', 'attachment'),
ctx.body(treeRecursiveResponse),
);
}
return res(ctx.status(404));
},
),
);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should return a single file when given an exact URL', async () => {
worker.use(
@@ -467,12 +519,53 @@ describe.skip('GerritUrlReader', () => {
expect(data.files.length).toBe(0);
});
it('throws if given URL with wildcard', async () => {
await expect(
gerritProcessor.search(
'https://gerrit.com/web/project/+/refs/heads/master/*.yaml',
),
).rejects.toThrow('Unsupported search pattern URL');
it('reads the wanted files correctly using gitiles.', async () => {
worker.use(
rest.get(branchAPIUrl, (_, res, ctx) => {
return res(ctx.status(200), ctx.body(branchAPIresponse));
}),
);
const response = await gerritProcessor.search(searchUrl);
expect(response.etag).toBe(etag);
expect(response.files.length).toBe(3);
expect(response.files[0].url).toEqual(
'https://gerrit.com/gitiles/app/web/+/refs/heads/master/catalog-info.yaml',
);
expect(response.files[1].url).toEqual(
'https://gerrit.com/gitiles/app/web/+/refs/heads/master/microservices/petstore-api/catalog-info.yaml',
);
expect(response.files[2].url).toEqual(
'https://gerrit.com/gitiles/app/web/+/refs/heads/master/microservices/petstore-consumer/catalog-info.yaml',
);
const docsYaml = await response.files[0].content();
expect(docsYaml.toString()).toBe('Backstage manifest');
});
it('throws NotModifiedError for matching etags.', async () => {
worker.use(
rest.get(branchAPIUrl, (_, res, ctx) => {
return res(ctx.status(200), ctx.body(branchAPIresponse));
}),
);
await expect(gerritProcessor.search(searchUrl, { etag })).rejects.toThrow(
NotModifiedError,
);
});
it('should throw on failures while getting branch info.', async () => {
worker.use(
rest.get(branchAPIUrl, (_, res, ctx) => {
return res(ctx.status(500, 'Error'));
}),
);
await expect(gerritProcessor.search(searchUrl)).rejects.toThrow(Error);
});
});
});
@@ -43,6 +43,8 @@ import {
assertError,
} from '@backstage/errors';
import { ReadTreeResponseFactory, ReaderFactory } from './types';
import { Minimatch } from 'minimatch';
import { getGitilesAuthenticationUrl } from '@backstage/integration';
/**
* Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for files in Gerrit.
@@ -150,35 +152,39 @@ export class GerritUrlReader implements UrlReaderService {
url: string,
options?: UrlReaderServiceSearchOptions,
): Promise<UrlReaderServiceSearchResponse> {
const { pathname } = new URL(url);
const { path } = parseGitilesUrlRef(this.integration.config, url);
if (pathname.match(/[*?]/)) {
throw new Error('Unsupported search pattern URL');
}
if (!path.match(/[*?]/)) {
try {
const data = await this.readUrl(url, options);
try {
const data = await this.readUrl(url, options);
return {
files: [
{
url: url,
content: data.buffer,
lastModifiedAt: data.lastModifiedAt,
},
],
etag: data.etag ?? '',
};
} catch (error) {
assertError(error);
if (error.name === 'NotFoundError') {
return {
files: [],
etag: '',
files: [
{
url: url,
content: data.buffer,
lastModifiedAt: data.lastModifiedAt,
},
],
etag: data.etag ?? '',
};
} catch (error) {
assertError(error);
if (error.name === 'NotFoundError') {
return {
files: [],
etag: '',
};
}
throw error;
}
throw error;
}
const urlRevision = await this.getRevisionForUrl(url, options);
const files = await this.searchFilesFromGitiles(url, options);
return { files, etag: urlRevision };
}
toString() {
@@ -260,4 +266,65 @@ export class GerritUrlReader implements UrlReaderService {
}
return branchInfo.revision;
}
private async searchFilesFromGitiles(
url: string,
options?: UrlReaderServiceReadTreeOptions,
): Promise<UrlReaderServiceSearchResponse['files']> {
const { path, basePath } = parseGitilesUrlRef(this.integration.config, url);
const treeUrl = `${basePath}/?format=JSON&recursive`.replace(
this.integration.config.gitilesBaseUrl,
getGitilesAuthenticationUrl(this.integration.config),
);
const treeResponse = await fetch(treeUrl, {
...getGerritRequestOptions(this.integration.config),
// TODO(freben): The signal cast is there because pre-3.x versions of
// node-fetch have a very slightly deviating AbortSignal type signature.
// The difference does not affect us in practice however. The cast can
// be removed after we support ESM for CLI dependencies and migrate to
// version 3 of node-fetch.
// https://github.com/backstage/backstage/issues/8242
signal: options?.signal as any,
});
if (!treeResponse.ok) {
throw await ResponseError.fromResponse(treeResponse);
}
const res = (await parseGerritJsonResponse(treeResponse as any)) as {
id: string;
entries: { mode: number; type: string; id: string; name: string }[];
};
const matcher = new Minimatch(decodeURIComponent(path).replace(/^\/+/, ''));
const matching = res.entries.filter(
item => item.type === 'blob' && item.name && matcher.match(item.name),
);
return matching.map(item => ({
url: `${basePath}/${item.name}`,
content: async () => {
const apiUrl = getGerritFileContentsApiUrl(
this.integration.config,
`${basePath}/${item.name}`,
);
const response = await fetch(apiUrl, {
method: 'GET',
...getGerritRequestOptions(this.integration.config),
// TODO(freben): The signal cast is there because pre-3.x versions of
// node-fetch have a very slightly deviating AbortSignal type signature.
// The difference does not affect us in practice however. The cast can
// be removed after we support ESM for CLI dependencies and migrate to
// version 3 of node-fetch.
// https://github.com/backstage/backstage/issues/8242
signal: options?.signal as any,
});
const responseBody = await response.text();
return Buffer.from(responseBody, 'base64');
},
}));
}
}
@@ -35,14 +35,6 @@ const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
jest.mock('./git', () => ({
Git: {
fromAuth: () => ({
clone: jest.fn(() => Promise.resolve({})),
}),
},
}));
const giteaProcessor = new GiteaUrlReader(
new GiteaIntegration(
readGiteaConfig(
@@ -35,14 +35,6 @@ const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
jest.mock('./git', () => ({
Git: {
fromAuth: () => ({
clone: jest.fn(() => Promise.resolve({})),
}),
},
}));
const harnessProcessor = new HarnessUrlReader(
new HarnessIntegration(
readHarnessConfig(
@@ -0,0 +1,30 @@
)]}'
{
"id": "d84874ac8e27a8592ece6eb3e61e1fc3cd668348",
"entries": [
{
"mode": 40960,
"type": "blob",
"id": "9abe6322fdb76e3c2d972f12aad46088fa785fbc",
"name": "catalog-info.yaml"
},
{
"mode": 33188,
"type": "blob",
"id": "7a472379c27f165a1472f0c3d455fa3b811585b9",
"name": "README.md"
},
{
"mode": 33199,
"type": "blob",
"id": "1a472379c27f165a1472f0c3d455fa3b811585b9",
"name": "microservices/petstore-api/catalog-info.yaml"
},
{
"mode": 32199,
"type": "blob",
"id": "2a472379c27f165a1472f0c3d455fa3b811585b9",
"name": "microservices/petstore-consumer/catalog-info.yaml"
}
]
}
@@ -1,148 +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 fs from 'fs-extra';
import isomorphicGit, { ProgressCallback, AuthCallback } from 'isomorphic-git';
import http from 'isomorphic-git/http/node';
import { LoggerService } from '@backstage/backend-plugin-api';
/**
* Configure static credential for authentication
*
* @public
*/
export type StaticAuthOptions = {
username?: string;
password?: string;
token?: string;
logger?: LoggerService;
};
/**
* Configure an authentication callback that can provide credentials on demand
*
* @public
*/
export type AuthCallbackOptions = {
onAuth: AuthCallback;
logger?: LoggerService;
};
function isAuthCallbackOptions(
options: StaticAuthOptions | AuthCallbackOptions,
): options is AuthCallbackOptions {
return 'onAuth' in options;
}
/*
provider username password
Azure 'notempty' token
Bitbucket Cloud 'x-token-auth' token
Bitbucket Server username password or token
GitHub 'x-access-token' token
GitLab 'oauth2' token
From : https://isomorphic-git.org/docs/en/onAuth with fix for GitHub
Or token provided as `token` for Bearer auth header
instead of Basic Auth (e.g., Bitbucket Server).
*/
/**
* A convenience wrapper around the `isomorphic-git` library.
*
* @public
*/
export class Git {
private readonly headers: {
[x: string]: string;
};
private constructor(
private readonly config: {
onAuth: AuthCallback;
token?: string;
logger?: LoggerService;
},
) {
this.onAuth = config.onAuth;
this.headers = {
'user-agent': 'git/@isomorphic-git',
...(config.token ? { Authorization: `Bearer ${config.token}` } : {}),
};
}
/** https://isomorphic-git.org/docs/en/clone */
async clone(options: {
url: string;
dir: string;
ref?: string;
depth?: number;
noCheckout?: boolean;
}): Promise<void> {
const { url, dir, ref, depth, noCheckout } = options;
this.config.logger?.info(`Cloning repo {dir=${dir},url=${url}}`);
try {
return await isomorphicGit.clone({
fs,
http,
url,
dir,
ref,
singleBranch: true,
depth: depth ?? 1,
noCheckout,
onProgress: this.onProgressHandler(),
headers: this.headers,
onAuth: this.onAuth,
});
} catch (ex) {
this.config.logger?.error(`Failed to clone repo {dir=${dir},url=${url}}`);
if (ex.data) {
throw new Error(`${ex.message} {data=${JSON.stringify(ex.data)}}`);
}
throw ex;
}
}
private onAuth: AuthCallback;
private onProgressHandler = (): ProgressCallback => {
let currentPhase = '';
return event => {
if (currentPhase !== event.phase) {
currentPhase = event.phase;
this.config.logger?.info(event.phase);
}
const total = event.total
? `${Math.round((event.loaded / event.total) * 100)}%`
: event.loaded;
this.config.logger?.debug(`status={${event.phase},total={${total}}}`);
};
};
static fromAuth = (options: StaticAuthOptions | AuthCallbackOptions) => {
if (isAuthCallbackOptions(options)) {
const { onAuth, logger } = options;
return new Git({ onAuth, logger });
}
const { username, password, token, logger } = options;
return new Git({ onAuth: () => ({ username, password }), token, logger });
};
}
+1 -1
View File
@@ -21,5 +21,5 @@ import { Settings } from 'luxon';
Settings.throwOnInvalid = true;
TestDatabases.setDefaults({
ids: ['MYSQL_8', 'POSTGRES_16', 'POSTGRES_12', 'SQLITE_3'],
ids: ['MYSQL_8', 'POSTGRES_17', 'POSTGRES_13', 'SQLITE_3'],
});
+1 -2
View File
@@ -1,6 +1,6 @@
# Knip report
## Unused dependencies (13)
## Unused dependencies (12)
| Name | Location | Severity |
| :------------------------------------------------- | :----------- | :------- |
@@ -12,7 +12,6 @@
| @gitbeaker/node | package.json | error |
| better-sqlite3 | package.json | error |
| @octokit/rest | package.json | error |
| example-app | package.json | error |
| dockerode | package.json | error |
| mysql2 | package.json | error |
| luxon | package.json | error |
+9 -3
View File
@@ -25,6 +25,7 @@
import Router from 'express-promise-router';
import {
CacheManager,
createLegacyAuthAdapters,
createServiceBuilder,
DatabaseManager,
getRootLogger,
@@ -37,7 +38,7 @@ import {
import { Config } from '@backstage/config';
import healthcheck from './plugins/healthcheck';
import { metricsHandler, metricsInit } from './metrics';
import auth from './plugins/auth';
import authPlugin from './plugins/auth';
import catalog from './plugins/catalog';
import events from './plugins/events';
import kubernetes from './plugins/kubernetes';
@@ -59,10 +60,15 @@ function makeCreateEnv(config: Config) {
const reader = UrlReaders.default({ logger: root, config });
const discovery = HostDiscovery.fromConfig(config);
const tokenManager = ServerTokenManager.fromConfig(config, { logger: root });
const permissions = ServerPermissionClient.fromConfig(config, {
const { auth } = createLegacyAuthAdapters({
auth: undefined,
discovery,
tokenManager,
});
const permissions = ServerPermissionClient.fromConfig(config, {
discovery,
auth,
});
const databaseManager = DatabaseManager.fromConfig(config, { logger: root });
const cacheManager = CacheManager.fromConfig(config);
const identity = DefaultIdentityClient.create({
@@ -137,7 +143,7 @@ async function main() {
apiRouter.use('/catalog', await catalog(catalogEnv));
apiRouter.use('/events', await events(eventsEnv));
apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv));
apiRouter.use('/auth', await auth(authEnv));
apiRouter.use('/auth', await authPlugin(authEnv));
apiRouter.use('/search', await search(searchEnv));
apiRouter.use('/techdocs', await techdocs(techdocsEnv));
apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv));
@@ -478,6 +478,7 @@ export class TestCaches {
// @public
export type TestDatabaseId =
| 'POSTGRES_17'
| 'POSTGRES_16'
| 'POSTGRES_15'
| 'POSTGRES_14'
@@ -28,6 +28,7 @@ export interface Engine {
* @public
*/
export type TestDatabaseId =
| 'POSTGRES_17'
| 'POSTGRES_16'
| 'POSTGRES_15'
| 'POSTGRES_14'
@@ -47,6 +48,13 @@ export type TestDatabaseProperties = {
export const allDatabases: Record<TestDatabaseId, TestDatabaseProperties> =
Object.freeze({
POSTGRES_17: {
name: 'Postgres 17.x',
driver: 'pg',
dockerImageName: getDockerImageForName('postgres:17'),
connectionStringEnvironmentVariableName:
'BACKSTAGE_TEST_DATABASE_POSTGRES17_CONNECTION_STRING',
},
POSTGRES_16: {
name: 'Postgres 16.x',
driver: 'pg',
+134 -12
View File
@@ -8,10 +8,13 @@ import type { CSSProperties } from 'react';
import { Field as Field_2 } from '@base-ui-components/react/field';
import { ForwardRefExoticComponent } from 'react';
import { Input as Input_2 } from '@base-ui-components/react/input';
import { Menu as Menu_2 } from '@base-ui-components/react/menu';
import { default as React_2 } from 'react';
import * as React_3 from 'react';
import { ReactNode } from 'react';
import { RefAttributes } from 'react';
import { ScrollArea as ScrollArea_2 } from '@base-ui-components/react/scroll-area';
import { Tooltip as Tooltip_2 } from '@base-ui-components/react/tooltip';
// @public (undocumented)
export type AlignItems = 'stretch' | 'start' | 'center' | 'end';
@@ -142,22 +145,12 @@ export const buttonPropDefs: {
};
// @public
export interface ButtonProps {
// (undocumented)
export interface ButtonProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
children: React.ReactNode;
// (undocumented)
className?: string;
// (undocumented)
disabled?: boolean;
// (undocumented)
iconEnd?: IconNames;
// (undocumented)
iconStart?: IconNames;
// (undocumented)
size?: ButtonOwnProps['size'];
// (undocumented)
style?: React.CSSProperties;
// (undocumented)
variant?: ButtonOwnProps['variant'];
}
@@ -599,6 +592,40 @@ export type HeightProps = GetPropDefTypes<typeof heightPropDefs>;
// @public (undocumented)
export const Icon: (props: IconProps) => React_2.JSX.Element;
// @public (undocumented)
export const IconButton: React_2.ForwardRefExoticComponent<
IconButtonProps & React_2.RefAttributes<HTMLButtonElement>
>;
// @public (undocumented)
export type IconButtonOwnProps = GetPropDefTypes<typeof iconButtonPropDefs>;
// @public (undocumented)
export const iconButtonPropDefs: {
variant: {
type: 'enum';
values: ('primary' | 'secondary')[];
className: string;
default: 'primary';
responsive: true;
};
size: {
type: 'enum';
values: ('small' | 'medium')[];
className: string;
default: 'medium';
responsive: true;
};
};
// @public
export interface IconButtonProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
icon: IconNames;
size?: IconButtonOwnProps['size'];
variant?: IconButtonOwnProps['variant'];
}
// @public (undocumented)
export const IconContext: Context<IconContextProps>;
@@ -732,6 +759,30 @@ export const marginPropDefs: (spacingValues: string[]) => {
// @public (undocumented)
export type MarginProps = GetPropDefTypes<typeof marginPropDefs>;
// @public (undocumented)
export const Menu: MenuComponent;
// @public (undocumented)
export type MenuComponent = {
Root: typeof Menu_2.Root;
Trigger: typeof Menu_2.Trigger;
Portal: typeof Menu_2.Portal;
Backdrop: typeof Menu_2.Backdrop;
Positioner: typeof Menu_2.Positioner;
Popup: typeof Menu_2.Popup;
Arrow: typeof Menu_2.Arrow;
Item: typeof Menu_2.Item;
Group: typeof Menu_2.Group;
GroupLabel: typeof Menu_2.GroupLabel;
RadioGroup: typeof Menu_2.RadioGroup;
RadioItem: typeof Menu_2.RadioItem;
RadioItemIndicator: typeof Menu_2.RadioItemIndicator;
CheckboxItem: typeof Menu_2.CheckboxItem;
CheckboxItemIndicator: typeof Menu_2.CheckboxItemIndicator;
SubmenuTrigger: typeof Menu_2.SubmenuTrigger;
Separator: typeof Menu_2.Separator;
};
// @public (undocumented)
export type NonStylingPropDef = {
className?: never;
@@ -837,6 +888,38 @@ export type ResponsivePropDef<T = any> = RegularPropDef<T> & {
responsive: true;
};
// @public (undocumented)
export const ScrollArea: {
Root: React_2.ForwardRefExoticComponent<
Omit<
ScrollArea_2.Root.Props & React_2.RefAttributes<HTMLDivElement>,
'ref'
> &
React_2.RefAttributes<HTMLDivElement>
>;
Viewport: React_2.ForwardRefExoticComponent<
Omit<
ScrollArea_2.Viewport.Props & React_2.RefAttributes<HTMLDivElement>,
'ref'
> &
React_2.RefAttributes<HTMLDivElement>
>;
Scrollbar: React_2.ForwardRefExoticComponent<
Omit<
ScrollArea_2.Scrollbar.Props & React_2.RefAttributes<HTMLDivElement>,
'ref'
> &
React_2.RefAttributes<HTMLDivElement>
>;
Thumb: React_2.ForwardRefExoticComponent<
Omit<
ScrollArea_2.Thumb.Props & React_2.RefAttributes<HTMLDivElement>,
'ref'
> &
React_2.RefAttributes<HTMLDivElement>
>;
};
// @public (undocumented)
export type Space =
| '0.5'
@@ -957,6 +1040,19 @@ export interface TextProps {
// (undocumented)
className?: string;
// (undocumented)
color?:
| 'primary'
| 'secondary'
| 'danger'
| 'warning'
| 'success'
| Partial<
Record<
Breakpoint,
'primary' | 'secondary' | 'danger' | 'warning' | 'success'
>
>;
// (undocumented)
style?: CSSProperties;
// (undocumented)
variant?:
@@ -969,6 +1065,32 @@ export interface TextProps {
weight?: 'regular' | 'bold' | Partial<Record<Breakpoint, 'regular' | 'bold'>>;
}
// @public (undocumented)
export const Tooltip: {
Provider: React_2.FC<Tooltip_2.Provider.Props>;
Root: React_2.FC<Tooltip_2.Root.Props>;
Trigger: React_2.ForwardRefExoticComponent<
Omit<Tooltip_2.Trigger.Props & React_2.RefAttributes<any>, 'ref'> &
React_2.RefAttributes<any>
>;
Portal: typeof Tooltip_2.Portal;
Positioner: React_2.ForwardRefExoticComponent<
Omit<
Tooltip_2.Positioner.Props & React_2.RefAttributes<HTMLDivElement>,
'ref'
> &
React_2.RefAttributes<HTMLDivElement>
>;
Popup: React_2.ForwardRefExoticComponent<
Omit<Tooltip_2.Popup.Props & React_2.RefAttributes<HTMLDivElement>, 'ref'> &
React_2.RefAttributes<HTMLDivElement>
>;
Arrow: React_2.ForwardRefExoticComponent<
Omit<Tooltip_2.Arrow.Props & React_2.RefAttributes<HTMLDivElement>, 'ref'> &
React_2.RefAttributes<HTMLDivElement>
>;
};
// @public (undocumented)
export const useIcons: () => IconContextProps;
@@ -57,7 +57,7 @@
}
.canon-Button--variant-secondary {
background-color: var(--canon-bg-elevated);
background-color: var(--canon-bg-surface-1);
box-shadow: inset 0 0 0 1px var(--canon-border);
color: var(--canon-fg-primary);
transition: box-shadow 150ms ease;
+23 -4
View File
@@ -21,13 +21,32 @@ import type { ButtonOwnProps } from './Button.props';
*
* @public
*/
export interface ButtonProps {
export interface ButtonProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
/**
* The size of the button
* @defaultValue 'medium'
*/
size?: ButtonOwnProps['size'];
/**
* The visual variant of the button
* @defaultValue 'primary'
*/
variant?: ButtonOwnProps['variant'];
/**
* The content of the button
*/
children: React.ReactNode;
className?: string;
disabled?: boolean;
/**
* Optional icon to display at the start of the button
*/
iconStart?: IconNames;
/**
* Optional icon to display at the end of the button
*/
iconEnd?: IconNames;
style?: React.CSSProperties;
}
@@ -0,0 +1,41 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { PropDef, GetPropDefTypes } from '../../props/prop-def';
/** @public */
export const iconButtonPropDefs = {
variant: {
type: 'enum',
values: ['primary', 'secondary'],
className: 'canon-Button--variant',
default: 'primary',
responsive: true,
},
size: {
type: 'enum',
values: ['small', 'medium'],
className: 'canon-Button--size',
default: 'medium',
responsive: true,
},
} satisfies {
variant: PropDef<'primary' | 'secondary'>;
size: PropDef<'small' | 'medium'>;
};
/** @public */
export type IconButtonOwnProps = GetPropDefTypes<typeof iconButtonPropDefs>;
@@ -0,0 +1,140 @@
/*
* 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 type { Meta, StoryObj } from '@storybook/react';
import { IconButton } from './IconButton';
import { Flex } from '../Flex';
import { Text } from '../Text';
import { IconButtonProps } from './types';
const meta = {
title: 'Components/IconButton',
component: IconButton,
argTypes: {
size: {
control: 'select',
options: ['small', 'medium'],
},
variant: {
control: 'select',
options: ['primary', 'secondary'],
},
},
args: {
size: 'medium',
variant: 'primary',
},
} satisfies Meta<typeof IconButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Variants: Story = {
args: {
icon: 'cloud',
},
parameters: {
argTypes: {
variant: {
control: false,
},
},
},
render: args => (
<Flex align="center">
<IconButton {...args} variant="primary" />
<IconButton {...args} variant="secondary" />
</Flex>
),
};
export const Sizes: Story = {
args: {
icon: 'cloud',
},
render: args => (
<Flex align="center">
<IconButton {...args} size="medium" />
<IconButton {...args} size="small" />
</Flex>
),
};
export const Disabled: Story = {
args: {
icon: 'cloud',
disabled: true,
},
render: args => (
<Flex direction="row" gap="4">
<IconButton {...args} variant="primary" />
<IconButton {...args} variant="secondary" />
</Flex>
),
};
export const Responsive: Story = {
args: {
icon: 'cloud',
variant: {
initial: 'primary',
sm: 'secondary',
},
size: {
xs: 'small',
sm: 'medium',
},
},
};
const variants: string[] = ['primary', 'secondary'];
export const Playground: Story = {
args: {
icon: 'cloud',
},
render: args => (
<Flex direction="column">
{variants.map(variant => (
<Flex direction="column" key={variant}>
<Text>{variant}</Text>
{['small', 'medium'].map(size => (
<Flex align="center" key={size}>
<IconButton
{...args}
variant={variant as IconButtonProps['variant']}
size={size as IconButtonProps['size']}
/>
<IconButton
{...args}
icon="chevronRight"
variant={variant as IconButtonProps['variant']}
size={size as IconButtonProps['size']}
/>
<IconButton
{...args}
icon="chevronRight"
variant={variant as IconButtonProps['variant']}
size={size as IconButtonProps['size']}
/>
</Flex>
))}
</Flex>
))}
</Flex>
),
};
@@ -0,0 +1,57 @@
/*
* 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, { forwardRef } from 'react';
import { Icon } from '../Icon';
import clsx from 'clsx';
import { useResponsiveValue } from '../../hooks/useResponsiveValue';
import type { IconButtonProps } from './types';
/** @public */
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
(props: IconButtonProps, ref) => {
const {
size = 'medium',
variant = 'primary',
icon,
className,
style,
...rest
} = props;
const responsiveSize = useResponsiveValue(size);
const responsiveVariant = useResponsiveValue(variant);
return (
<button
ref={ref}
className={clsx(
'canon-IconButton',
`canon-IconButton--size-${responsiveSize}`,
`canon-IconButton--variant-${responsiveVariant}`,
className,
)}
style={style}
{...rest}
>
<Icon name={icon} className="canon-IconButton--icon" />
</button>
);
},
);
export default IconButton;
@@ -0,0 +1,20 @@
/*
* 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.
*/
export { IconButton } from './IconButton';
export type { IconButtonProps } from './types';
export { iconButtonPropDefs } from './IconButton.props';
export type { IconButtonOwnProps } from './IconButton.props';
@@ -0,0 +1,105 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.canon-IconButton {
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
user-select: none;
font-family: var(--canon-font-regular);
font-weight: var(--canon-font-weight-bold);
padding: 0;
cursor: pointer;
border-radius: var(--canon-radius-2);
gap: var(--canon-space-1_5);
&:disabled {
cursor: not-allowed;
}
}
.canon-IconButton--variant-primary {
background-color: var(--canon-bg-solid);
color: var(--canon-fg-solid);
transition: background-color 150ms ease, box-shadow 150ms ease;
&:hover {
background-color: var(--canon-bg-solid-hover);
}
&:active {
background-color: var(--canon-bg-solid-pressed);
}
&:focus-visible {
outline: 2px solid var(--canon-ring);
outline-offset: 2px;
}
&:disabled {
background-color: var(--canon-bg-solid-disabled);
color: var(--canon-fg-solid-disabled);
}
}
.canon-IconButton--variant-secondary {
background-color: var(--canon-bg-surface-1);
box-shadow: inset 0 0 0 1px var(--canon-border);
color: var(--canon-fg-primary);
transition: box-shadow 150ms ease;
&:hover {
box-shadow: inset 0 0 0 1px var(--canon-border-hover);
}
&:active {
box-shadow: inset 0 0 0 1px var(--canon-border-pressed);
}
&:focus-visible {
outline: none;
transition: none;
box-shadow: inset 0 0 0 2px var(--canon-ring);
}
&:disabled {
box-shadow: inset 0 0 0 1px var(--canon-border-disabled);
color: var(--canon-fg-disabled);
}
}
.canon-IconButton--size-medium {
font-size: var(--canon-font-size-4);
height: 40px;
width: 40px;
}
.canon-IconButton--size-small {
font-size: var(--canon-font-size-3);
height: 32px;
width: 32px;
}
.canon-IconButton--size-small .canon-IconButton--icon {
width: 1rem;
height: 1rem;
}
.canon-IconButton--size-medium .canon-IconButton--icon {
width: 1.5rem;
height: 1.5rem;
}
@@ -0,0 +1,42 @@
/*
* 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 { IconNames } from '../Icon';
import type { IconButtonOwnProps } from './IconButton.props';
/**
* Properties for {@link IconButton}
*
* @public
*/
export interface IconButtonProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
/**
* The size of the button
* @defaultValue 'medium'
*/
size?: IconButtonOwnProps['size'];
/**
* The visual variant of the button
* @defaultValue 'primary'
*/
variant?: IconButtonOwnProps['variant'];
/**
* Icon to display at the start of the button
*/
icon: IconNames;
}
@@ -18,7 +18,7 @@
border-radius: var(--canon-radius-3);
border: 1px solid var(--canon-border);
padding: 0 var(--canon-space-4);
background-color: var(--canon-bg-elevated);
background-color: var(--canon-bg-surface-1);
font-size: var(--canon-font-size-3);
font-family: var(--canon-font-regular);
font-weight: var(--canon-font-weight-regular);
@@ -0,0 +1,92 @@
/*
* 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 type { Meta, StoryObj } from '@storybook/react';
import { Link } from './Link';
import { Flex } from '../Flex';
import { Text } from '../Text';
const meta = {
title: 'Components/Link',
component: Link,
args: {
children: 'Link',
},
} satisfies Meta<typeof Link>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
href: 'https://canon.backstage.io',
children: 'Sign up for Backstage',
},
};
export const AllVariants: Story = {
args: {
...Default.args,
},
render: args => (
<Flex gap="4" direction="column">
<Link href="https://canon.backstage.io" variant="subtitle" {...args} />
<Link href="https://canon.backstage.io" variant="body" {...args} />
<Link href="https://canon.backstage.io" variant="caption" {...args} />
<Link href="https://canon.backstage.io" variant="label" {...args} />
</Flex>
),
};
export const AllWeights: Story = {
args: {
...Default.args,
},
render: args => (
<Flex gap="4" direction="column">
<Link weight="regular" style={{ maxWidth: '600px' }} {...args} />
<Link weight="bold" style={{ maxWidth: '600px' }} {...args} />
</Flex>
),
};
export const Responsive: Story = {
args: {
variant: {
xs: 'label',
md: 'body',
},
},
};
export const Playground: Story = {
args: {
...Default.args,
},
render: args => (
<Flex gap="4" direction="column">
<Text>Subtitle</Text>
<Link variant="subtitle" style={{ maxWidth: '600px' }} {...args} />
<Text>Body</Text>
<Link variant="body" style={{ maxWidth: '600px' }} {...args} />
<Text>Caption</Text>
<Link variant="caption" style={{ maxWidth: '600px' }} {...args} />
<Text>Label</Text>
<Link variant="label" style={{ maxWidth: '600px' }} {...args} />
</Flex>
),
};
@@ -0,0 +1,55 @@
/*
* 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, { forwardRef } from 'react';
import { useResponsiveValue } from '../../hooks/useResponsiveValue';
import clsx from 'clsx';
import type { LinkProps } from './types';
/** @public */
export const Link = forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => {
const {
children,
variant = 'body',
weight = 'regular',
style,
className,
...restProps
} = props;
// Get the responsive values for the variant and weight
const responsiveVariant = useResponsiveValue(variant);
const responsiveWeight = useResponsiveValue(weight);
return (
<a
ref={ref}
className={clsx(
'canon-Link',
responsiveVariant && `canon-Link--variant-${responsiveVariant}`,
responsiveWeight && `canon-Link--weight-${responsiveWeight}`,
className,
)}
style={style}
{...restProps}
>
{children}
</a>
);
});
Link.displayName = 'Link';
@@ -0,0 +1,18 @@
/*
* 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.
*/
export { Link } from './Link';
export type { LinkProps } from './types';
@@ -0,0 +1,65 @@
/*
* 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.
*/
.canon-Link {
font-family: var(--canon-font-regular);
color: var(--canon-fg-link);
padding: 0;
margin: 0;
cursor: pointer;
text-decoration-line: none;
&:hover {
color: var(--canon-fg-link-hover);
text-decoration-line: underline;
text-decoration-style: solid;
text-decoration-thickness: min(2px, max(1px, 0.05em));
text-underline-offset: calc(0.025em + 2px);
text-decoration-color: color-mix(
in srgb,
var(--canon-fg-link-hover) 30%,
transparent
);
}
}
.canon-Link--variant-body {
font-size: var(--canon-font-size-3);
line-height: 140%;
}
.canon-Link--variant-subtitle {
font-size: var(--canon-font-size-4);
line-height: 140%;
}
.canon-Link--variant-caption {
font-size: var(--canon-font-size-2);
line-height: 140%;
}
.canon-Link--variant-label {
font-size: var(--canon-font-size-1);
line-height: 140%;
}
.canon-Link--weight-regular {
font-weight: var(--canon-font-weight-regular);
}
.canon-Link--weight-bold {
font-weight: var(--canon-font-weight-bold);
}
@@ -0,0 +1,33 @@
/*
* 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 type { CSSProperties, ReactNode } from 'react';
import type { Breakpoint } from '../../types';
/** @public */
export interface LinkProps
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
children: ReactNode;
variant?:
| 'subtitle'
| 'body'
| 'caption'
| 'label'
| Partial<Record<Breakpoint, 'subtitle' | 'body' | 'caption' | 'label'>>;
weight?: 'regular' | 'bold' | Partial<Record<Breakpoint, 'regular' | 'bold'>>;
className?: string;
style?: CSSProperties;
}
@@ -0,0 +1,46 @@
/*
* 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 type { Meta, StoryObj } from '@storybook/react';
import { Menu } from './Menu';
const meta = {
title: 'Components/Menu',
component: Menu.Root,
} satisfies Meta<typeof Menu.Root>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
children: (
<>
<Menu.Trigger>Menu</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item>Item 1</Menu.Item>
<Menu.Item>Item 2</Menu.Item>
<Menu.Item>Item 3</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</>
),
},
};
@@ -0,0 +1,104 @@
.canon-MenuTrigger {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
height: 2.5rem;
padding: 0 0.875rem;
margin: 0;
outline: 0;
border: 1px solid var(--color-gray-200);
border-radius: 0.375rem;
background-color: var(--color-gray-50);
font-family: inherit;
font-size: 1rem;
font-weight: 500;
line-height: 1.5rem;
color: var(--color-gray-900);
user-select: none;
@media (hover: hover) {
&:hover {
background-color: var(--color-gray-100);
}
}
&:active {
background-color: var(--color-gray-100);
}
&[data-popup-open] {
background-color: var(--color-gray-100);
}
&:focus-visible {
outline: 2px solid var(--color-blue);
outline-offset: -1px;
}
}
.canon-MenuPositioner {
outline: 0;
}
.canon-MenuPopup {
box-sizing: border-box;
padding-block: 0.25rem;
border-radius: 0.375rem;
background-color: var(--canon-bg-elevated);
color: var(--color-fg-primary);
transform-origin: var(--transform-origin);
transition: transform 150ms, opacity 150ms;
&[data-starting-style],
&[data-ending-style] {
opacity: 0;
transform: scale(0.9);
}
@media (prefers-color-scheme: light) {
outline: 1px solid var(--color-gray-200);
box-shadow: 0px 10px 15px -3px var(--color-gray-200),
0px 4px 6px -4px var(--color-gray-200);
}
@media (prefers-color-scheme: dark) {
outline: 1px solid var(--color-gray-300);
outline-offset: -1px;
}
}
.canon-MenuItem {
outline: 0;
cursor: default;
user-select: none;
padding-block: 0.5rem;
padding-left: 1rem;
padding-right: 2rem;
display: flex;
font-size: 0.875rem;
line-height: 1rem;
&[data-highlighted] {
z-index: 0;
position: relative;
color: var(--color-gray-50);
}
&[data-highlighted]::before {
content: '';
z-index: -1;
position: absolute;
inset-block: 0;
inset-inline: 0.25rem;
border-radius: 0.25rem;
background-color: var(--color-gray-900);
}
}
.canon-MenuSeparator {
margin: 0.375rem 1rem;
height: 1px;
background-color: var(--color-gray-200);
}
+223
View File
@@ -0,0 +1,223 @@
/*
* 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 { Menu as MenuPrimitive } from '@base-ui-components/react/menu';
import clsx from 'clsx';
import { MenuComponent } from './types';
const MenuTrigger = React.forwardRef<
React.ElementRef<typeof MenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenuPrimitive.Trigger
ref={ref}
className={clsx('canon-MenuTrigger', className)}
{...props}
/>
));
MenuTrigger.displayName = MenuPrimitive.Trigger.displayName;
const MenuBackdrop = React.forwardRef<
React.ElementRef<typeof MenuPrimitive.Backdrop>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Backdrop>
>(({ className, ...props }, ref) => (
<MenuPrimitive.Backdrop
ref={ref}
className={clsx('canon-MenuBackdrop', className)}
{...props}
/>
));
MenuBackdrop.displayName = MenuPrimitive.Backdrop.displayName;
const MenuPositioner = React.forwardRef<
React.ElementRef<typeof MenuPrimitive.Positioner>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Positioner>
>(({ className, ...props }, ref) => (
<MenuPrimitive.Positioner
ref={ref}
className={clsx('canon-MenuPositioner', className)}
{...props}
/>
));
MenuPositioner.displayName = MenuPrimitive.Positioner.displayName;
const MenuPopup = React.forwardRef<
React.ElementRef<typeof MenuPrimitive.Popup>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Popup>
>(({ className, ...props }, ref) => (
<MenuPrimitive.Popup
ref={ref}
className={clsx('canon-MenuPopup', className)}
{...props}
/>
));
MenuPopup.displayName = MenuPrimitive.Popup.displayName;
const MenuArrow = React.forwardRef<
React.ElementRef<typeof MenuPrimitive.Arrow>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Arrow>
>(({ className, ...props }, ref) => (
<MenuPrimitive.Arrow
ref={ref}
className={clsx('canon-MenuArrow', className)}
{...props}
/>
));
MenuArrow.displayName = MenuPrimitive.Arrow.displayName;
const MenuItem = React.forwardRef<
React.ElementRef<typeof MenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<MenuPrimitive.Item
ref={ref}
className={clsx('canon-MenuItem', className)}
{...props}
/>
));
MenuItem.displayName = MenuPrimitive.Item.displayName;
const MenuGroup = React.forwardRef<
React.ElementRef<typeof MenuPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Group>
>(({ className, ...props }, ref) => (
<MenuPrimitive.Group
ref={ref}
className={clsx('canon-MenuGroup', className)}
{...props}
/>
));
MenuGroup.displayName = MenuPrimitive.Group.displayName;
const MenuGroupLabel = React.forwardRef<
React.ElementRef<typeof MenuPrimitive.GroupLabel>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.GroupLabel>
>(({ className, ...props }, ref) => (
<MenuPrimitive.GroupLabel
ref={ref}
className={clsx('canon-MenuGroupLabel', className)}
{...props}
/>
));
MenuGroupLabel.displayName = MenuPrimitive.GroupLabel.displayName;
const MenuRadioGroup = React.forwardRef<
React.ElementRef<typeof MenuPrimitive.RadioGroup>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.RadioGroup>
>(({ className, ...props }, ref) => (
<MenuPrimitive.RadioGroup
ref={ref}
className={clsx('canon-MenuRadioGroup', className)}
{...props}
/>
));
MenuRadioGroup.displayName = MenuPrimitive.RadioGroup.displayName;
const MenuRadioItem = React.forwardRef<
React.ElementRef<typeof MenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.RadioItem>
>(({ className, ...props }, ref) => (
<MenuPrimitive.RadioItem
ref={ref}
className={clsx('canon-MenuRadioItem', className)}
{...props}
/>
));
MenuRadioItem.displayName = MenuPrimitive.RadioItem.displayName;
const MenuRadioItemIndicator = React.forwardRef<
React.ElementRef<typeof MenuPrimitive.RadioItemIndicator>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.RadioItemIndicator>
>(({ className, ...props }, ref) => (
<MenuPrimitive.RadioItemIndicator
ref={ref}
className={clsx('canon-MenuRadioItemIndicator', className)}
{...props}
/>
));
MenuRadioItemIndicator.displayName =
MenuPrimitive.RadioItemIndicator.displayName;
const MenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.CheckboxItem>
>(({ className, ...props }, ref) => (
<MenuPrimitive.CheckboxItem
ref={ref}
className={clsx('canon-MenuCheckboxItem', className)}
{...props}
/>
));
MenuCheckboxItem.displayName = MenuPrimitive.CheckboxItem.displayName;
const MenuCheckboxItemIndicator = React.forwardRef<
React.ElementRef<typeof MenuPrimitive.CheckboxItemIndicator>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.CheckboxItemIndicator>
>(({ className, ...props }, ref) => (
<MenuPrimitive.CheckboxItemIndicator
ref={ref}
className={clsx('canon-MenuCheckboxItemIndicator', className)}
{...props}
/>
));
MenuCheckboxItemIndicator.displayName =
MenuPrimitive.CheckboxItemIndicator.displayName;
const MenuSubmenuTrigger = React.forwardRef<
React.ElementRef<typeof MenuPrimitive.SubmenuTrigger>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.SubmenuTrigger>
>(({ className, ...props }, ref) => (
<MenuPrimitive.SubmenuTrigger
ref={ref}
className={clsx('canon-MenuSubmenuTrigger', className)}
{...props}
/>
));
MenuSubmenuTrigger.displayName = MenuPrimitive.SubmenuTrigger.displayName;
const MenuSeparator = React.forwardRef<
React.ElementRef<typeof MenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenuPrimitive.Separator
ref={ref}
className={clsx('canon-MenuSeparator', className)}
{...props}
/>
));
MenuSeparator.displayName = MenuPrimitive.Separator.displayName;
/** @public */
export const Menu: MenuComponent = {
Root: MenuPrimitive.Root,
Trigger: MenuTrigger,
Portal: MenuPrimitive.Portal,
Backdrop: MenuBackdrop,
Positioner: MenuPositioner,
Popup: MenuPopup,
Arrow: MenuArrow,
Item: MenuItem,
Group: MenuGroup,
GroupLabel: MenuGroupLabel,
RadioGroup: MenuRadioGroup,
RadioItem: MenuRadioItem,
RadioItemIndicator: MenuRadioItemIndicator,
CheckboxItem: MenuCheckboxItem,
CheckboxItemIndicator: MenuCheckboxItemIndicator,
SubmenuTrigger: MenuSubmenuTrigger,
Separator: MenuSeparator,
};
@@ -0,0 +1,18 @@
/*
* 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.
*/
export * from './Menu';
export * from './types';
@@ -0,0 +1,37 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Menu as MenuPrimitive } from '@base-ui-components/react/menu';
/** @public */
export type MenuComponent = {
Root: typeof MenuPrimitive.Root;
Trigger: typeof MenuPrimitive.Trigger;
Portal: typeof MenuPrimitive.Portal;
Backdrop: typeof MenuPrimitive.Backdrop;
Positioner: typeof MenuPrimitive.Positioner;
Popup: typeof MenuPrimitive.Popup;
Arrow: typeof MenuPrimitive.Arrow;
Item: typeof MenuPrimitive.Item;
Group: typeof MenuPrimitive.Group;
GroupLabel: typeof MenuPrimitive.GroupLabel;
RadioGroup: typeof MenuPrimitive.RadioGroup;
RadioItem: typeof MenuPrimitive.RadioItem;
RadioItemIndicator: typeof MenuPrimitive.RadioItemIndicator;
CheckboxItem: typeof MenuPrimitive.CheckboxItem;
CheckboxItemIndicator: typeof MenuPrimitive.CheckboxItemIndicator;
SubmenuTrigger: typeof MenuPrimitive.SubmenuTrigger;
Separator: typeof MenuPrimitive.Separator;
};
@@ -0,0 +1,68 @@
/*
* 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 type { Meta, StoryObj } from '@storybook/react';
import { ScrollArea } from './ScrollArea';
import { Text } from '../Text/Text';
const meta = {
title: 'Components/ScrollArea',
component: ScrollArea.Root,
} satisfies Meta<typeof ScrollArea.Root>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => (
<ScrollArea.Root style={{ width: '24rem', height: '8.5rem' }}>
<ScrollArea.Viewport
style={{ border: '1px solid var(--canon-border)', borderRadius: '4px' }}
>
<div style={{ padding: '0.75rem', paddingRight: '1.5rem' }}>
<Text style={{ paddingBottom: '1rem' }}>
Vernacular architecture is building done outside any academic
tradition, and without professional guidance. It is not a particular
architectural movement or style, but rather a broad category,
encompassing a wide range and variety of building types, with
differing methods of construction, from around the world, both
historical and extant and classical and modern. Vernacular
architecture constitutes 95% of the world's built environment, as
estimated in 1995 by Amos Rapoport, as measured against the small
percentage of new buildings every year designed by architects and
built by engineers.
</Text>
<Text>
This type of architecture usually serves immediate, local needs, is
constrained by the materials available in its particular region and
reflects local traditions and cultural practices. The study of
vernacular architecture does not examine formally schooled
architects, but instead that of the design skills and tradition of
local builders, who were rarely given any attribution for the work.
More recently, vernacular architecture has been examined by
designers and the building industry in an effort to be more energy
conscious with contemporary design and constructionpart of a
broader interest in sustainable design.
</Text>
</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical">
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
),
};
@@ -0,0 +1,67 @@
/*
* 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.
*/
.canon-ScrollAreaRoot {
box-sizing: border-box;
width: 24rem;
height: 8.5rem;
max-width: calc(100vw - 8rem);
}
.canon-ScrollAreaViewport {
height: 100%;
overscroll-behavior: contain;
}
.canon-ScrollAreaContent {
display: flex;
flex-direction: column;
gap: 1rem;
padding-block: 0.75rem;
padding-left: 1rem;
padding-right: 1.5rem;
}
.canon-ScrollAreaScrollbar {
display: flex;
justify-content: center;
background-color: var(--canon-scrollbar);
width: 0.25rem;
border-radius: 0.375rem;
margin: 0.5rem;
opacity: 0;
transition: opacity 150ms 300ms;
&[data-hovering],
&[data-scrolling] {
opacity: 1;
transition-duration: 75ms;
transition-delay: 0ms;
}
&::before {
content: '';
position: absolute;
width: 1.25rem;
height: 100%;
}
}
.canon-ScrollAreaThumb {
width: 100%;
border-radius: inherit;
background-color: var(--canon-scrollbar-thumb);
}
@@ -0,0 +1,75 @@
/*
* 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 { ScrollArea as ScrollAreaPrimitive } from '@base-ui-components/react/scroll-area';
import clsx from 'clsx';
const ScrollAreaRoot = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={clsx('canon-ScrollAreaRoot', className)}
{...props}
/>
));
ScrollAreaRoot.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollAreaViewport = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<ScrollAreaPrimitive.Viewport
ref={ref}
className={clsx('canon-ScrollAreaViewport', className)}
{...props}
/>
));
ScrollAreaViewport.displayName = ScrollAreaPrimitive.Viewport.displayName;
const ScrollAreaScrollbar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Scrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Scrollbar>
>(({ className, ...props }, ref) => (
<ScrollAreaPrimitive.Scrollbar
ref={ref}
className={clsx('canon-ScrollAreaScrollbar', className)}
{...props}
/>
));
ScrollAreaScrollbar.displayName = ScrollAreaPrimitive.Scrollbar.displayName;
const ScrollAreaThumb = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Thumb>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Thumb>
>(({ className, ...props }, ref) => (
<ScrollAreaPrimitive.Thumb
ref={ref}
className={clsx('canon-ScrollAreaThumb', className)}
{...props}
/>
));
ScrollAreaThumb.displayName = ScrollAreaPrimitive.Thumb.displayName;
/** @public */
export const ScrollArea = {
Root: ScrollAreaRoot,
Viewport: ScrollAreaViewport,
Scrollbar: ScrollAreaScrollbar,
Thumb: ScrollAreaThumb,
};
@@ -0,0 +1,17 @@
/*
* 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.
*/
export * from './ScrollArea';
@@ -2,7 +2,7 @@
position: relative;
overflow: auto;
width: 100%;
background-color: var(--canon-bg-elevated);
background-color: var(--canon-bg-surface-1);
border-radius: var(--canon-radius-2);
padding-bottom: var(--canon-space-0_5);
padding-top: var(--canon-space-0_5);
@@ -39,16 +39,16 @@
& .table-cell:first-child {
border-top-left-radius: var(--canon-radius-2);
border-bottom-left-radius: var(--canon-radius-2);
box-shadow: inset 4px 2px 0 0 var(--canon-bg-elevated),
inset 4px -2px 0 0 var(--canon-bg-elevated);
box-shadow: inset 4px 2px 0 0 var(--canon-bg-surface-1),
inset 4px -2px 0 0 var(--canon-bg-surface-1);
padding-left: var(--canon-space-3);
}
& .table-cell:last-child {
border-top-right-radius: var(--canon-radius-2);
border-bottom-right-radius: var(--canon-radius-2);
box-shadow: inset -4px 2px 0 0 var(--canon-bg-elevated),
inset -4px -2px 0 0 var(--canon-bg-elevated);
box-shadow: inset -4px 2px 0 0 var(--canon-bg-surface-1),
inset -4px -2px 0 0 var(--canon-bg-surface-1);
padding-right: var(--canon-space-3);
}
}
@@ -56,6 +56,6 @@
.table-cell {
padding: var(--canon-space-3);
background-color: var(--canon-bg);
box-shadow: inset 0px 2px 0 0 var(--canon-bg-elevated),
inset 0px -2px 0 0 var(--canon-bg-elevated);
box-shadow: inset 0px 2px 0 0 var(--canon-bg-surface-1),
inset 0px -2px 0 0 var(--canon-bg-surface-1);
}
@@ -39,51 +39,49 @@ export const Default: Story = {
};
export const AllVariants: Story = {
render: () => (
<Flex gap="4">
<Text variant="subtitle" style={{ maxWidth: '600px' }}>
A man looks at a painting in a museum and says, Brothers and sisters I
have none, but that man&apos;s father is my father&apos;s son. Who is
in the painting?
</Text>
<Text variant="body" style={{ maxWidth: '600px' }}>
A man looks at a painting in a museum and says, Brothers and sisters I
have none, but that man&apos;s father is my father&apos;s son. Who is
in the painting?
</Text>
<Text variant="caption" style={{ maxWidth: '600px' }}>
A man looks at a painting in a museum and says, Brothers and sisters I
have none, but that man&apos;s father is my father&apos;s son. Who is
in the painting?
</Text>
<Text variant="label" style={{ maxWidth: '600px' }}>
A man looks at a painting in a museum and says, Brothers and sisters I
have none, but that man&apos;s father is my father&apos;s son. Who is
in the painting?
</Text>
args: {
...Default.args,
},
render: args => (
<Flex gap="4" direction="column">
<Text variant="subtitle" {...args} />
<Text variant="body" {...args} />
<Text variant="caption" {...args} />
<Text variant="label" {...args} />
</Flex>
),
};
export const AllWeights: Story = {
render: () => (
<Flex gap="4">
<Text weight="regular" style={{ maxWidth: '600px' }}>
A man looks at a painting in a museum and says, Brothers and sisters I
have none, but that man&apos;s father is my father&apos;s son. Who is
in the painting?
</Text>
<Text weight="bold" style={{ maxWidth: '600px' }}>
A man looks at a painting in a museum and says, Brothers and sisters I
have none, but that man&apos;s father is my father&apos;s son. Who is
in the painting?
</Text>
args: {
...Default.args,
},
render: args => (
<Flex gap="4" direction="column">
<Text weight="regular" {...args} />
<Text weight="bold" {...args} />
</Flex>
),
};
export const AllColors: Story = {
args: {
...Default.args,
},
render: args => (
<Flex gap="4" direction="column">
<Text color="primary" {...args} />
<Text color="secondary" {...args} />
<Text color="danger" {...args} />
<Text color="warning" {...args} />
<Text color="success" {...args} />
</Flex>
),
};
export const Responsive: Story = {
args: {
...Default.args,
variant: {
xs: 'label',
md: 'body',
@@ -93,7 +91,7 @@ export const Responsive: Story = {
export const Playground: Story = {
render: () => (
<Flex>
<Flex gap="4" direction="column">
<Text>Subtitle</Text>
<Text variant="subtitle" style={{ maxWidth: '600px' }}>
A man looks at a painting in a museum and says, Brothers and sisters I
@@ -27,6 +27,7 @@ export const Text = forwardRef<HTMLParagraphElement, TextProps>(
children,
variant = 'body',
weight = 'regular',
color = 'primary',
style,
className,
...restProps
@@ -35,6 +36,7 @@ export const Text = forwardRef<HTMLParagraphElement, TextProps>(
// Get the responsive values for the variant and weight
const responsiveVariant = useResponsiveValue(variant);
const responsiveWeight = useResponsiveValue(weight);
const responsiveColor = useResponsiveValue(color);
return (
<p
@@ -43,6 +45,7 @@ export const Text = forwardRef<HTMLParagraphElement, TextProps>(
'canon-Text',
responsiveVariant && `canon-Text--variant-${responsiveVariant}`,
responsiveWeight && `canon-Text--weight-${responsiveWeight}`,
responsiveColor && `canon-Text--color-${responsiveColor}`,
className,
)}
style={style}
@@ -48,3 +48,23 @@
.canon-Text--weight-bold {
font-weight: var(--canon-font-weight-bold);
}
.canon-Text--color-primary {
color: var(--canon-fg-primary);
}
.canon-Text--color-secondary {
color: var(--canon-fg-secondary);
}
.canon-Text--color-danger {
color: var(--canon-fg-danger);
}
.canon-Text--color-warning {
color: var(--canon-fg-warning);
}
.canon-Text--color-success {
color: var(--canon-fg-success);
}

Some files were not shown because too many files have changed in this diff Show More