Merge branch 'master' into rugvip/depr

This commit is contained in:
Patrik Oldsberg
2022-01-17 13:07:16 +01:00
committed by GitHub
106 changed files with 2791 additions and 1360 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-permission-react': minor
---
**BREAKING**: Update to use renamed request and response types from @backstage/plugin-permission-common.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/core-components': patch
'@backstage/plugin-api-docs': patch
---
chore(deps): bump `react-syntax-highligher` and `swagger-ui-react`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-permission-backend': minor
---
**BREAKING**: Wrap batched requests and responses to /authorize in an envelope object. The latest version of the PermissionClient in @backstage/permission-common uses the new format - as long as the permission-backend is consumed using this client, no other changes are necessary.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-permission-common': minor
---
**BREAKING**: Authorize API request and response types have been updated. The existing `AuthorizeRequest` and `AuthorizeResponse` types now match the entire request and response objects for the /authorize endpoint, and new types `AuthorizeQuery` and `AuthorizeDecision` have been introduced for individual items in the request and response batches respectively.
**BREAKING**: PermissionClient has been updated to use the new request and response format in the latest version of @backstage/permission-backend.
+13
View File
@@ -0,0 +1,13 @@
---
'@backstage/plugin-org': patch
---
For the component `EntityMembersListCard` you can now specify the pageSize. For example:
```tsx
<Grid item xs={12}>
<EntityMembersListCard pageSize={100} />
</Grid>
```
If left empty it will by default use 50.
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/plugin-catalog': patch
'@backstage/plugin-cost-insights': patch
'@backstage/plugin-shortcuts': patch
'@backstage/plugin-user-settings': patch
---
**@backstage/plugin-user-settings:** Hide Header on mobile screens to improve the UI & give more space to the content. Furthermore, the "Pin Sidebar" setting is removed on mobile screens, as the mobile sidebar is always pinned to the bottom.
**Other plugins:** Smaller style adjustments across plugins to improve the UI on mobile devices.
+11
View File
@@ -0,0 +1,11 @@
---
'@backstage/core-components': patch
---
The `Bar` component will now render a `MobileSidebar` instead of the current sidebar on smaller screens. The state of the `MobileSidebar` will be treated as always open.
---
**Add MobileSidebar:** A navigation component, which sticks to the bottom. If there is no content in the Sidebar, it won't be rendered. If there are `children ` in the `Sidebar`, but no `SidebarGroup`s as `children`, it will render all `children` into a default overlay menu, which can be displayed by clicking a menu item. If `SidebarGroup`s are provided, it will render them in the bottom navigation. Additionally, a `MobileSidebarContext`, which wraps the component, will save the selected menu item.
**Add SidebarGroup:** Groups items of the `Sidebar` together. On bigger screens, this won't have any effect at the moment. On smaller screens, it will render a given icon into the `MobileSidebar`. If a route is provided, clicking the `SidebarGroup` in the `MobileSidebar` will route to the page. If no route is provided, it will add a provided icon to the `MobileSidebar` as a menu item & will render the children into an overlay menu, which will be displayed when the menu item is clicked.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Now when entities are deleted, the parent entity state is updated such that it will "heal" accidental deletes on the next refresh round.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-org': patch
---
Fixed typo in `MembersListCard` component
+22
View File
@@ -0,0 +1,22 @@
---
'@backstage/backend-common': patch
'@backstage/config': patch
'@backstage/config-loader': patch
'@backstage/plugin-app-backend': patch
---
Loading of app configurations now reference the `@deprecated` construct from
JSDoc to determine if a property in-use has been deprecated. Users are notified
of deprecated keys in the format:
```txt
The configuration key 'catalog.processors.githubOrg' of app-config.yaml is deprecated and may be removed soon. Configure a GitHub integration instead.
```
When the `withDeprecatedKeys` option is set to `true` in the `process` method
of `loadConfigSchema`, the user will be notified that deprecated keys have been
identified in their app configuration.
The `backend-common` and `plugin-app-backend` packages have been updated to set
`withDeprecatedKeys` to true so that users are notified of deprecated settings
by default.
+12
View File
@@ -0,0 +1,12 @@
---
'@backstage/cli': patch
---
Introduce `--deprecated` option to `config:check` to log all deprecated app configuration properties
```sh
$ yarn backstage-cli config:check --lax --deprecated
config:check --lax --deprecated
Loaded config from app-config.yaml
The configuration key 'catalog.processors.githubOrg' of app-config.yaml is deprecated and may be removed soon. Configure a GitHub integration instead.
```
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-permission-node': minor
---
**BREAKING**: `PolicyAuthorizeRequest` type has been renamed to `PolicyAuthorizeQuery`.
**BREAKING**: Update to use renamed request and response types from @backstage/plugin-permission-common.
+128
View File
@@ -0,0 +1,128 @@
{
"mode": "pre",
"tag": "next",
"initialVersions": {
"example-app": "0.2.60",
"@backstage/app-defaults": "0.1.4",
"example-backend": "0.2.60",
"@backstage/backend-common": "0.10.3",
"@backstage/backend-tasks": "0.1.3",
"@backstage/backend-test-utils": "0.1.13",
"@backstage/catalog-client": "0.5.4",
"@backstage/catalog-model": "0.9.9",
"@backstage/cli": "0.11.0",
"@backstage/cli-common": "0.1.6",
"@backstage/codemods": "0.1.29",
"@backstage/config": "0.1.12",
"@backstage/config-loader": "0.9.2",
"@backstage/core-app-api": "0.4.0",
"@backstage/core-components": "0.8.4",
"@backstage/core-plugin-api": "0.5.0",
"@backstage/create-app": "0.4.12",
"@backstage/dev-utils": "0.2.17",
"e2e-test": "0.2.0",
"embedded-techdocs-app": "0.2.59",
"@backstage/errors": "0.2.0",
"@backstage/integration": "0.7.1",
"@backstage/integration-react": "0.1.18",
"@backstage/search-common": "0.2.1",
"storybook": "0.2.1",
"@techdocs/cli": "0.8.10",
"@backstage/techdocs-common": "0.11.3",
"@backstage/test-utils": "0.2.2",
"@backstage/theme": "0.2.14",
"@backstage/types": "0.1.1",
"@backstage/version-bridge": "0.1.1",
"@backstage/plugin-airbrake": "0.1.0",
"@backstage/plugin-allure": "0.1.11",
"@backstage/plugin-analytics-module-ga": "0.1.6",
"@backstage/plugin-apache-airflow": "0.1.3",
"@backstage/plugin-api-docs": "0.6.22",
"@backstage/plugin-app-backend": "0.3.21",
"@backstage/plugin-auth-backend": "0.6.2",
"@backstage/plugin-azure-devops": "0.1.10",
"@backstage/plugin-azure-devops-backend": "0.3.0",
"@backstage/plugin-azure-devops-common": "0.2.0",
"@backstage/plugin-badges": "0.2.19",
"@backstage/plugin-badges-backend": "0.1.15",
"@backstage/plugin-bazaar": "0.1.9",
"@backstage/plugin-bazaar-backend": "0.1.6",
"@backstage/plugin-bitrise": "0.1.22",
"@backstage/plugin-catalog": "0.7.8",
"@backstage/plugin-catalog-backend": "0.20.0",
"@backstage/plugin-catalog-backend-module-ldap": "0.3.9",
"@backstage/plugin-catalog-backend-module-msgraph": "0.2.12",
"@backstage/plugin-catalog-common": "0.1.0",
"@backstage/plugin-catalog-graph": "0.2.6",
"@backstage/plugin-catalog-graphql": "0.3.0",
"@backstage/plugin-catalog-import": "0.7.9",
"@backstage/plugin-catalog-react": "0.6.11",
"@backstage/plugin-circleci": "0.2.34",
"@backstage/plugin-cloudbuild": "0.2.32",
"@backstage/plugin-code-coverage": "0.1.22",
"@backstage/plugin-code-coverage-backend": "0.1.19",
"@backstage/plugin-config-schema": "0.1.18",
"@backstage/plugin-cost-insights": "0.11.17",
"@backstage/plugin-explore": "0.3.25",
"@backstage/plugin-explore-react": "0.0.10",
"@backstage/plugin-firehydrant": "0.1.12",
"@backstage/plugin-fossa": "0.2.27",
"@backstage/plugin-gcp-projects": "0.3.13",
"@backstage/plugin-git-release-manager": "0.3.8",
"@backstage/plugin-github-actions": "0.4.31",
"@backstage/plugin-github-deployments": "0.1.26",
"@backstage/plugin-gitops-profiles": "0.3.13",
"@backstage/plugin-gocd": "0.1.1",
"@backstage/plugin-graphiql": "0.2.27",
"@backstage/plugin-graphql-backend": "0.1.11",
"@backstage/plugin-home": "0.4.10",
"@backstage/plugin-ilert": "0.1.21",
"@backstage/plugin-jenkins": "0.5.17",
"@backstage/plugin-jenkins-backend": "0.1.10",
"@backstage/plugin-kafka": "0.2.25",
"@backstage/plugin-kafka-backend": "0.2.14",
"@backstage/plugin-kubernetes": "0.5.4",
"@backstage/plugin-kubernetes-backend": "0.4.3",
"@backstage/plugin-kubernetes-common": "0.2.1",
"@backstage/plugin-lighthouse": "0.2.34",
"@backstage/plugin-newrelic": "0.3.13",
"@backstage/plugin-newrelic-dashboard": "0.1.3",
"@backstage/plugin-org": "0.3.34",
"@backstage/plugin-pagerduty": "0.3.22",
"@backstage/plugin-permission-backend": "0.3.0",
"@backstage/plugin-permission-common": "0.3.1",
"@backstage/plugin-permission-node": "0.3.0",
"@backstage/plugin-permission-react": "0.2.2",
"@backstage/plugin-proxy-backend": "0.2.15",
"@backstage/plugin-rollbar": "0.3.23",
"@backstage/plugin-rollbar-backend": "0.1.18",
"@backstage/plugin-scaffolder": "0.11.18",
"@backstage/plugin-scaffolder-backend": "0.15.20",
"@backstage/plugin-scaffolder-backend-module-cookiecutter": "0.1.8",
"@backstage/plugin-scaffolder-backend-module-rails": "0.2.3",
"@backstage/plugin-scaffolder-backend-module-yeoman": "0.1.2",
"@backstage/plugin-scaffolder-common": "0.1.2",
"@backstage/plugin-search": "0.5.5",
"@backstage/plugin-search-backend": "0.3.0",
"@backstage/plugin-search-backend-module-elasticsearch": "0.0.7",
"@backstage/plugin-search-backend-module-pg": "0.2.3",
"@backstage/plugin-search-backend-node": "0.4.4",
"@backstage/plugin-sentry": "0.3.33",
"@backstage/plugin-shortcuts": "0.1.19",
"@backstage/plugin-sonarqube": "0.2.12",
"@backstage/plugin-splunk-on-call": "0.3.19",
"@backstage/plugin-tech-insights": "0.1.5",
"@backstage/plugin-tech-insights-backend": "0.1.5",
"@backstage/plugin-tech-insights-backend-module-jsonfc": "0.1.5",
"@backstage/plugin-tech-insights-common": "0.2.1",
"@backstage/plugin-tech-insights-node": "0.1.2",
"@backstage/plugin-tech-radar": "0.5.2",
"@backstage/plugin-techdocs": "0.12.14",
"@backstage/plugin-techdocs-backend": "0.12.3",
"@backstage/plugin-todo": "0.1.18",
"@backstage/plugin-todo-backend": "0.1.18",
"@backstage/plugin-user-settings": "0.3.16",
"@backstage/plugin-xcmetrics": "0.2.15"
},
"changesets": []
}
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
use lighter color for block quotes and horizontal rulers
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-api-docs': patch
---
Using an explicitly empty string for the url argument ensures that the swagger UI does not run into undefined errors.
+51
View File
@@ -0,0 +1,51 @@
---
'@backstage/create-app': patch
---
You can now add `SidebarGroup`s to the current `Sidebar`. This will not affect how the current sidebar is displayed, but allows a customization on how the `MobileSidebar` on smaller screens will look like. A `SidebarGroup` will be displayed with the given icon in the `MobileSidebar`.
A `SidebarGroup` can either link to an existing page (e.g. `/search` or `/settings`) or wrap components, which will be displayed in a full-screen overlay menu (e.g. `Menu`).
```diff
<Sidebar>
<SidebarLogo />
+ <SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
<SidebarSearchModal />
+ </SidebarGroup>
<SidebarDivider />
+ <SidebarGroup label="Menu" icon={<MenuIcon />}>
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
<SidebarDivider />
<SidebarScrollWrapper>
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
</SidebarScrollWrapper>
+ </SidebarGroup>
<SidebarSpace />
<SidebarDivider />
+ <SidebarGroup
+ label="Settings"
+ icon={<UserSettingsSignInAvatar />}
+ to="/settings"
+ >
<SidebarSettings />
+ </SidebarGroup>
</Sidebar>
```
Additionally, you can order the groups differently in the `MobileSidebar` than in the usual `Sidebar` simply by giving a group a priority. The groups will be displayed in descending order from left to right.
```diff
<SidebarGroup
label="Settings"
icon={<UserSettingsSignInAvatar />}
to="/settings"
+ priority={1}
>
<SidebarSettings />
</SidebarGroup>
```
If you decide against adding `SidebarGroup`s to your `Sidebar` the `MobileSidebar` will contain one default menu item, which will open a full-screen overlay menu displaying all the content of the current `Sidebar`.
More information on the `SidebarGroup` & the `MobileSidebar` component can be found in the changeset for the `core-components`.
+6 -1
View File
@@ -192,7 +192,12 @@ jobs:
# Publishes current version of packages that are not already present in the registry
- name: publish
run: yarn lerna -- publish from-package --yes
run: |
if [ -f ".changeset/pre.json" ]; then
yarn lerna -- publish from-package --yes --dist-tag next
else
yarn lerna -- publish from-package --yes
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+1 -1
View File
@@ -5,7 +5,7 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
version: 0.1.2
version: 0.1.3
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application.
+62 -9
View File
@@ -3,10 +3,10 @@
{{- $lighthouseUrl := urlParse .Values.appConfig.lighthouse.baseUrl}}
{{/* Determine the api type for the ingress */}}
{{- if lt .Capabilities.KubeVersion.Minor "19" }}
apiVersion: networking.k8s.io/v1beta1
{{- else if ge .Capabilities.KubeVersion.Minor "19" }}
{{- if .Capabilities.APIVersions.Has "networking.k8s.io/v1" }}
apiVersion: networking.k8s.io/v1
{{- else -}}
apiVersion: networking.k8s.io/v1beta1
{{- end }}
kind: Ingress
metadata:
@@ -29,31 +29,60 @@ spec:
- {{ $frontendUrl.host }}
- {{ $backendUrl.host }}
- {{ $lighthouseUrl.host }}
rules:
- host: {{ $frontendUrl.host }}
http:
paths:
- path: /
{{- if .Capabilities.APIVersions.Has "networking.k8s.io/v1" }}
pathType: Prefix
{{- end }}
backend:
{{- if .Capabilities.APIVersions.Has "networking.k8s.io/v1" }}
service:
name: {{ include "frontend.serviceName" . }}
port:
number: 80
{{- else -}}
serviceName: {{ include "frontend.serviceName" . }}
servicePort: 80
{{/* Route the backend inside the same hostname as the frontend when they are the same */}}
{{- if eq $frontendUrl.host $backendUrl.host}}
{{- end }}
{{/* Route the backend inside the same hostname as the frontend when they are the same */}}
{{- if eq $frontendUrl.host $backendUrl.host}}
- path: /api/
{{- if .Capabilities.APIVersions.Has "networking.k8s.io/v1" }}
pathType: Prefix
{{- end }}
backend:
{{- if .Capabilities.APIVersions.Has "networking.k8s.io/v1" }}
service:
name: {{ include "backend.serviceName" . }}
port:
number: 80
{{- else -}}
serviceName: {{ include "backend.serviceName" . }}
servicePort: 80
{{/* Route the backend through a different host */}}
{{- else -}}
{{- end }}
{{/* Route the backend through a different host */}}
{{- else -}}
- host: {{ $backendUrl.host }}
http:
paths:
- path: {{ $backendUrl.path | default "/" }}
{{- if .Capabilities.APIVersions.Has "networking.k8s.io/v1" }}
pathType: Prefix
{{- end }}
backend:
{{- if .Capabilities.APIVersions.Has "networking.k8s.io/v1" }}
service:
name: {{ include "frontend.serviceName" . }}
port:
number: 80
{{- else -}}
serviceName: {{ include "backend.serviceName" . }}
servicePort: 80
{{- end }}
{{- end }}
{{- end }}
{{/* Route lighthouse through a different host */}}
{{- if not ( eq $frontendUrl.host $lighthouseUrl.host ) }}
@@ -61,13 +90,27 @@ spec:
http:
paths:
- path: {{ $lighthouseUrl.path | default "/" }}
{{- if .Capabilities.APIVersions.Has "networking.k8s.io/v1" }}
pathType: Prefix
{{- end }}
backend:
{{- if .Capabilities.APIVersions.Has "networking.k8s.io/v1" }}
service:
name: {{ include "lighthouse.serviceName" . }}
port:
number: 80
{{- else -}}
serviceName: {{ include "lighthouse.serviceName" . }}
servicePort: 80
{{- end }}
{{- else }}
{{/* Route lighthouse by path with re-write rules when it is hosted under the same hostname */}}
---
{{- if .Capabilities.APIVersions.Has "networking.k8s.io/v1" }}
apiVersion: networking.k8s.io/v1
{{- else -}}
apiVersion: networking.k8s.io/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ include "backstage.fullname" . }}-ingress-lighthouse
@@ -92,7 +135,17 @@ spec:
http:
paths:
- path: {{$lighthouseUrl.path}}(/|$)(.*)
{{- if .Capabilities.APIVersions.Has "networking.k8s.io/v1" }}
pathType: Prefix
{{- end }}
backend:
{{- if .Capabilities.APIVersions.Has "networking.k8s.io/v1" }}
service:
name: {{ include "lighthouse.serviceName" . }}
port:
number: 80
{{- else -}}
serviceName: {{ include "lighthouse.serviceName" . }}
servicePort: 80
{{- end }}
{{- end }}
@@ -727,7 +727,7 @@ filtering templates, and should ideally match the Component
You can find out more about the `parameters` key
[here](../software-templates/writing-templates.md)
### `spec.steps` [optional]
### `spec.steps` [required]
You can find out more about the `steps` key
[here](../software-templates/writing-templates.md)
@@ -1266,6 +1266,10 @@ shape, this kind has the following structure.
Exactly equal to `backstage.io/v1alpha1` and `Location`, respectively.
### `spec` [required]
The `spec` field is required. The minimal spec should be an empty object.
### `spec.type` [optional]
The single location type, that's common to the targets specified in the spec. If
@@ -90,3 +90,29 @@ extension to `.icon.svg`. For example:
```ts
import InternalToolIcon from './internal-tool.icon.svg';
```
On mobile devices the `Sidebar` is displayed at the bottom of the screen. For
customizing the experience you can group `SidebarItems` in a `SidebarGroup`
(Example 1) or create a `SidebarGroup` with a link (Example 2). All
`SidebarGroup`s are displayed in the bottom navigation with an icon.
```ts
// Example 1
<SidebarGroup icon={<MenuIcon />} label="Menu">
...
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
...
<SidebarGroup />
```
```ts
// Example 2
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
...
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
...
<SidebarGroup />
```
If no `SidebarGroup` is provided a default menu will display the `Sidebar`
content.
+13
View File
@@ -26,3 +26,16 @@ catalog:
```
Note the `s3-discovery` type, as this is not a regular `url` processor.
As this processor is not one of the default providers, you will also need to add
the below to `packages/backend/src/plugins/catalog.ts`:
```ts
/* packages/backend/src/plugins/catalog.ts */
import { AwsS3DiscoveryProcessor } from '@backstage/plugin-catalog-backend';
const builder = await CatalogBuilder.create(env);
/** ... other processors ... */
builder.addProcessor(new AwsS3DiscoveryProcessor(env.reader));
```
+1
View File
@@ -556,6 +556,7 @@ Options:
--package &lt;name&gt; Only load config schema that applies to the given package
--lax Do not require environment variables to be set
--frontend Only validate the frontend configuration
--deprecated List all deprecated configuration settings
--config &lt;path&gt; Config files to load instead of app-config.yaml (default: [])
-h, --help display help for command
```
+10 -10
View File
@@ -202,14 +202,14 @@ with a smaller collection of plugins.
### Plugin Packages
A typical plugin consists of up to five packages, two frontend ones, two
backend, and one isomorphic packages. All packages within the plugin must share
a common prefix, typically of the form `@<scope>/plugin-<plugin-id>`, but
backend, and one isomorphic package. All packages within the plugin must share a
common prefix, typically of the form `@<scope>/plugin-<plugin-id>`, but
alternatives like `backstage-plugin-<plugin-id>` or
`@scope/backstage-plugin-<plugin-id>` are also valid. Along with this prefix,
each of the packages have their own unique suffix that denotes their role. In
addition to these five plugin packages it's also possible for to a plugin to
have additional frontend and backend modules that can be installed to enable
optional features. For a full list of suffixes and their roles, see the
addition to these five plugin packages it's also possible for a plugin to have
additional frontend and backend modules that can be installed to enable optional
features. For a full list of suffixes and their roles, see the
[Plugin Package Structure ADR](../architecture-decisions/adr011-plugin-package-structure.md).
The `-react`, `-common`, and `-node` plugin packages together form the external
@@ -230,7 +230,7 @@ The frontend packages are grouped into two main groups. The first one is
as well as provide a foundation for the plugin libraries to rely upon.
The second group is the rest of the shared packages, further divided into
"Frontend Plugin Core" and "Frontend Libraries". The core packages that are
"Frontend Plugin Core" and "Frontend Libraries". The core packages are
considered particularly stable and form the core of the frontend framework.
Their most important role is to form the boundary around each plugin and provide
a set of tools that helps you combine a collection of plugins into a running
@@ -246,10 +246,10 @@ however likely to change in the future.
### Common Packages
The common packages are the packages are effectively depended on by all other
pages. This is a much smaller set of packages but they are also very pervasive.
Because the common packages are isomorphic and must execute both in the frontend
and backend, they are never allowed to depend on any of the frontend of backend
The common packages are the packages effectively depended on by all other pages.
This is a much smaller set of packages but they are also very pervasive. Because
the common packages are isomorphic and must execute both in the frontend and
backend, they are never allowed to depend on any of the frontend of backend
packages.
The Backstage CLI is in a category of its own and is depended on by virtually
+55 -74
View File
@@ -41,8 +41,8 @@ If you have specific questions about the roadmap, please create an
The Backstage roadmap lays out both [“whats next”](#whats-next) and
[“future work”](#future-work). With "next" we mean features planned for release
within the ongoing quarter starting in July until September 2021 included. With
"future" we mean features in the radar, but not yet scheduled.
within the ongoing quarter from January through March 2022. With "future" we
mean features in the radar, but not yet scheduled.
The long-term roadmap (12 - 36 months) is not detailed in the public roadmap.
Third-party contributions are also not currently included in the roadmap. Let us
@@ -61,92 +61,57 @@ The feature set below is planned for the ongoing quarter, and grouped by theme.
The list order doesnt necessarily reflect priority, and the development/release
cycle will vary based on maintainer schedules.
### Backstage Core
### Backstage 1.0 (and following versions)
The following features are planned for release:
During the first quarter of 2022, we plan to finalize and release version 1.0 of
the Backstage platform (defined by the Core,
[Catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview),
[Scaffolder](https://backstage.io/docs/features/software-templates/software-templates-index)
and [TechDocs](https://backstage.io/docs/features/techdocs/techdocs-overview)).
Included as part of this milestone:
- **Improved responsiveness:** Check out the
[RFC here](https://github.com/backstage/backstage/issues/6318) for further
details on how to improve the responsiveness for Backstage's UI.
- Deciding on the cadence of minor/weekly/daily releases to provide clarity on
the frequency and expectations for future versions of the platform and its
defining modules.
- Establish the support model to set the expectations from the adopters in their
respective use cases.
### Software Templates
### Backstage Security Audit
The following features are planned for release::
This initiative is the first of a broader Security Strategy for Backstage. The
purpose of the Security Audit is to involve third-party companies in auditing
the platform and highlighting potential vulnerabilities. The benefit for the
adopters is clear: We want Backstage to be as secure as possible, and we want to
make it reliable through a specific initiative. This initiative in particular is
done together, and with the support of, the
[Cloud Native Computing Foundation (CNCF)](https://www.cncf.io/).
- **Re-creation/resubmission in case of failure:** Speed up productivity by
allowing developers to relaunch a project after a failure or any unexpected
problem. In the current version, this task requires retyping and a full
re-creation from scratch.
- **Performance and usability improvements for contributors:** Reach a relevant
improvement in templating's performance through the replacement of
[handlebars](https://handlebarsjs.com/). Other replacements will be considered
as part of this task (possibly
[cookiecutter](https://cookiecutter.readthedocs.io/)) for easier software
template creation, allowing more contributors to reach their goals without
having to learn new tooling.
- **Improved extensibility through inclusion:** Make software templates more
maintainable and extensible by adding `$include` support for parameters.
- **Authenticated job creation:** Created jobs will be able to run with an
authenticated user with all actions tracked for future consumption and
evidence. Track users creating jobs and make “jobs created by me” reporting
available.
### Moving to Incubation in CNCF
### Software Catalog
The following features are planned for release:
- **Request For Comments (RFC) for composability improvements (routing):**
Enable plugins to be auto-added and make plugin installation and upgrades
easier for all Backstage users. This includes information card layouts, entity
pages containing content and hooking the external header, considering the
support of a separate deployment, and configuration for plugins.
- **Removing duplicated entities in catalog:** As any adopter knows, a software
catalog can contain thousands or more entities and it is very important to
avoid duplications in naming to prevent failures. With this development task,
two entities with the same name won't be allowed as described
[here](https://github.com/backstage/backstage/issues/4760).
- **Connecting identity to ownership to prepare for role-based access control
([RBAC](https://en.wikipedia.org/wiki/Role-based_access_control)):** This is a
first step to supporting RBAC for the software catalog (see the
[future work section](#future-work) for further details). Provide each entity
within the software catalog with a recognized owner.
- **Catalog performance improvements through improved caching:** Fix the
performance gaps in the catalog processor, which currently doesnt have a
strong caching mechanism. The current version often requires fetching a
relevant amount of data, especially at scale.
The progress of the request can be seen
[here](https://github.com/cncf/toc/pull/717).
## Future work
The following feature list doesnt represent a commitment to develop and the
list order doesnt reflect any priority or importance. But these features are on
The following feature list doesnt represent a commitment to develop, and the
list order doesnt reflect any priority or importance, but these features are on
the maintainers radar, with clear interest expressed by the community.
- **Backend Services:** To better scale and maintain the Backstage instances, a
backend layer of services is planned to be introduced as part of the software
architecture. This layer of backend services will help in decoupling the
various modules (e.g. Catalog and Scaffolder) from the frontend experience.
- **Security Plan (and Strategy):** The purpose of the Security Strategy is to
move another step along the path to maturing the platform, setting the
expectations of any adopters from a security standpoint.
- **Search GA:**.
- **[GraphQL](https://graphql.org/) support:** Introduce the ability to query
Backstage backend services with a standard query language for APIs.
- **Telemetry:** To efficiently generate logging and metrics in such a way that
adopters can get insights so that Backstage can be monitored and improved.
- **Improved UX design:** Provide a better Backstage user experience through
visual guidelines and templates, especially navigation across plug-ins and
portal functionalities.
- **Catalog composability (routing):** Follow up development after the RFC
planned for the ongoing quarter (see [whats next](#whats-next) for further
details).
- **Catalog-import improvements:** Provide a faster (scalability) and better
(more features like move/rename) way to import entities into the Software
Catalog. Importing items in the Software Catalog is crucial for creating a
Backstage proof-of-concept or testing/planning for broader organizational
adoption. This enhancement better supports getting developers to use Backstage
with less effort and customization.
- **Catalog improvements:** Add pagination and sourcing to Software Catalog.
- **[GraphQL](https://graphql.org/) support:** Introduce the ability to query
Backstage backend services with a standard query language for APIs.
- **Software templates performance improvements through decoupling a separate
worker:** Improve performance through decoupling resource-consuming services
and making them asynchronous. In the current version, project auto-creation
through the Software Templating system can consume a lot of resources and
bottleneck many concurrent projects created simultaneously.
- **API discovery and documentation:** Add better support for the
[gRPC](https://grpc.io/).
- **TechDocs GA release:** Work toward enhancements necessary to get TechDocs to
general availability. Check out the
[milestone here](https://github.com/backstage/backstage/milestone/30) for
further details.
## Completed milestones
@@ -177,3 +142,19 @@ Read more about the completed (and released) features for reference.
- Support auth providers: Google, Okta, GitHub, GitLab,
[auth0](https://github.com/backstage/backstage/pull/1611),
[AWS](https://github.com/backstage/backstage/pull/1990)
- [Donate Backstage to the CNCF 🎉](https://backstage.io/blog/2020/09/23/backstage-cncf-sandbox)
- [TechDocs v1](https://backstage.io/blog/2020/09/08/announcing-tech-docs)
- [Plugin marketplace](https://backstage.io/plugins)
- [Improved and move documentation to backstage.io](https://backstage.io/docs/overview/what-is-backstage)
- [Backstage Software Catalog (alpha)](https://backstage.io/blog/2020/06/22/backstage-service-catalog-alpha)
- [Backstage Software Templates (beta)](https://backstage.io/blog/2021/07/26/software-templates-are-now-in-beta)
- [Make it possible to add custom auth providers](https://backstage.io/blog/2020/07/01/how-to-enable-authentication-in-backstage-using-passport)
- [TechDocs v0](https://github.com/backstage/backstage/milestone/15)
- CI plugins: CircleCI, Jenkins, GitHub Actions and TravisCI
- [Service API documentation](https://github.com/backstage/backstage/pull/1737)
- Backstage Software Catalog can read from: GitHub, GitLab,
[Bitbucket](https://github.com/backstage/backstage/pull/1938)
- Support auth providers: Google, Okta, GitHub, GitLab,
[auth0](https://github.com/backstage/backstage/pull/1611),
[AWS](https://github.com/backstage/backstage/pull/1990)
-154
View File
@@ -522,157 +522,3 @@ clarify intent. Refer to the following table to formulate the new name:
| Entity Overview Card | \*Card | Entity\*Card | EntitySentryCard, EntityPagerDutyCard |
| Entity Conditional | isPluginApplicableToEntity | is\*Available | isPagerDutyAvailable, isJenkinsAvailable |
| Plugin Instance | plugin | \*Plugin | jenkinsPlugin, catalogPlugin |
## Porting Existing Apps
The first step of porting any app is to replace the root `Routes` component with
`FlatRoutes` from `@backstage/core-app-api`. As opposed to the `Routes`
component, `FlatRoutes` only considers the first level of `Route` components in
its children, and provides any additional children to the outlet of the route.
It also removes the need to append `"/*"` to paths, as it is added
automatically.
```diff
const AppRoutes = () => (
- <Routes>
+ <FlatRoutes>
...
- <Route path="/docs/*" element={<DocsRouter />} />
+ <Route path="/docs" element={<DocsRouter />} />
...
- </Routes>
+ </FlatRoutes>
);
```
The next step should be to switch from using `EntityPageLayout` to
`EntityLayout`, as this can also be done without waiting for plugins to be
ported. You should also replace the top-level `Router` from the catalog plugin
with the separate `CatalogIndexPage` and `CatalogEntityPage` extensions that
have been added to the catalog:
```diff
-<Route
- path={`${catalogRouteRef.path}/*`}
- element={<CatalogRouter EntityPage={EntityPage} />}
-/>
+<Route path="/catalog" element={<CatalogIndexPage />} />
+<Route
+ path="/catalog/:namespace/:kind/:name"
+ element={<CatalogEntityPage />}
+>
+ <EntityPage />
+</Route>
```
At that point you should flatten out the element tree as much as possible in the
app, removing any intermediate components. At the top level this should usually
be straightforward, but when reaching the catalog entity pages you may need to
wait for some plugins to be migrated. This is because it is no longer possible
to pass in the selected entity through component props, and it should be picked
up from context inside the plugin instead. See the sections below for how to
carry out migrations of some common entity page patterns.
Once the app element tree doesn't contain any intermediate components, and all
plugin imports have been switched to extensions rather than plain components,
the app has been fully ported.
### Switching from EntityPageLayout to EntityLayout
The existing `EntityPageLayout` is replaced by the new `EntityLayout` component,
which has a slightly different pattern for expressing the contents and paths.
Porting from the old to the new API is just a matter of moving some things
around. For example, given the following existing code:
```tsx
<EntityPageLayout>
<EntityPageLayout.Content
path="/"
title="Overview"
element={<ComponentOverviewContent entity={entity} />}
/>
<EntityPageLayout.Content
path="/sentry"
title="Sentry"
element={<SentryRouter entity={entity} />}
/>
<EntityPageLayout.Content
path="/kubernetes/*"
title="Kubernetes"
element={<KubernetesRouter entity={entity} />}
/>
</EntityPageLayout>
```
It would be ported to this:
```tsx
<EntityLayout>
<EntityLayout.Route path="/" title="Overview">
<ComponentOverviewContent entity={entity} />
</EntityLayout.Route>
<EntityLayout.Route path="/sentry" title="Sentry">
<SentryRouter entity={entity} />
</EntityLayout.Route>
<EntityLayout.Route path="/kubernetes" title="Kubernetes">
<KubernetesRouter entity={entity} />
</EntityLayout.Route>
</EntityLayout>
```
In addition to the renaming, the `element` prop has been moved to `children`.
Also note that the `/*` suffix has been removed from the `"/kubernetes"` path,
as it's now added automatically.
Usage of the `EntityLayout` component is required to be able to properly
discover routes, and so it is required to apply this change before you can start
using routable entity content extensions from plugins.
### Porting Entity Pages
The established pattern in the app is to use custom components in order to
select what plugin components to render for a given entity. The new
`EntitySwitch` component introduced above is what is intended to replace this
pattern, now that the entire app needs to be rendered as a single element tree.
For example, given the following existing code:
```tsx
export const EntityPage = () => {
const { entity } = useEntity();
switch (entity?.kind?.toLowerCase()) {
case 'component':
return <ComponentEntityPage entity={entity} />;
case 'api':
return <ApiEntityPage entity={entity} />;
case 'group':
return <GroupEntityPage entity={entity} />;
case 'user':
return <UserEntityPage entity={entity} />;
default:
return <DefaultEntityPage entity={entity} />;
}
};
```
It would be migrated to this:
```tsx
export const entityPage = (
<EntitySwitch>
<EntitySwitch.Case if={isKind('component')} children={componentPage} />
<EntitySwitch.Case if={isKind('api')} children={apiPage} />
<EntitySwitch.Case if={isKind('group')} children={groupPage} />
<EntitySwitch.Case if={isKind('user')} children={userPage} />
<EntitySwitch.Case children={defaultPage} />
</EntitySwitch>
);
```
Note that for example `<ComponentEntityPage ... />` has been changed to simply
`componentPage`, that is because just like the `EntityPage` component, the
`ComponentEntityPage` also needs to be ported to be an element rather a
component in a similar way.
+1 -1
View File
@@ -5,7 +5,7 @@ authorUrl: https://github.com/soundcloud
category: CI/CD
description: GoCD is an open-source tool which is used in software development to help teams and organizations automate the continuous delivery of software.
documentation: https://github.com/backstage/backstage/tree/master/plugins/gocd
iconUrl: https://pics.freeicons.io/uploads/icons/png/13646383971540553613-512.png
iconUrl: https://www.gocd.org/assets/images/go_logo-5b5ca9e1.svg
npmPackageName: '@backstage/plugin-gocd'
tags:
- ci
+42 -9
View File
@@ -1197,9 +1197,9 @@ babylon@^6.18.0:
integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==
balanced-match@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
version "1.0.2"
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base64-js@^1.3.1:
version "1.5.1"
@@ -3028,7 +3028,19 @@ glob-to-regexp@^0.3.0:
resolved "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab"
integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs=
glob@^7.0.0, glob@^7.0.5, glob@^7.1.2, glob@^7.1.3, glob@^7.1.6, glob@^7.1.7, glob@~7.1.1:
glob@^7.0.0, glob@^7.0.5, glob@^7.1.2, glob@^7.1.3, glob@^7.1.6, glob@^7.1.7:
version "7.2.0"
resolved "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023"
integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==
dependencies:
fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
minimatch "^3.0.4"
once "^1.3.0"
path-is-absolute "^1.0.0"
glob@~7.1.1:
version "7.1.7"
resolved "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90"
integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
@@ -3556,6 +3568,13 @@ is-core-module@^2.1.0:
dependencies:
has "^1.0.3"
is-core-module@^2.8.0:
version "2.8.1"
resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz#f59fdfca701d5879d0a6b100a40aa1560ce27211"
integrity sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==
dependencies:
has "^1.0.3"
is-data-descriptor@^0.1.4:
version "0.1.4"
resolved "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"
@@ -4793,7 +4812,7 @@ path-key@^2.0.0, path-key@^2.0.1:
resolved "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=
path-parse@^1.0.6:
path-parse@^1.0.6, path-parse@^1.0.7:
version "1.0.7"
resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
@@ -5586,7 +5605,16 @@ resolve-url@^0.2.1:
resolved "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=
resolve@^1.1.6, resolve@^1.10.0:
resolve@^1.1.6:
version "1.21.0"
resolved "https://registry.npmjs.org/resolve/-/resolve-1.21.0.tgz#b51adc97f3472e6a5cf4444d34bc9d6b9037591f"
integrity sha512-3wCbTpk5WJlyE4mSOtDLhqQmGFi0/TD9VPwmiolnk8U0wRgMEktqCXd3vy5buTO3tljvalNvKrjHEfrd2WpEKA==
dependencies:
is-core-module "^2.8.0"
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
resolve@^1.10.0:
version "1.19.0"
resolved "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz#1af5bf630409734a067cae29318aac7fa29a267c"
integrity sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==
@@ -5781,9 +5809,9 @@ shell-quote@1.7.2:
integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==
shelljs@^0.8.4:
version "0.8.4"
resolved "https://registry.npmjs.org/shelljs/-/shelljs-0.8.4.tgz#de7684feeb767f8716b326078a8a00875890e3c2"
integrity sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ==
version "0.8.5"
resolved "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz#de055408d8361bed66c669d2f000538ced8ee20c"
integrity sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==
dependencies:
glob "^7.0.0"
interpret "^1.0.0"
@@ -6133,6 +6161,11 @@ supports-color@^7.1.0:
dependencies:
has-flag "^4.0.0"
supports-preserve-symlinks-flag@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
svgo@^1.0.0, svgo@^1.3.2:
version "1.3.2"
resolved "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167"
+1
View File
@@ -57,6 +57,7 @@
"@roadiehq/backstage-plugin-github-insights": "^1.4.2",
"@roadiehq/backstage-plugin-github-pull-requests": "^1.3.2",
"@roadiehq/backstage-plugin-travis-ci": "^1.3.2",
"@roadiehq/backstage-plugin-buildkite": "^1.3.4",
"history": "^5.0.0",
"prop-types": "^15.7.2",
"react": "^16.13.1",
+44 -30
View File
@@ -23,12 +23,17 @@ import MapIcon from '@material-ui/icons/MyLocation';
import LayersIcon from '@material-ui/icons/Layers';
import LibraryBooks from '@material-ui/icons/LibraryBooks';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import SearchIcon from '@material-ui/icons/Search';
import MenuIcon from '@material-ui/icons/Menu';
import MoneyIcon from '@material-ui/icons/MonetizationOn';
import LogoFull from './LogoFull';
import LogoIcon from './LogoIcon';
import { NavLink } from 'react-router-dom';
import { GraphiQLIcon } from '@backstage/plugin-graphiql';
import { Settings as SidebarSettings } from '@backstage/plugin-user-settings';
import {
Settings as SidebarSettings,
UserSettingsSignInAvatar,
} from '@backstage/plugin-user-settings';
import {
SidebarSearchModal,
SearchContextProvider,
@@ -36,15 +41,15 @@ import {
import { Shortcuts } from '@backstage/plugin-shortcuts';
import {
Sidebar,
SidebarPage,
sidebarConfig,
SidebarContext,
SidebarItem,
SidebarDivider,
SidebarSpace,
SidebarGroup,
SidebarItem,
SidebarPage,
SidebarScrollWrapper,
SidebarSpace,
} from '@backstage/core-components';
import { AzurePullRequestsIcon } from '@backstage/plugin-azure-devops';
const useSidebarLogoStyles = makeStyles({
root: {
@@ -83,34 +88,43 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
<SidebarPage>
<Sidebar>
<SidebarLogo />
<SearchContextProvider>
<SidebarSearchModal />
</SearchContextProvider>
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
<SearchContextProvider>
<SidebarSearchModal />
</SearchContextProvider>
</SidebarGroup>
<SidebarDivider />
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
<SidebarItem icon={LibraryBooks} to="docs" text="Docs" />
<SidebarItem icon={LayersIcon} to="explore" text="Explore" />
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
{/* End global nav */}
<SidebarDivider />
<SidebarScrollWrapper>
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
<SidebarItem icon={RuleIcon} to="lighthouse" text="Lighthouse" />
<SidebarItem icon={MoneyIcon} to="cost-insights" text="Cost Insights" />
<SidebarItem icon={GraphiQLIcon} to="graphiql" text="GraphiQL" />
<SidebarItem
icon={AzurePullRequestsIcon}
to="azure-pull-requests"
text="Azure PRs"
/>
</SidebarScrollWrapper>
<SidebarDivider />
<Shortcuts />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
<SidebarItem icon={LibraryBooks} to="docs" text="Docs" />
<SidebarItem icon={LayersIcon} to="explore" text="Explore" />
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
{/* End global nav */}
<SidebarDivider />
<SidebarScrollWrapper>
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
<SidebarItem icon={RuleIcon} to="lighthouse" text="Lighthouse" />
<SidebarItem
icon={MoneyIcon}
to="cost-insights"
text="Cost Insights"
/>
<SidebarItem icon={GraphiQLIcon} to="graphiql" text="GraphiQL" />
</SidebarScrollWrapper>
<SidebarDivider />
<Shortcuts />
</SidebarGroup>
<SidebarSpace />
<SidebarDivider />
<SidebarSettings />
<SidebarGroup
label="Settings"
icon={<UserSettingsSignInAvatar />}
to="/settings"
>
<SidebarSettings />
</SidebarGroup>
</Sidebar>
{children}
</SidebarPage>
@@ -124,6 +124,10 @@ import {
EntityTravisCIOverviewCard,
isTravisciAvailable,
} from '@roadiehq/backstage-plugin-travis-ci';
import {
EntityBuildkiteContent,
isBuildkiteAvailable,
} from '@roadiehq/backstage-plugin-buildkite';
import {
isNewRelicDashboardAvailable,
EntityNewRelicDashboardContent,
@@ -174,6 +178,10 @@ export const cicdContent = (
<EntityJenkinsContent />
</EntitySwitch.Case>
<EntitySwitch.Case if={isBuildkiteAvailable}>
<EntityBuildkiteContent />
</EntitySwitch.Case>
<EntitySwitch.Case if={isCircleCIAvailable}>
<EntityCircleCIContent />
</EntitySwitch.Case>
@@ -21,6 +21,7 @@ import {
Header,
Lifecycle,
Page,
SidebarPinStateContext,
} from '@backstage/core-components';
import { CatalogResultListItem } from '@backstage/plugin-catalog';
import {
@@ -33,7 +34,7 @@ import {
} from '@backstage/plugin-search';
import { DocsResultListItem } from '@backstage/plugin-techdocs';
import { Grid, List, makeStyles, Paper, Theme } from '@material-ui/core';
import React from 'react';
import React, { useContext } from 'react';
const useStyles = makeStyles((theme: Theme) => ({
bar: {
@@ -52,9 +53,11 @@ const useStyles = makeStyles((theme: Theme) => ({
const SearchPage = () => {
const classes = useStyles();
const { isMobile } = useContext(SidebarPinStateContext);
return (
<Page themeId="home">
<Header title="Search" subtitle={<Lifecycle alpha />} />
{!isMobile && <Header title="Search" subtitle={<Lifecycle alpha />} />}
<Content>
<Grid container direction="row">
<Grid item xs={12}>
@@ -62,37 +65,39 @@ const SearchPage = () => {
<SearchBar debounceTime={100} />
</Paper>
</Grid>
<Grid item xs={3}>
<SearchType.Accordion
name="Result Type"
defaultValue="software-catalog"
types={[
{
value: 'software-catalog',
name: 'Software Catalog',
icon: <CatalogIcon />,
},
{
value: 'techdocs',
name: 'Documentation',
icon: <DocsIcon />,
},
]}
/>
<Paper className={classes.filters}>
<SearchFilter.Select
className={classes.filter}
name="kind"
values={['Component', 'Template']}
{!isMobile && (
<Grid item xs={3}>
<SearchType.Accordion
name="Result Type"
defaultValue="software-catalog"
types={[
{
value: 'software-catalog',
name: 'Software Catalog',
icon: <CatalogIcon />,
},
{
value: 'techdocs',
name: 'Documentation',
icon: <DocsIcon />,
},
]}
/>
<SearchFilter.Checkbox
className={classes.filter}
name="lifecycle"
values={['experimental', 'production']}
/>
</Paper>
</Grid>
<Grid item xs={9}>
<Paper className={classes.filters}>
<SearchFilter.Select
className={classes.filter}
name="kind"
values={['Component', 'Template']}
/>
<SearchFilter.Checkbox
className={classes.filter}
name="lifecycle"
values={['experimental', 'production']}
/>
</Paper>
</Grid>
)}
<Grid item xs>
<SearchResult>
{({ results }) => (
<List>
+4 -1
View File
@@ -39,7 +39,10 @@ const updateRedactionList = (
configs: AppConfig[],
logger: Logger,
) => {
const secretAppConfigs = schema.process(configs, { visibility: ['secret'] });
const secretAppConfigs = schema.process(configs, {
visibility: ['secret'],
withDeprecatedKeys: true,
});
const secretConfig = ConfigReader.fromConfigs(secretAppConfigs);
const values = new Set<string>();
const data = secretConfig.get();
+1
View File
@@ -32,6 +32,7 @@
"@backstage/integration": "^0.7.1",
"@backstage/plugin-app-backend": "^0.3.21",
"@backstage/plugin-auth-backend": "^0.6.2",
"@backstage/plugin-azure-devops-backend": "^0.3.0",
"@backstage/plugin-badges-backend": "^0.1.15",
"@backstage/plugin-catalog-backend": "^0.20.0",
"@backstage/plugin-code-coverage-backend": "^0.1.19",
+3
View File
@@ -40,6 +40,7 @@ import { Config } from '@backstage/config';
import healthcheck from './plugins/healthcheck';
import { metricsInit, metricsHandler } from './metrics';
import auth from './plugins/auth';
import azureDevOps from './plugins/azure-devops';
import catalog from './plugins/catalog';
import codeCoverage from './plugins/codecoverage';
import kubernetes from './plugins/kubernetes';
@@ -116,6 +117,7 @@ async function main() {
);
const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder'));
const authEnv = useHotMemoize(module, () => createEnv('auth'));
const azureDevOpsEnv = useHotMemoize(module, () => createEnv('azure-devops'));
const proxyEnv = useHotMemoize(module, () => createEnv('proxy'));
const rollbarEnv = useHotMemoize(module, () => createEnv('rollbar'));
const searchEnv = useHotMemoize(module, () => createEnv('search'));
@@ -139,6 +141,7 @@ async function main() {
apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv));
apiRouter.use('/tech-insights', await techInsights(techInsightsEnv));
apiRouter.use('/auth', await auth(authEnv));
apiRouter.use('/azure-devops', await azureDevOps(azureDevOpsEnv));
apiRouter.use('/search', await search(searchEnv));
apiRouter.use('/techdocs', await techdocs(techdocsEnv));
apiRouter.use('/todo', await todo(todoEnv));
@@ -0,0 +1,26 @@
/*
* Copyright 2020 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 { createRouter } from '@backstage/plugin-azure-devops-backend';
import { Router } from 'express';
import type { PluginEnvironment } from '../types';
export default function createPlugin({
logger,
config,
}: PluginEnvironment): Promise<Router> {
return createRouter({ logger, config });
}
+2 -2
View File
@@ -45,8 +45,8 @@
"@spotify/eslint-config-typescript": "^12.0.0",
"@sucrase/jest-plugin": "^2.1.1",
"@sucrase/webpack-loader": "^2.0.0",
"@svgr/plugin-jsx": "5.5.x",
"@svgr/plugin-svgo": "5.4.x",
"@svgr/plugin-jsx": "6.2.x",
"@svgr/plugin-svgo": "6.2.x",
"@svgr/rollup": "5.5.x",
"@svgr/webpack": "5.5.x",
"@types/webpack-env": "^1.15.2",
@@ -23,5 +23,6 @@ export default async (cmd: Command) => {
fromPackage: cmd.package,
mockEnv: cmd.lax,
fullVisibility: !cmd.frontend,
withDeprecatedKeys: cmd.deprecated,
});
};
+1
View File
@@ -193,6 +193,7 @@ export function registerCommands(program: CommanderStatic) {
)
.option('--lax', 'Do not require environment variables to be set')
.option('--frontend', 'Only validate the frontend configuration')
.option('--deprecated', 'Output deprecated configuration settings')
.option(...configOption)
.description(
'Validate that the given configuration loads and matches schema',
+2
View File
@@ -30,6 +30,7 @@ type Options = {
fromPackage?: string;
mockEnv?: boolean;
withFilteredKeys?: boolean;
withDeprecatedKeys?: boolean;
fullVisibility?: boolean;
};
@@ -91,6 +92,7 @@ export async function loadCliConfig(options: Options) {
? ['frontend', 'backend', 'secret']
: ['frontend'],
withFilteredKeys: options.withFilteredKeys,
withDeprecatedKeys: options.withDeprecatedKeys,
});
const frontendConfig = ConfigReader.fromConfigs(frontendAppConfigs);
+1
View File
@@ -21,6 +21,7 @@ export type ConfigSchemaProcessingOptions = {
visibility?: ConfigVisibility[];
valueTransform?: TransformFunc<any>;
withFilteredKeys?: boolean;
withDeprecatedKeys?: boolean;
};
// @public (undocumented)
@@ -182,10 +182,10 @@ function compileTsSchemas(paths: string[]) {
program,
// All schemas should export a `Config` symbol
'Config',
// This enables usage of @visibility is doc comments
// This enables usage of @visibility and @deprecated in doc comments
{
required: true,
validationKeywords: ['visibility'],
validationKeywords: ['visibility', 'deprecated'],
},
[path.split(sep).join('/')], // Unix paths are expected for all OSes here
) as JsonObject | null;
@@ -40,6 +40,7 @@ describe('compileConfigSchemas', () => {
],
visibilityByDataPath: new Map(),
visibilityBySchemaPath: new Map(),
deprecationByDataPath: new Map(),
});
expect(validate([{ data: { b: 'b' }, context: 'test' }])).toEqual({
errors: [
@@ -53,6 +54,7 @@ describe('compileConfigSchemas', () => {
],
visibilityByDataPath: new Map(),
visibilityBySchemaPath: new Map(),
deprecationByDataPath: new Map(),
});
});
@@ -112,6 +114,7 @@ describe('compileConfigSchemas', () => {
'/properties/d/items': 'frontend',
}),
),
deprecationByDataPath: new Map(),
});
});
@@ -137,4 +140,34 @@ describe('compileConfigSchemas', () => {
"Config schema visibility is both 'frontend' and 'secret' for properties/a/visibility",
);
});
it('should discover deprecations', () => {
const validate = compileConfigSchemas([
{
path: 'a1',
value: {
type: 'object',
properties: {
a: { type: 'string', deprecated: 'deprecation reason for a' },
b: { type: 'string', deprecated: 'deprecation reason for b' },
c: { type: 'string' },
},
},
},
]);
expect(
validate([
{ data: { a: 'a', b: 'b', c: 'c', d: ['d'] }, context: 'test' },
]),
).toEqual({
deprecationByDataPath: new Map(
Object.entries({
'/a': 'deprecation reason for a',
'/b': 'deprecation reason for b',
}),
),
visibilityByDataPath: new Map(),
visibilityBySchemaPath: new Map(),
});
});
});
@@ -40,6 +40,7 @@ export function compileConfigSchemas(
// output during validation. We work around this by having this extra piece
// of state that we reset before each validation.
const visibilityByDataPath = new Map<string, ConfigVisibility>();
const deprecationByDataPath = new Map<string, string>();
const ajv = new Ajv({
allErrors: true,
@@ -47,28 +48,48 @@ export function compileConfigSchemas(
schemas: {
'https://backstage.io/schema/config-v1': true,
},
}).addKeyword({
keyword: 'visibility',
metaSchema: {
type: 'string',
enum: CONFIG_VISIBILITIES,
},
compile(visibility: ConfigVisibility) {
return (_data, context) => {
if (context?.dataPath === undefined) {
return false;
}
if (visibility && visibility !== 'backend') {
})
.addKeyword({
keyword: 'visibility',
metaSchema: {
type: 'string',
enum: CONFIG_VISIBILITIES,
},
compile(visibility: ConfigVisibility) {
return (_data, context) => {
if (context?.dataPath === undefined) {
return false;
}
if (visibility && visibility !== 'backend') {
const normalizedPath = context.dataPath.replace(
/\['?(.*?)'?\]/g,
(_, segment) => `/${segment}`,
);
visibilityByDataPath.set(normalizedPath, visibility);
}
return true;
};
},
})
.removeKeyword('deprecated') // remove `deprecated` keyword so that we can implement our own compiler
.addKeyword({
keyword: 'deprecated',
metaSchema: { type: 'string' },
compile(deprecationDescription: string) {
return (_data, context) => {
if (context?.dataPath === undefined) {
return false;
}
const normalizedPath = context.dataPath.replace(
/\['?(.*?)'?\]/g,
(_, segment) => `/${segment}`,
);
visibilityByDataPath.set(normalizedPath, visibility);
}
return true;
};
},
});
// create mapping of deprecation description and data path of property
deprecationByDataPath.set(normalizedPath, deprecationDescription);
return true;
};
},
});
for (const schema of schemas) {
try {
@@ -79,6 +100,7 @@ export function compileConfigSchemas(
}
const merged = mergeConfigSchemas(schemas.map(_ => _.value));
const validate = ajv.compile(merged);
const visibilityBySchemaPath = new Map<string, ConfigVisibility>();
@@ -94,17 +116,20 @@ export function compileConfigSchemas(
visibilityByDataPath.clear();
const valid = validate(config);
if (!valid) {
return {
errors: validate.errors ?? [],
visibilityByDataPath: new Map(visibilityByDataPath),
visibilityBySchemaPath,
deprecationByDataPath,
};
}
return {
visibilityByDataPath: new Map(visibilityByDataPath),
visibilityBySchemaPath,
deprecationByDataPath,
};
};
}
@@ -64,6 +64,13 @@ const visibility = new Map<string, ConfigVisibility>(
}),
);
const deprecations = new Map<string, string>(
Object.entries({
'/arr': 'deprecated array',
'/objB/never': 'deprecated nested property',
}),
);
describe('filterByVisibility', () => {
test.each<[ConfigVisibility[], JsonObject]>([
[
@@ -185,9 +192,34 @@ describe('filterByVisibility', () => {
[['frontend', 'backend', 'secret'], { data, filteredKeys: [] }],
])('should filter correctly with %p', (filter, expected) => {
expect(
filterByVisibility(data, filter, visibility, undefined, true),
filterByVisibility(
data,
filter,
visibility,
deprecations,
undefined,
true,
false,
),
).toEqual(expected);
});
it('should include deprecated keys regardless of visibility', () => {
expect(
filterByVisibility(
data,
[],
visibility,
deprecations,
undefined,
true,
true,
).deprecatedKeys,
).toEqual([
{ key: 'arr', description: 'deprecated array' },
{ key: 'objB.never', description: 'deprecated nested property' },
]);
});
});
describe('filterErrorsByVisibility', () => {
@@ -30,10 +30,17 @@ export function filterByVisibility(
data: JsonObject,
includeVisibilities: ConfigVisibility[],
visibilityByDataPath: Map<string, ConfigVisibility>,
deprecationByDataPath: Map<string, string>,
transformFunc?: TransformFunc<number | string | boolean>,
withFilteredKeys?: boolean,
): { data: JsonObject; filteredKeys?: string[] } {
withDeprecatedKeys?: boolean,
): {
data: JsonObject;
filteredKeys?: string[];
deprecatedKeys?: { key: string; description: string }[];
} {
const filteredKeys = new Array<string>();
const deprecatedKeys = new Array<{ key: string; description: string }>();
function transform(
jsonVal: JsonValue,
@@ -44,6 +51,12 @@ export function filterByVisibility(
visibilityByDataPath.get(visibilityPath) ?? DEFAULT_CONFIG_VISIBILITY;
const isVisible = includeVisibilities.includes(visibility);
// deprecated keys are added regardless of visibility indicator
const deprecation = deprecationByDataPath.get(visibilityPath);
if (deprecation) {
deprecatedKeys.push({ key: filterPath, description: deprecation });
}
if (typeof jsonVal !== 'object') {
if (isVisible) {
if (transformFunc) {
@@ -109,6 +122,7 @@ export function filterByVisibility(
return {
filteredKeys: withFilteredKeys ? filteredKeys : undefined,
deprecatedKeys: withDeprecatedKeys ? deprecatedKeys : undefined,
data: (transform(data, '', '') as JsonObject) ?? {},
};
}
@@ -62,7 +62,10 @@ describe('loadConfigSchema', () => {
expect(schema.process(configs)).toEqual(configs);
expect(schema.process(configs, { visibility: ['frontend'] })).toEqual([
{ data: { key1: 'a' }, context: 'test' },
{
data: { key1: 'a' },
context: 'test',
},
]);
expect(
schema.process(configs, {
@@ -71,7 +74,11 @@ describe('loadConfigSchema', () => {
withFilteredKeys: true,
}),
).toEqual([
{ data: { key1: 'X' }, context: 'test', filteredKeys: ['key2'] },
{
data: { key1: 'X' },
context: 'test',
filteredKeys: ['key2'],
},
]);
expect(
schema.process(configs, {
@@ -79,14 +86,21 @@ describe('loadConfigSchema', () => {
withFilteredKeys: true,
}),
).toEqual([
{ data: { key1: 'X', key2: 'X' }, context: 'test', filteredKeys: [] },
{
data: { key1: 'X', key2: 'X' },
context: 'test',
filteredKeys: [],
},
]);
const serialized = schema.serialize();
const schema2 = await loadConfigSchema({ serialized });
expect(schema2.process(configs, { visibility: ['frontend'] })).toEqual([
{ data: { key1: 'a' }, context: 'test' },
{
data: { key1: 'a' },
context: 'test',
},
]);
expect(() =>
schema2.process([...configs, { data: { key1: 3 }, context: 'test2' }]),
@@ -131,7 +145,10 @@ describe('loadConfigSchema', () => {
'Config validation failed, Config should be number { type=number } at /key2',
);
expect(schema.process(configs, { visibility: ['frontend'] })).toEqual([
{ data: { key1: 'a' }, context: 'test' },
{
data: { key1: 'a' },
context: 'test',
},
]);
expect(() => schema.process(configs, { visibility: ['secret'] })).toThrow(
'Config validation failed, Config should be number { type=number } at /key2',
@@ -179,7 +196,12 @@ describe('loadConfigSchema', () => {
];
expect(
schema.process(mkConfig({ x: 1 }), { visibility: ['frontend'] }),
).toEqual([{ data: { nested: [{}] }, context: 'test' }]);
).toEqual([
{
data: { nested: [{}] },
context: 'test',
},
]);
expect(() => schema.process(mkConfig({ y: 1 }))).toThrow(
'Config validation failed, Config should be string { type=string } at /nested/0/y',
);
@@ -190,10 +212,20 @@ describe('loadConfigSchema', () => {
);
expect(
schema.process(mkConfig({ x: 'a' }), { visibility: ['frontend'] }),
).toEqual([{ data: { nested: [{}] }, context: 'test' }]);
).toEqual([
{
data: { nested: [{}] },
context: 'test',
},
]);
expect(
schema.process(mkConfig({ y: 'aaa' }), { visibility: ['frontend'] }),
).toEqual([{ data: { nested: [{ y: 'aaa' }] }, context: 'test' }]);
).toEqual([
{
data: { nested: [{ y: 'aaa' }] },
context: 'test',
},
]);
expect(() =>
schema.process(mkConfig({ y: 'aaaa' }), { visibility: ['frontend'] }),
).toThrow(
@@ -82,7 +82,7 @@ export async function loadConfigSchema(
return {
process(
configs: AppConfig[],
{ visibility, valueTransform, withFilteredKeys } = {},
{ visibility, valueTransform, withFilteredKeys, withDeprecatedKeys } = {},
): AppConfig[] {
const result = validate(configs);
@@ -105,8 +105,10 @@ export async function loadConfigSchema(
data,
visibility,
result.visibilityByDataPath,
result.deprecationByDataPath,
valueTransform,
withFilteredKeys,
withDeprecatedKeys,
),
}));
} else if (valueTransform) {
@@ -116,8 +118,10 @@ export async function loadConfigSchema(
data,
Array.from(CONFIG_VISIBILITIES),
result.visibilityByDataPath,
result.deprecationByDataPath,
valueTransform,
withFilteredKeys,
withDeprecatedKeys,
),
}));
}
@@ -81,6 +81,13 @@ type ValidationResult = {
* The path in the key uses the form `/properties/<key>/items/additionalProperties/<leaf-key>`
*/
visibilityBySchemaPath: Map<string, ConfigVisibility>;
/**
* The deprecated options that were discovered during validation.
*
* The path in the key uses the form `/<key>/<sub-key>/<array-index>/<leaf-key>`
*/
deprecationByDataPath: Map<string, string>;
};
/**
@@ -124,6 +131,13 @@ export type ConfigSchemaProcessingOptions = {
* Default: `false`.
*/
withFilteredKeys?: boolean;
/**
* Whether or not to include the `deprecatedKeys` property in the output `AppConfig`s.
*
* Default: `true`.
*/
withDeprecatedKeys?: boolean;
};
/**
+4
View File
@@ -13,6 +13,10 @@ export type AppConfig = {
context: string;
data: JsonObject_2;
filteredKeys?: string[];
deprecatedKeys?: {
key: string;
description: string;
}[];
};
// @public
+13 -1
View File
@@ -83,9 +83,21 @@ export class ConfigReader implements Config {
// Merge together all configs into a single config with recursive fallback
// readers, giving the first config object in the array the lowest priority.
return configs.reduce<ConfigReader>(
(previousReader, { data, context, filteredKeys }) => {
(previousReader, { data, context, filteredKeys, deprecatedKeys }) => {
const reader = new ConfigReader(data, context, previousReader);
reader.filteredKeys = filteredKeys;
if (deprecatedKeys) {
for (const { key, description } of deprecatedKeys) {
// eslint-disable-next-line no-console
console.warn(
`The configuration key '${key}' of ${context} is deprecated and may be removed soon. ${
description || ''
}`,
);
}
}
return reader;
},
undefined!,
+6
View File
@@ -36,6 +36,12 @@ export type AppConfig = {
* This can be used to warn the user if they try to read any of these keys.
*/
filteredKeys?: string[];
/**
* A list of deprecated keys that were found in the configuration when it was loaded.
*
* This can be used to warn the user if they are using deprecated properties.
*/
deprecatedKeys?: { key: string; description: string }[];
};
/**
+52 -23
View File
@@ -10,6 +10,7 @@ import { BackstageIdentityApi } from '@backstage/core-plugin-api';
import { BackstagePalette } from '@backstage/theme';
import { BackstageTheme } from '@backstage/theme';
import { BackstageUserIdentity } from '@backstage/core-plugin-api';
import { BottomNavigationActionProps } from '@material-ui/core/BottomNavigationAction';
import { ButtonProps as ButtonProps_2 } from '@material-ui/core/Button';
import { CardHeaderProps } from '@material-ui/core/CardHeader';
import { Column } from '@material-table/core';
@@ -104,7 +105,7 @@ export type BottomLinkProps = {
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
//
// @public
export function Breadcrumbs(props: Props_20): JSX.Element;
export function Breadcrumbs(props: Props_19): JSX.Element;
// @public (undocumented)
export type BreadcrumbsClickableTextClassKey = 'root';
@@ -695,6 +696,14 @@ export function MissingAnnotationEmptyState(props: Props_3): JSX.Element;
// @public (undocumented)
export type MissingAnnotationEmptyStateClassKey = 'code';
// @public
export const MobileSidebar: (props: MobileSidebarProps) => JSX.Element | null;
// @public
export type MobileSidebarProps = {
children?: React_2.ReactNode;
};
// Warning: (ae-missing-release-tag) "OAuthRequestDialog" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -844,11 +853,8 @@ export type SelectItem = {
value: string | number;
};
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "Sidebar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function Sidebar(props: PropsWithChildren<Props_17>): JSX.Element;
// @public
export const Sidebar: (props: SidebarProps) => JSX.Element;
// Warning: (ae-missing-release-tag) "SIDEBAR_INTRO_LOCAL_STORAGE" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -856,10 +862,8 @@ export function Sidebar(props: PropsWithChildren<Props_17>): JSX.Element;
export const SIDEBAR_INTRO_LOCAL_STORAGE =
'@backstage/core/sidebar-intro-dismissed';
// Warning: (ae-missing-release-tag) "SidebarClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type SidebarClassKey = 'root' | 'drawer' | 'drawerOpen';
export type SidebarClassKey = 'drawer' | 'drawerOpen';
// Warning: (ae-missing-release-tag) "sidebarConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -877,16 +881,17 @@ export const sidebarConfig: {
selectedIndicatorWidth: number;
userBadgePadding: number;
userBadgeDiameter: number;
mobileSidebarHeight: number;
};
// Warning: (ae-missing-release-tag) "SidebarContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
export const SidebarContext: Context<SidebarContextType>;
// Warning: (ae-missing-release-tag) "SidebarContextType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
export type SidebarContextType = {
isOpen: boolean;
setOpen: (open: boolean) => void;
@@ -1169,6 +1174,16 @@ export type SidebarDividerClassKey = 'root';
// @public
export const SidebarExpandButton: () => JSX.Element | null;
// @public
export const SidebarGroup: (props: SidebarGroupProps) => JSX.Element;
// @public
export interface SidebarGroupProps extends BottomNavigationActionProps {
children?: React_2.ReactNode;
priority?: number;
to?: string;
}
// Warning: (ae-missing-release-tag) "SidebarIntro" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -1185,19 +1200,19 @@ export type SidebarIntroClassKey =
// Warning: (ae-forgotten-export) The symbol "SidebarItemProps" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "SidebarItem" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
export const SidebarItem: React_2.ForwardRefExoticComponent<
SidebarItemProps & React_2.RefAttributes<any>
>;
// Warning: (ae-missing-release-tag) "SidebarItemClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type SidebarItemClassKey =
| 'root'
| 'buttonItem'
| 'closed'
| 'open'
| 'highlightable'
| 'highlighted'
| 'label'
| 'iconContainer'
| 'searchRoot'
@@ -1205,29 +1220,43 @@ export type SidebarItemClassKey =
| 'searchFieldHTMLInput'
| 'searchContainer'
| 'secondaryAction'
| 'closedItemIcon'
| 'submenuArrow'
| 'expandButton'
| 'arrows'
| 'selected';
// Warning: (ae-missing-release-tag) "SidebarPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function SidebarPage(props: PropsWithChildren<{}>): JSX.Element;
export function SidebarPage(props: SidebarPageProps): JSX.Element;
// Warning: (ae-missing-release-tag) "SidebarPageClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type SidebarPageClassKey = 'root';
// Warning: (ae-missing-release-tag) "SidebarPinStateContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
export type SidebarPageProps = {
children?: React_2.ReactNode;
};
// @public
export const SidebarPinStateContext: React_2.Context<SidebarPinStateContextType>;
// Warning: (ae-missing-release-tag) "SidebarPinStateContextType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
export type SidebarPinStateContextType = {
isPinned: boolean;
toggleSidebarPinState: () => any;
isMobile?: boolean;
};
// @public (undocumented)
export type SidebarProps = {
openDelayMs?: number;
closeDelayMs?: number;
disableExpandOnHover?: boolean;
children?: React_2.ReactNode;
};
// Warning: (ae-missing-release-tag) "SidebarScrollWrapper" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -2085,7 +2114,7 @@ export type SidebarSubmenuProps = {
// Warning: (ae-missing-release-tag) "SignInPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function SignInPage(props: Props_18): JSX.Element;
export function SignInPage(props: Props_17): JSX.Element;
// Warning: (ae-missing-release-tag) "SignInPageClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -2256,7 +2285,7 @@ export type TabBarClassKey = 'indicator' | 'flexContainer' | 'root';
// Warning: (ae-missing-release-tag) "TabbedCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function TabbedCard(props: PropsWithChildren<Props_19>): JSX.Element;
export function TabbedCard(props: PropsWithChildren<Props_18>): JSX.Element;
// Warning: (ae-missing-release-tag) "TabbedCardClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
+1 -1
View File
@@ -58,7 +58,7 @@
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-sparklines": "^1.7.0",
"react-syntax-highlighter": "^15.4.3",
"react-syntax-highlighter": "^15.4.5",
"react-text-truncate": "^0.16.0",
"react-use": "^17.2.4",
"react-virtualized-auto-sizer": "^1.0.6",
@@ -20,7 +20,7 @@ import { makeStyles, ThemeProvider } from '@material-ui/core/styles';
export type PageClassKey = 'root';
const useStyles = makeStyles(
const useStyles = makeStyles<BackstageTheme>(
() => ({
root: {
display: 'grid',
@@ -28,7 +28,7 @@ const useStyles = makeStyles(
"'pageHeader pageHeader pageHeader' 'pageSubheader pageSubheader pageSubheader' 'pageNav pageContent pageSidebar'",
gridTemplateRows: 'max-content auto 1fr',
gridTemplateColumns: 'auto 1fr auto',
height: '100vh',
height: '100%',
overflowY: 'auto',
},
}),
@@ -14,24 +14,32 @@
* limitations under the License.
*/
import React from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import AcUnitIcon from '@material-ui/icons/AcUnit';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import BuildRoundedIcon from '@material-ui/icons/BuildRounded';
import MenuBookIcon from '@material-ui/icons/MenuBook';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import BuildRoundedIcon from '@material-ui/icons/BuildRounded';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import MenuBookIcon from '@material-ui/icons/MenuBook';
import AcUnitIcon from '@material-ui/icons/AcUnit';
import { Sidebar, SidebarExpandButton } from './Bar';
import { SidebarItem, SidebarSearchField } from './Items';
import { SidebarSubmenuItem } from './SidebarSubmenuItem';
import { SidebarSubmenu } from './SidebarSubmenu';
import { SidebarPinStateContext } from '.';
import React from 'react';
import {
Sidebar,
SidebarExpandButton,
SidebarItem,
SidebarSearchField,
SidebarPinStateContext,
SidebarSubmenu,
SidebarSubmenuItem,
} from '.';
async function renderScalableSidebar() {
await renderInTestApp(
<SidebarPinStateContext.Provider
value={{ isPinned: false, toggleSidebarPinState: () => {} }}
value={{
isPinned: false,
isMobile: false,
toggleSidebarPinState: () => {},
}}
>
<Sidebar disableExpandOnHover>
<SidebarSearchField onSearch={() => {}} to="/search" />
@@ -17,23 +17,17 @@
import { makeStyles } from '@material-ui/core/styles';
import useMediaQuery from '@material-ui/core/useMediaQuery';
import classnames from 'classnames';
import React, { useState, useContext, PropsWithChildren, useRef } from 'react';
import React, { useState, useContext, useRef } from 'react';
import { sidebarConfig, SidebarContext } from './config';
import { BackstageTheme } from '@backstage/theme';
import { SidebarPinStateContext } from './Page';
import DoubleArrowRight from './icons/DoubleArrowRight';
import DoubleArrowLeft from './icons/DoubleArrowLeft';
import { MobileSidebar } from './MobileSidebar';
export type SidebarClassKey = 'root' | 'drawer' | 'drawerOpen';
/** @public */
export type SidebarClassKey = 'drawer' | 'drawerOpen';
const useStyles = makeStyles<BackstageTheme>(
theme => ({
root: {
zIndex: 1000,
position: 'relative',
overflow: 'visible',
width: theme.spacing(7) + 1,
},
drawer: {
display: 'flex',
flexFlow: 'column nowrap',
@@ -42,7 +36,7 @@ const useStyles = makeStyles<BackstageTheme>(
left: 0,
top: 0,
bottom: 0,
padding: 0,
zIndex: theme.zIndex.appBar,
background: theme.palette.navigation.background,
overflowX: 'hidden',
msOverflowStyle: 'none',
@@ -59,19 +53,6 @@ const useStyles = makeStyles<BackstageTheme>(
display: 'none',
},
},
expandButton: {
background: 'none',
border: 'none',
color: theme.palette.navigation.color,
width: '100%',
cursor: 'pointer',
position: 'relative',
height: 48,
},
arrows: {
position: 'absolute',
right: 10,
},
drawerOpen: {
width: sidebarConfig.drawerWidthOpen,
transition: theme.transitions.create('width', {
@@ -89,29 +70,44 @@ enum State {
Open,
}
type Props = {
/** @public */
export type SidebarProps = {
openDelayMs?: number;
closeDelayMs?: number;
disableExpandOnHover?: boolean;
children?: React.ReactNode;
};
export function Sidebar(props: PropsWithChildren<Props>) {
/**
* Places the Sidebar & wraps the children providing context weather the `Sidebar` is open or not.
*
* Handles & delays hover events for expanding the `Sidebar`
*
* @param props `disableExpandOnHover` disables the default hover behaviour;
* `openDelayMs` & `closeDelayMs` set delay until sidebar will open/close on hover
* @returns
* @internal
*/
const DesktopSidebar = (props: SidebarProps) => {
const {
disableExpandOnHover = false,
openDelayMs = sidebarConfig.defaultOpenDelayMs,
closeDelayMs = sidebarConfig.defaultCloseDelayMs,
disableExpandOnHover,
children,
} = props;
const classes = useStyles();
const isSmallScreen = useMediaQuery<BackstageTheme>(theme =>
theme.breakpoints.down('md'),
const isSmallScreen = useMediaQuery<BackstageTheme>(
theme => theme.breakpoints.down('md'),
{ noSsr: true },
);
const [state, setState] = useState(State.Closed);
const hoverTimerRef = useRef<number>();
const { isPinned } = useContext(SidebarPinStateContext);
const { isPinned, toggleSidebarPinState } = useContext(
SidebarPinStateContext,
);
const handleOpen = () => {
if (isPinned) {
if (isPinned || disableExpandOnHover) {
return;
}
if (hoverTimerRef.current) {
@@ -129,7 +125,7 @@ export function Sidebar(props: PropsWithChildren<Props>) {
};
const handleClose = () => {
if (isPinned) {
if (isPinned || disableExpandOnHover) {
return;
}
if (hoverTimerRef.current) {
@@ -148,73 +144,60 @@ export function Sidebar(props: PropsWithChildren<Props>) {
const isOpen = (state === State.Open && !isSmallScreen) || isPinned;
/**
* Close/Open Sidebar directily without delays. Also toggles `SidebarPinState` to avoid hidden content behind Sidebar.
*/
const setOpen = (open: boolean) => {
if (open) {
handleOpen();
setState(State.Open);
toggleSidebarPinState();
} else {
handleClose();
setState(State.Closed);
toggleSidebarPinState();
}
};
return (
<div
className={classes.root}
data-testid="sidebar-root"
onMouseEnter={disableExpandOnHover ? () => {} : handleOpen}
onFocus={disableExpandOnHover ? () => {} : handleOpen}
onMouseLeave={disableExpandOnHover ? () => {} : handleClose}
onBlur={disableExpandOnHover ? () => {} : handleClose}
<SidebarContext.Provider
value={{
isOpen,
setOpen,
}}
>
<SidebarContext.Provider
value={{
isOpen,
setOpen,
}}
<div
onMouseEnter={handleOpen}
onFocus={handleOpen}
onMouseLeave={handleClose}
onBlur={handleClose}
data-testid="sidebar-root"
className={classnames(classes.drawer, {
[classes.drawerOpen]: isOpen,
})}
>
<div
className={classnames(classes.drawer, {
[classes.drawerOpen]: isOpen,
})}
>
{children}
</div>
</SidebarContext.Provider>
</div>
{children}
</div>
</SidebarContext.Provider>
);
}
};
/**
* A button which allows you to expand the sidebar when clicked.
* Use optionally to replace sidebar's expand-on-hover feature with expand-on-click.
* Passing children into the desktop or mobile sidebar depending on the context
*
* @public
*/
export const SidebarExpandButton = () => {
const classes = useStyles();
const { isOpen, setOpen } = useContext(SidebarContext);
const { isPinned } = useContext(SidebarPinStateContext);
const isSmallScreen = useMediaQuery<BackstageTheme>(theme =>
theme.breakpoints.down('md'),
);
export const Sidebar = (props: SidebarProps) => {
const { children, openDelayMs, closeDelayMs, disableExpandOnHover } = props;
const { isMobile } = useContext(SidebarPinStateContext);
const handleClick = () => {
setOpen(!isOpen);
};
if (isPinned || isSmallScreen) {
return null;
}
return (
<button
onClick={handleClick}
className={classes.expandButton}
aria-label="Expand Sidebar"
data-testid="sidebar-expand-button"
return isMobile ? (
<MobileSidebar>{children}</MobileSidebar>
) : (
<DesktopSidebar
openDelayMs={openDelayMs}
closeDelayMs={closeDelayMs}
disableExpandOnHover={disableExpandOnHover}
>
<div className={classes.arrows}>
{isOpen ? <DoubleArrowLeft /> : <DoubleArrowRight />}
</div>
</button>
{children}
</DesktopSidebar>
);
};
@@ -20,8 +20,8 @@ import { createEvent, fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import HomeIcon from '@material-ui/icons/Home';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import { Sidebar, SidebarExpandButton } from './Bar';
import { SidebarItem, SidebarSearchField } from './Items';
import { Sidebar } from './Bar';
import { SidebarItem, SidebarSearchField, SidebarExpandButton } from './Items';
import { renderHook } from '@testing-library/react-hooks';
import { hexToRgb, makeStyles } from '@material-ui/core/styles';
@@ -17,18 +17,19 @@
import { IconComponent, useElementFilter } from '@backstage/core-plugin-api';
import { BackstageTheme } from '@backstage/theme';
import { makeStyles, styled, Theme } from '@material-ui/core/styles';
import useMediaQuery from '@material-ui/core/useMediaQuery';
import Badge from '@material-ui/core/Badge';
import TextField from '@material-ui/core/TextField';
import Typography from '@material-ui/core/Typography';
import { CreateCSSProperties } from '@material-ui/core/styles/withStyles';
import ArrowRightIcon from '@material-ui/icons/ArrowRight';
import SearchIcon from '@material-ui/icons/Search';
import ArrowDropUp from '@material-ui/icons/ArrowDropUp';
import ArrowDropDown from '@material-ui/icons/ArrowDropDown';
import classnames from 'classnames';
import React, {
Children,
forwardRef,
KeyboardEventHandler,
PropsWithChildren,
ReactNode,
useContext,
useState,
@@ -45,15 +46,24 @@ import {
SidebarContext,
SidebarItemWithSubmenuContext,
} from './config';
import { SidebarSubmenu } from './SidebarSubmenu';
import {
SidebarSubmenuItemProps,
SidebarSubmenuProps,
SidebarSubmenu,
} from '.';
import DoubleArrowLeft from './icons/DoubleArrowLeft';
import DoubleArrowRight from './icons/DoubleArrowRight';
import { isLocationMatch } from './utils';
import { Location } from 'history';
/** @public */
export type SidebarItemClassKey =
| 'root'
| 'buttonItem'
| 'closed'
| 'open'
| 'highlightable'
| 'highlighted'
| 'label'
| 'iconContainer'
| 'searchRoot'
@@ -61,6 +71,10 @@ export type SidebarItemClassKey =
| 'searchFieldHTMLInput'
| 'searchContainer'
| 'secondaryAction'
| 'closedItemIcon'
| 'submenuArrow'
| 'expandButton'
| 'arrows'
| 'selected';
const useStyles = makeStyles<BackstageTheme>(
@@ -83,7 +97,7 @@ const useStyles = makeStyles<BackstageTheme>(
buttonItem: {
background: 'none',
border: 'none',
width: 'auto',
width: '100%',
margin: 0,
padding: 0,
textAlign: 'inherit',
@@ -94,7 +108,9 @@ const useStyles = makeStyles<BackstageTheme>(
justifyContent: 'center',
},
open: {
width: drawerWidthOpen,
[theme.breakpoints.up('sm')]: {
width: drawerWidthOpen,
},
},
highlightable: {
'&:hover': {
@@ -134,7 +150,7 @@ const useStyles = makeStyles<BackstageTheme>(
fontSize: theme.typography.fontSize,
},
searchFieldHTMLInput: {
padding: `${theme.spacing(2)} 0 ${theme.spacing(2)}`,
padding: theme.spacing(2, 0, 2),
},
searchContainer: {
width: drawerWidthOpen - iconContainerWidth,
@@ -149,8 +165,20 @@ const useStyles = makeStyles<BackstageTheme>(
justifyContent: 'center',
},
submenuArrow: {
display: 'flex',
},
expandButton: {
background: 'none',
border: 'none',
color: theme.palette.navigation.color,
width: '100%',
cursor: 'pointer',
position: 'relative',
height: 48,
},
arrows: {
position: 'absolute',
right: 0,
right: 10,
},
selected: {
'&$root': {
@@ -172,112 +200,49 @@ const useStyles = makeStyles<BackstageTheme>(
{ name: 'BackstageSidebarItem' },
);
function isSidebarItemWithSubmenuActive(
submenu: ReactNode,
currentLocation: Location,
) {
// Item is active if any of submenu items have active paths
const toPathnames: string[] = [];
let isActive = false;
let submenuItems: ReactNode;
Children.forEach(submenu, element => {
if (!React.isValidElement(element)) return;
submenuItems = element.props.children;
});
Children.forEach(submenuItems, element => {
if (!React.isValidElement(element)) return;
if (element.props.dropdownItems) {
element.props.dropdownItems.map((item: { to: string }) =>
toPathnames.push(item.to),
);
} else if (element.props.to) {
toPathnames.push(element.props.to);
}
});
isActive = toPathnames.some(to => {
const toLocation = resolvePath(to);
return isLocationMatch(currentLocation, toLocation);
});
return isActive;
}
const SidebarItemWithSubmenu = ({
text,
hasNotifications = false,
icon: Icon,
children,
}: PropsWithChildren<SidebarItemWithSubmenuProps>) => {
const classes = useStyles();
const [isHoveredOn, setIsHoveredOn] = useState(false);
const currentLocation = useLocation();
const isActive = isSidebarItemWithSubmenuActive(children, currentLocation);
const handleMouseEnter = () => {
setIsHoveredOn(true);
};
const handleMouseLeave = () => {
setIsHoveredOn(false);
};
const { isOpen } = useContext(SidebarContext);
const itemIcon = (
<Badge
color="secondary"
variant="dot"
overlap="circular"
className={isOpen ? '' : classes.closedItemIcon}
invisible={!hasNotifications}
>
<Icon fontSize="small" />
</Badge>
/**
* Evaluates the routes of the SubmenuItems & nested DropdownItems.
* The reeveluation is only triggered, if the `locationPathname` changes, as `useElementFilter` uses memorization.
*
* @param submenu SidebarSubmenu component
* @param location Location
* @returns boolean
*/
const useLocationMatch = (
submenu: React.ReactElement<SidebarSubmenuProps>,
location: Location,
): boolean =>
useElementFilter(
submenu.props.children,
elements => {
let active = false;
elements
.getElements()
.forEach(
({
props: { to, dropdownItems },
}: {
props: Partial<SidebarSubmenuItemProps>;
}) => {
if (!active) {
if (dropdownItems?.length) {
dropdownItems.forEach(
({ to: _to }) =>
(active =
active || isLocationMatch(location, resolvePath(_to))),
);
return;
}
if (to) {
active = isLocationMatch(location, resolvePath(to));
}
}
},
);
return active;
},
[location.pathname],
);
const openContent = (
<>
<div data-testid="login-button" className={classes.iconContainer}>
{itemIcon}
</div>
{text && (
<Typography variant="subtitle2" className={classes.label}>
{text}
</Typography>
)}
<div className={classes.secondaryAction}>{}</div>
</>
);
const closedContent = itemIcon;
return (
<SidebarItemWithSubmenuContext.Provider
value={{
isHoveredOn,
setIsHoveredOn,
}}
>
<div
onMouseLeave={handleMouseLeave}
className={classnames(isHoveredOn && classes.highlighted)}
>
<div
onMouseEnter={handleMouseEnter}
data-testid="item-with-submenu"
className={classnames(
classes.root,
isOpen ? classes.open : classes.closed,
isActive && classes.selected,
classes.highlightable,
isHoveredOn && classes.highlighted,
)}
>
{isOpen ? openContent : closedContent}
{!isHoveredOn && (
<ArrowRightIcon fontSize="small" className={classes.submenuArrow} />
)}
</div>
{isHoveredOn && children}
</div>
</SidebarItemWithSubmenuContext.Provider>
);
};
type SidebarItemBaseProps = {
icon: IconComponent;
@@ -370,7 +335,10 @@ export const WorkaroundNavLink = React.forwardRef<
);
});
export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
/**
* Common component used by SidebarItem & SidebarItemWithSubmenu
*/
const SidebarItemBase = forwardRef<any, SidebarItemProps>((props, ref) => {
const {
icon: Icon,
text,
@@ -399,8 +367,6 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
</Badge>
);
const closedContent = itemIcon;
const openContent = (
<>
<div data-testid="login-button" className={classes.iconContainer}>
@@ -415,7 +381,7 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
</>
);
const content = isOpen ? openContent : closedContent;
const content = isOpen ? openContent : itemIcon;
const childProps = {
onClick,
@@ -428,39 +394,6 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
),
};
let hasSubmenu = false;
let submenu: ReactNode;
const componentType = (
<SidebarSubmenu>
<></>
</SidebarSubmenu>
).type;
// Filter children for SidebarSubmenu components
const submenus = useElementFilter(children, elements =>
elements.getElements().filter(child => child.type === componentType),
);
// Error thrown if more than one SidebarSubmenu in a SidebarItem
if (submenus.length > 1) {
throw new Error(
'Cannot render more than one SidebarSubmenu inside a SidebarItem',
);
} else if (submenus.length === 1) {
hasSubmenu = true;
submenu = submenus[0];
}
if (hasSubmenu) {
return (
<SidebarItemWithSubmenu
text={text}
icon={Icon}
hasNotifications={hasNotifications}
>
{submenu}
</SidebarItemWithSubmenu>
);
}
if (isButtonItem(props)) {
return (
<button aria-label={text} {...childProps} ref={ref}>
@@ -483,6 +416,90 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
);
});
const SidebarItemWithSubmenu = ({
children,
...props
}: SidebarItemBaseProps & {
children: React.ReactElement<SidebarSubmenuProps>;
}) => {
const classes = useStyles();
const [isHoveredOn, setIsHoveredOn] = useState(false);
const location = useLocation();
const isActive = useLocationMatch(children, location);
const isSmallScreen = useMediaQuery<BackstageTheme>((theme: BackstageTheme) =>
theme.breakpoints.down('sm'),
);
const handleMouseEnter = () => {
setIsHoveredOn(true);
};
const handleMouseLeave = () => {
setIsHoveredOn(false);
};
const arrowIcon = () => {
if (isSmallScreen) {
return isHoveredOn ? (
<ArrowDropUp fontSize="small" className={classes.submenuArrow} />
) : (
<ArrowDropDown fontSize="small" className={classes.submenuArrow} />
);
}
return (
!isHoveredOn && (
<ArrowRightIcon fontSize="small" className={classes.submenuArrow} />
)
);
};
return (
<SidebarItemWithSubmenuContext.Provider
value={{
isHoveredOn,
setIsHoveredOn,
}}
>
<div
data-testid="item-with-submenu"
onMouseLeave={handleMouseLeave}
onTouchStart={isHoveredOn ? handleMouseLeave : handleMouseEnter}
onMouseEnter={handleMouseEnter}
className={classnames(isHoveredOn && classes.highlighted)}
>
<SidebarItemBase
className={isActive ? classes.selected : ''}
{...props}
>
{arrowIcon()}
</SidebarItemBase>
{isHoveredOn && children}
</div>
</SidebarItemWithSubmenuContext.Provider>
);
};
/**
* Creates a `SidebarItem`
*
* If children contain a `SidebarSubmenu` component the `SidebarItem` will have a expandable submenu
*/
export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
// Filter children for SidebarSubmenu components
const [submenu] = useElementFilter(props.children, elements =>
elements.getElements().filter(child => child.type === SidebarSubmenu),
);
if (submenu) {
return (
<SidebarItemWithSubmenu {...props}>
{submenu as React.ReactElement<SidebarSubmenuProps>}
</SidebarItemWithSubmenu>
);
}
return <SidebarItemBase {...props} ref={ref} />;
});
type SidebarSearchFieldProps = {
onSearch: (input: string) => void;
to?: string;
@@ -609,3 +626,41 @@ export const SidebarScrollWrapper = styled('div')(({ theme }) => {
'&:hover': scrollbarStyles,
};
});
/**
* A button which allows you to expand the sidebar when clicked.
* Use optionally to replace sidebar's expand-on-hover feature with expand-on-click.
*
* If you are using this you might want to set the `disableExpandOnHover` of the `Sidebar` to `true`.
*
* @public
*/
export const SidebarExpandButton = () => {
const classes = useStyles();
const { isOpen, setOpen } = useContext(SidebarContext);
const isSmallScreen = useMediaQuery<BackstageTheme>(
theme => theme.breakpoints.down('md'),
{ noSsr: true },
);
if (isSmallScreen) {
return null;
}
const handleClick = () => {
setOpen(!isOpen);
};
return (
<button
onClick={handleClick}
className={classes.expandButton}
aria-label="Expand Sidebar"
data-testid="sidebar-expand-button"
>
<div className={classes.arrows}>
{isOpen ? <DoubleArrowLeft /> : <DoubleArrowRight />}
</div>
</button>
);
};
@@ -0,0 +1,105 @@
/*
* Copyright 2020 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 { mockBreakpoint, renderInTestApp } from '@backstage/test-utils';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import HomeIcon from '@material-ui/icons/Home';
import LayersIcon from '@material-ui/icons/Layers';
import LibraryBooks from '@material-ui/icons/LibraryBooks';
import { fireEvent } from '@testing-library/react';
import React from 'react';
import {
MobileSidebar,
Sidebar,
SidebarGroup,
SidebarItem,
SidebarPage,
} from '.';
const MobileSidebarWithGroups = () => (
<SidebarPage>
<MobileSidebar>
<h1>Header</h1>
<SidebarGroup icon={<HomeIcon />} label="Menu">
<SidebarItem icon={HomeIcon} to="/" text="Home" />
<SidebarItem icon={LayersIcon} to="/" text="Explore" />
<SidebarItem icon={LibraryBooks} to="/" text="Docs" />
</SidebarGroup>
<div>Content</div>
<div>More Content</div>
<SidebarGroup icon={<CreateComponentIcon />} label="Create" to="#" />
<footer>Footer</footer>
</MobileSidebar>
</SidebarPage>
);
const MobileSidebarWithoutGroups = () => (
<SidebarPage>
<MobileSidebar>
<SidebarItem icon={HomeIcon} to="/one" text="Home" />
<SidebarItem icon={LayersIcon} to="/two" text="Explore" />
<SidebarItem icon={LibraryBooks} to="/three" text="Docs" />
</MobileSidebar>
</SidebarPage>
);
describe('<MobileSidebar />', () => {
beforeEach(() => {
mockBreakpoint({ matches: true });
});
it('should render MobileSidebar on smaller screens', async () => {
const { getByTestId } = await renderInTestApp(
<SidebarPage>
<Sidebar>
<SidebarItem icon={HomeIcon} to="/one" text="Home" />
</Sidebar>
</SidebarPage>,
);
expect(getByTestId('mobile-sidebar-root')).toBeVisible();
});
it('should render only SidebarGroups inside MobileSidebar', async () => {
const { findAllByRole, getByTestId, findByText } = await renderInTestApp(
<MobileSidebarWithGroups />,
);
expect(getByTestId('mobile-sidebar-root').children.length).toBe(2);
expect((await findAllByRole('button')).length).toBe(2);
expect(await findByText('Menu')).toBeValid();
expect(await findByText('Create')).toBeValid();
});
it('should render default MobileSidebar when there are no SidebarGroups', async () => {
const { findAllByRole, getByTestId } = await renderInTestApp(
<MobileSidebarWithoutGroups />,
);
expect(getByTestId('mobile-sidebar-root').children.length).toBe(1);
const defaultSidebarGroup = await findAllByRole('button');
expect(defaultSidebarGroup.length).toBe(1);
});
it('should render OverlayMenu displaying SidebarItems', async () => {
const { findByText, getByRole } = await renderInTestApp(
<MobileSidebarWithGroups />,
);
const menuButton = await findByText('Menu');
fireEvent.click(menuButton);
expect(getByRole('heading', { name: 'Menu' })).toBeVisible();
expect(getByRole('link', { name: 'Home' })).toBeVisible();
expect(getByRole('link', { name: 'Explore' })).toBeVisible();
expect(getByRole('link', { name: 'Docs' })).toBeVisible();
});
});
@@ -0,0 +1,215 @@
/*
* Copyright 2020 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 { useElementFilter } from '@backstage/core-plugin-api';
import { BackstageTheme } from '@backstage/theme';
import BottomNavigation from '@material-ui/core/BottomNavigation';
import Box from '@material-ui/core/Box';
import IconButton from '@material-ui/core/IconButton';
import { makeStyles } from '@material-ui/core/styles';
import Drawer from '@material-ui/core/Drawer';
import Typography from '@material-ui/core/Typography';
import CloseIcon from '@material-ui/icons/Close';
import MenuIcon from '@material-ui/icons/Menu';
import { orderBy } from 'lodash';
import React, { createContext, useEffect, useState } from 'react';
import { useLocation } from 'react-router';
import { SidebarContext } from '.';
import { sidebarConfig } from './config';
import { SidebarGroup } from './SidebarGroup';
/**
* Type of `MobileSidebarContext`
*
* @internal
*/
export type MobileSidebarContextType = {
selectedMenuItemIndex: number;
setSelectedMenuItemIndex: React.Dispatch<React.SetStateAction<number>>;
};
/**
* Props of MobileSidebar
*
* @public
*/
export type MobileSidebarProps = {
children?: React.ReactNode;
};
/**
* @internal
*/
type OverlayMenuProps = {
label?: string;
onClose: () => void;
open: boolean;
children?: React.ReactNode;
};
const useStyles = makeStyles<BackstageTheme>(theme => ({
root: {
position: 'fixed',
backgroundColor: theme.palette.navigation.background,
color: theme.palette.navigation.color,
bottom: 0,
left: 0,
right: 0,
zIndex: theme.zIndex.snackbar,
// SidebarDivider color
borderTop: '1px solid #383838',
},
overlay: {
background: theme.palette.navigation.background,
width: '100%',
bottom: `${sidebarConfig.mobileSidebarHeight}px`,
height: `calc(100% - ${sidebarConfig.mobileSidebarHeight}px)`,
flex: '0 1 auto',
overflow: 'auto',
},
overlayHeader: {
display: 'flex',
color: theme.palette.bursts.fontColor,
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing(2, 3),
},
overlayHeaderClose: {
color: theme.palette.bursts.fontColor,
},
}));
const sortSidebarGroupsForPriority = (children: React.ReactElement[]) =>
orderBy(
children,
({ props: { priority } }) => (Number.isInteger(priority) ? priority : -1),
'desc',
);
const OverlayMenu = ({
children,
label = 'Menu',
open,
onClose,
}: OverlayMenuProps) => {
const classes = useStyles();
return (
<Drawer
anchor="bottom"
open={open}
onClose={onClose}
classes={{ paperAnchorBottom: classes.overlay }}
>
<Box className={classes.overlayHeader}>
<Typography variant="h3">{label}</Typography>
<IconButton
onClick={onClose}
classes={{ root: classes.overlayHeaderClose }}
>
<CloseIcon />
</IconButton>
</Box>
<Box>{children}</Box>
</Drawer>
);
};
/**
* Context on which `SidebarGroup` is currently selected
*
* @internal
*/
export const MobileSidebarContext = createContext<MobileSidebarContextType>({
selectedMenuItemIndex: -1,
setSelectedMenuItemIndex: () => {},
});
/**
* A navigation component for mobile screens, which sticks to the bottom.
*
* It alternates the normal sidebar by grouping the `SidebarItems` based on provided `SidebarGroup`s
* either rendering them as a link or an overlay menu.
* If no `SidebarGroup`s are provided the sidebar content is wrapped in an default overlay menu.
*
* @public
*/
export const MobileSidebar = (props: MobileSidebarProps) => {
const { children } = props;
const classes = useStyles();
const location = useLocation();
const [selectedMenuItemIndex, setSelectedMenuItemIndex] =
useState<number>(-1);
useEffect(() => {
setSelectedMenuItemIndex(-1);
}, [location.pathname]);
// Filter children for SidebarGroups
let sidebarGroups = useElementFilter(children, elements =>
elements.getElements().filter(child => child.type === SidebarGroup),
);
if (!children) {
// If Sidebar has no children the MobileSidebar won't be rendered
return null;
} else if (!sidebarGroups.length) {
// If Sidebar has no SidebarGroup as a children a default
// SidebarGroup with the complete Sidebar content will be created
sidebarGroups.push(
<SidebarGroup key="default_menu" icon={<MenuIcon />}>
{children}
</SidebarGroup>,
);
} else {
// Sort SidebarGroups for the given Priority
sidebarGroups = sortSidebarGroupsForPriority(sidebarGroups);
}
const shouldShowGroupChildren =
selectedMenuItemIndex >= 0 &&
!sidebarGroups[selectedMenuItemIndex].props.to;
return (
<SidebarContext.Provider value={{ isOpen: true, setOpen: () => {} }}>
<MobileSidebarContext.Provider
value={{ selectedMenuItemIndex, setSelectedMenuItemIndex }}
>
<OverlayMenu
label={
sidebarGroups[selectedMenuItemIndex] &&
(sidebarGroups[selectedMenuItemIndex]!.props.label as string)
}
open={shouldShowGroupChildren}
onClose={() => setSelectedMenuItemIndex(-1)}
>
{sidebarGroups[selectedMenuItemIndex] &&
(sidebarGroups[selectedMenuItemIndex].props
.children as React.ReactChildren)}
</OverlayMenu>
<BottomNavigation
className={classes.root}
data-testid="mobile-sidebar-root"
>
{sidebarGroups}
</BottomNavigation>
</MobileSidebarContext.Provider>
</SidebarContext.Provider>
);
};
@@ -15,46 +15,67 @@
*/
import { makeStyles } from '@material-ui/core/styles';
import React, {
createContext,
PropsWithChildren,
useEffect,
useState,
} from 'react';
import React, { createContext, useEffect, useState } from 'react';
import { sidebarConfig } from './config';
import { BackstageTheme } from '@backstage/theme';
import { LocalStorage } from './localStorage';
import useMediaQuery from '@material-ui/core/useMediaQuery';
export type SidebarPageClassKey = 'root';
const useStyles = makeStyles<BackstageTheme, { isPinned: boolean }>(
{
theme => ({
root: {
width: '100%',
minHeight: '100%',
transition: 'padding-left 0.1s ease-out',
paddingLeft: ({ isPinned }) =>
isPinned
? sidebarConfig.drawerWidthOpen
: sidebarConfig.drawerWidthClosed,
[theme.breakpoints.up('sm')]: {
paddingLeft: ({ isPinned }) =>
isPinned
? sidebarConfig.drawerWidthOpen
: sidebarConfig.drawerWidthClosed,
},
[theme.breakpoints.down('xs')]: {
paddingBottom: sidebarConfig.mobileSidebarHeight,
},
},
},
}),
{ name: 'BackstageSidebarPage' },
);
/**
* Type of `SidebarPinStateContext`
*
* @public
*/
export type SidebarPinStateContextType = {
isPinned: boolean;
toggleSidebarPinState: () => any;
isMobile?: boolean;
};
/**
* Props for SidebarPage
*
* @public
*/
export type SidebarPageProps = {
children?: React.ReactNode;
};
/**
* Contains the state on how the `Sidebar` is rendered
*
* @public
*/
export const SidebarPinStateContext = createContext<SidebarPinStateContextType>(
{
isPinned: true,
toggleSidebarPinState: () => {},
isMobile: false,
},
);
export function SidebarPage(props: PropsWithChildren<{}>) {
export function SidebarPage(props: SidebarPageProps) {
const [isPinned, setIsPinned] = useState(() =>
LocalStorage.getSidebarPinState(),
);
@@ -63,6 +84,11 @@ export function SidebarPage(props: PropsWithChildren<{}>) {
LocalStorage.setSidebarPinState(isPinned);
}, [isPinned]);
const isMobile = useMediaQuery<BackstageTheme>(
theme => theme.breakpoints.down('xs'),
{ noSsr: true },
);
const toggleSidebarPinState = () => setIsPinned(!isPinned);
const classes = useStyles({ isPinned });
@@ -71,6 +97,7 @@ export function SidebarPage(props: PropsWithChildren<{}>) {
value={{
isPinned,
toggleSidebarPinState,
isMobile,
}}
>
<div className={classes.root}>{props.children}</div>
@@ -13,36 +13,41 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createRouteRef } from '@backstage/core-plugin-api';
import { wrapInTestApp } from '@backstage/test-utils';
import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined';
import MenuIcon from '@material-ui/icons/Menu';
import BuildRoundedIcon from '@material-ui/icons/BuildRounded';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import MenuBookIcon from '@material-ui/icons/MenuBook';
import CloudQueueIcon from '@material-ui/icons/CloudQueue';
import AcUnitIcon from '@material-ui/icons/AcUnit';
import AppsIcon from '@material-ui/icons/Apps';
import React, { ComponentType } from 'react';
import {
Sidebar,
SidebarDivider,
SidebarGroup,
SidebarExpandButton,
SidebarIntro,
SidebarItem,
SidebarPage,
SidebarSearchField,
SidebarSpace,
SidebarSubmenu,
SidebarSubmenuItem,
} from '.';
import { SidebarSubmenuItem } from './SidebarSubmenuItem';
import MenuBookIcon from '@material-ui/icons/MenuBook';
import CloudQueueIcon from '@material-ui/icons/CloudQueue';
import AppsIcon from '@material-ui/icons/Apps';
import AcUnitIcon from '@material-ui/icons/AcUnit';
import { SidebarSubmenu } from './SidebarSubmenu';
const routeRef = createRouteRef({
id: 'storybook.test-route',
});
export default {
title: 'Layout/Sidebar',
component: Sidebar,
decorators: [
(storyFn: () => JSX.Element) => (
<MemoryRouter initialEntries={['/']}>{storyFn()}</MemoryRouter>
),
(Story: ComponentType<{}>) =>
wrapInTestApp(<Story />, { mountedRoutes: { '/': routeRef } }),
],
};
@@ -54,13 +59,15 @@ const handleSearch = (input: string) => {
export const SampleSidebar = () => (
<SidebarPage>
<Sidebar>
<SidebarSearchField onSearch={handleSearch} to="/search" />
<SidebarDivider />
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" />
<SidebarItem icon={AddCircleOutlineIcon} to="#" text="Create..." />
<SidebarDivider />
<SidebarIntro />
<SidebarSpace />
<SidebarGroup label="Menu" icon={MenuIcon}>
<SidebarSearchField onSearch={handleSearch} to="/search" />
<SidebarDivider />
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" />
<SidebarItem icon={AddCircleOutlineIcon} to="#" text="Create..." />
<SidebarDivider />
<SidebarIntro />
<SidebarSpace />
</SidebarGroup>
</Sidebar>
</SidebarPage>
);
@@ -70,30 +77,32 @@ export const SampleScalableSidebar = () => (
<Sidebar disableExpandOnHover>
<SidebarSearchField onSearch={handleSearch} to="/search" />
<SidebarDivider />
<SidebarItem icon={MenuBookIcon} text="Catalog">
<SidebarSubmenu title="Catalog">
<SidebarSubmenuItem title="Tools" to="/1" icon={BuildRoundedIcon} />
<SidebarSubmenuItem title="APIs" to="/2" icon={CloudQueueIcon} />
<SidebarSubmenuItem title="Components" to="/3" icon={AppsIcon} />
<SidebarSubmenuItem
title="Misc"
to="/6"
icon={AcUnitIcon}
dropdownItems={[
{
title: 'Lorem Ipsum',
to: '/7',
},
{
title: 'Lorem Ipsum',
to: '/8',
},
]}
/>
</SidebarSubmenu>
</SidebarItem>
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" />
<SidebarItem icon={AddCircleOutlineIcon} to="#" text="Create..." />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
<SidebarItem icon={MenuBookIcon} text="Catalog">
<SidebarSubmenu title="Catalog">
<SidebarSubmenuItem title="Tools" to="/1" icon={BuildRoundedIcon} />
<SidebarSubmenuItem title="APIs" to="/2" icon={CloudQueueIcon} />
<SidebarSubmenuItem title="Components" to="/3" icon={AppsIcon} />
<SidebarSubmenuItem
title="Misc"
to="/6"
icon={AcUnitIcon}
dropdownItems={[
{
title: 'Lorem Ipsum',
to: '/7',
},
{
title: 'Lorem Ipsum',
to: '/8',
},
]}
/>
</SidebarSubmenu>
</SidebarItem>
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" />
<SidebarItem icon={AddCircleOutlineIcon} to="#" text="Create..." />
</SidebarGroup>
<SidebarDivider />
<SidebarIntro />
<SidebarSpace />
@@ -0,0 +1,72 @@
/*
* Copyright 2020 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 { mockBreakpoint, renderInTestApp } from '@backstage/test-utils';
import HomeIcon from '@material-ui/icons/Home';
import LayersIcon from '@material-ui/icons/Layers';
import LibraryBooks from '@material-ui/icons/LibraryBooks';
import { fireEvent } from '@testing-library/react';
import React from 'react';
import { MobileSidebarContext } from './MobileSidebar';
import { SidebarGroup, SidebarItem, SidebarPage } from '.';
const SidebarGroupWithItems = () => (
<SidebarPage>
<SidebarGroup icon={<HomeIcon />} label="Menu">
<SidebarItem icon={HomeIcon} to="/one" text="Home" />
<SidebarItem icon={LayersIcon} to="/two" text="Explore" />
<SidebarItem icon={LibraryBooks} to="/three" text="Docs" />
</SidebarGroup>
</SidebarPage>
);
describe('<SidebarGroup />', () => {
it('should render Items in BottomNavigationAction on small screens', async () => {
mockBreakpoint({ matches: true });
const { getByRole, getAllByRole } = await renderInTestApp(
<SidebarGroupWithItems />,
);
expect(getAllByRole('button').length).toEqual(1);
expect(getByRole('button')).toBeVisible();
});
it('should render Items without wrapper on bigger screens', async () => {
mockBreakpoint({ matches: false });
const { getByRole, queryByRole } = await renderInTestApp(
<SidebarGroupWithItems />,
);
expect(queryByRole('button')).not.toBeInTheDocument();
expect(getByRole('link', { name: 'Home' })).toBeVisible();
expect(getByRole('link', { name: 'Explore' })).toBeVisible();
expect(getByRole('link', { name: 'Docs' })).toBeVisible();
});
it('should trigger update of MobileSidebarContext', async () => {
mockBreakpoint({ matches: true });
const value = {
selectedMenuItemIndex: -1,
setSelectedMenuItemIndex: jest.fn(),
};
const { findByRole } = await renderInTestApp(
<MobileSidebarContext.Provider value={value}>
<SidebarGroupWithItems />
</MobileSidebarContext.Provider>,
);
const group = await findByRole('button');
fireEvent.click(group);
expect(value.setSelectedMenuItemIndex).toHaveBeenCalled();
});
});
@@ -0,0 +1,130 @@
/* eslint-disable @typescript-eslint/no-shadow */
/*
* Copyright 2020 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 { BackstageTheme } from '@backstage/theme';
import BottomNavigationAction, {
BottomNavigationActionProps,
} from '@material-ui/core/BottomNavigationAction';
import { makeStyles } from '@material-ui/core/styles';
import React, { useContext } from 'react';
import { useLocation } from 'react-router-dom';
import { SidebarPinStateContext } from '.';
import { Link } from '../../components';
import { sidebarConfig } from './config';
import { MobileSidebarContext } from './MobileSidebar';
/**
* Props for the `SidebarGroup`
*
* @public
*/
export interface SidebarGroupProps extends BottomNavigationActionProps {
/**
* If the `SidebarGroup` should be a `Link`, `to` should be a pathname to that location
*/
to?: string;
/**
* If the `SidebarGroup`s should be in a different order than in the normal `Sidebar`, you can provide
* each `SidebarGroup` it's own priority to reorder them.
*/
priority?: number;
/**
* React children
*/
children?: React.ReactNode;
}
const useStyles = makeStyles<BackstageTheme>(theme => ({
root: {
flexGrow: 0,
margin: theme.spacing(0, 2),
color: theme.palette.navigation.color,
},
selected: {
color: `${theme.palette.navigation.selectedColor}!important`,
borderTop: `solid ${sidebarConfig.selectedIndicatorWidth}px ${theme.palette.navigation.indicator}`,
marginTop: '-1px',
},
label: {
display: 'none',
},
}));
/**
* Returns a MUI `BottomNavigationAction`, which is aware of the current location & the selected item in the `BottomNavigation`,
* such that it will highlight a `MobileSidebarGroup` either on location change or if the selected item changes.
*
* @param props `to`: pathname of link; `value`: index of the selected item
* @internal
*/
const MobileSidebarGroup = (props: SidebarGroupProps) => {
const { to, label, icon, value } = props;
const classes = useStyles();
const location = useLocation();
const { selectedMenuItemIndex, setSelectedMenuItemIndex } =
useContext(MobileSidebarContext);
const onChange = (_: React.ChangeEvent<{}>, value: number) => {
if (value === selectedMenuItemIndex) {
setSelectedMenuItemIndex(-1);
} else {
setSelectedMenuItemIndex(value);
}
};
const selected =
(value === selectedMenuItemIndex && selectedMenuItemIndex >= 0) ||
(!(value === selectedMenuItemIndex) &&
!(selectedMenuItemIndex >= 0) &&
to === location.pathname);
return (
// Material UI issue: https://github.com/mui-org/material-ui/issues/27820
// @ts-ignore
<BottomNavigationAction
label={label}
icon={icon}
component={Link}
to={to ? to : location.pathname}
onChange={onChange}
value={value}
selected={selected}
classes={classes}
/>
);
};
/**
* Groups items of the `Sidebar` together.
*
* On bigger screens, this won't have any effect at the moment.
* On small screens, it will add an action to the bottom navigation - either triggering an overlay menu or acting as a link
*
* @public
*/
export const SidebarGroup = (props: SidebarGroupProps) => {
const { children, to, label, icon, value } = props;
const { isMobile } = useContext(SidebarPinStateContext);
return isMobile ? (
<MobileSidebarGroup to={to} label={label} icon={icon} value={value} />
) : (
<>{children}</>
);
};
@@ -38,7 +38,13 @@ const useStyles = (props: { left: number }) =>
flexFlow: 'column nowrap',
alignItems: 'flex-start',
position: 'fixed',
left: props.left,
[theme.breakpoints.up('sm')]: {
marginLeft: props.left,
transition: theme.transitions.create('margin-left', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.shortest,
}),
},
top: 0,
bottom: 0,
padding: 0,
@@ -58,12 +64,22 @@ const useStyles = (props: { left: number }) =>
},
drawerOpen: {
width: submenuConfig.drawerWidthOpen,
[theme.breakpoints.down('xs')]: {
width: '100%',
position: 'relative',
paddingLeft: theme.spacing(3),
left: 0,
top: 0,
},
},
title: {
fontSize: 24,
fontWeight: 500,
color: '#FFF',
padding: 20,
[theme.breakpoints.down('xs')]: {
display: 'none',
},
},
}));
@@ -88,7 +104,7 @@ export const SidebarSubmenu = (props: SidebarSubmenuProps) => {
const left = isOpen
? sidebarConfig.drawerWidthOpen
: sidebarConfig.drawerWidthClosed;
const classes = useStyles({ left: left })();
const classes = useStyles({ left })();
const { isHoveredOn } = useContext(SidebarItemWithSubmenuContext);
const [isSubmenuOpen, setIsSubmenuOpen] = useState(false);
@@ -77,6 +77,10 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
color: theme.palette.navigation.color,
display: 'flex',
justifyContent: 'center',
[theme.breakpoints.down('xs')]: {
display: 'block',
paddingLeft: theme.spacing(4),
},
fontSize: '14px',
},
}));
@@ -139,6 +143,7 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {
<div className={classes.itemContainer}>
<button
onClick={handleClickDropdown}
onTouchStart={e => e.stopPropagation()}
className={classnames(
classes.item,
isActive ? classes.selected : undefined,
@@ -163,6 +168,7 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {
underline="none"
className={classes.dropdownItem}
onClick={closeSubmenu}
onTouchStart={e => e.stopPropagation()}
key={key}
>
<Typography className={classes.textContent}>
@@ -187,6 +193,7 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {
isActive ? classes.selected : undefined,
)}
onClick={closeSubmenu}
onTouchStart={e => e.stopPropagation()}
>
<Icon fontSize="small" />
<Typography variant="subtitle1" className={classes.label}>
@@ -35,6 +35,7 @@ export const sidebarConfig = {
selectedIndicatorWidth: 3,
userBadgePadding,
userBadgeDiameter: drawerWidthClosed - userBadgePadding * 2,
mobileSidebarHeight: 56,
};
export const submenuConfig = {
@@ -46,14 +47,20 @@ export const submenuConfig = {
export const SIDEBAR_INTRO_LOCAL_STORAGE =
'@backstage/core/sidebar-intro-dismissed';
/**
* Types for the `SidebarContext`
*/
export type SidebarContextType = {
isOpen: boolean;
setOpen: (open: boolean) => void;
};
/**
* Context wether the `Sidebar` is open
*/
export const SidebarContext = createContext<SidebarContextType>({
isOpen: false,
setOpen: _open => {},
setOpen: () => {},
});
export type SidebarItemWithSubmenuContextType = {
@@ -14,7 +14,11 @@
* limitations under the License.
*/
export { Sidebar, SidebarExpandButton } from './Bar';
export { Sidebar } from './Bar';
export { MobileSidebar } from './MobileSidebar';
export type { MobileSidebarProps } from './MobileSidebar';
export { SidebarGroup } from './SidebarGroup';
export type { SidebarGroupProps } from './SidebarGroup';
export { SidebarSubmenuItem } from './SidebarSubmenuItem';
export { SidebarSubmenu } from './SidebarSubmenu';
export type { SidebarSubmenuProps } from './SidebarSubmenu';
@@ -22,9 +26,16 @@ export type {
SidebarSubmenuItemProps,
SidebarSubmenuItemDropdownItem,
} from './SidebarSubmenuItem';
export type { SidebarClassKey } from './Bar';
export { SidebarPage, SidebarPinStateContext } from './Page';
export type { SidebarPinStateContextType, SidebarPageClassKey } from './Page';
export type { SidebarClassKey, SidebarProps } from './Bar';
export {
SidebarPage,
SidebarPinStateContext as SidebarPinStateContext,
} from './Page';
export type {
SidebarPinStateContextType as SidebarPinStateContextType,
SidebarPageClassKey,
SidebarPageProps,
} from './Page';
export {
SidebarDivider,
SidebarItem,
@@ -32,6 +43,7 @@ export {
SidebarSpace,
SidebarSpacer,
SidebarScrollWrapper,
SidebarExpandButton,
} from './Items';
export type {
SidebarItemClassKey,
+1 -1
View File
@@ -16,7 +16,7 @@
function makeCreateEnv(config: Config) {
...
+ const permissions = ServerPerimssionClient.fromConfig(config, {
+ const permissions = ServerPermissionClient.fromConfig(config, {
+ discovery,
+ tokenManager,
+ });
@@ -24,21 +24,27 @@ import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import LogoFull from './LogoFull';
import LogoIcon from './LogoIcon';
import { NavLink } from 'react-router-dom';
import { Settings as SidebarSettings } from '@backstage/plugin-user-settings';
import {
Settings as SidebarSettings,
UserSettingsSignInAvatar,
} from '@backstage/plugin-user-settings';
import {
SidebarSearchModal,
SearchContextProvider,
} from '@backstage/plugin-search';
import {
Sidebar,
SidebarPage,
sidebarConfig,
SidebarContext,
SidebarItem,
SidebarDivider,
SidebarSpace,
SidebarGroup,
SidebarItem,
SidebarPage,
SidebarScrollWrapper,
SidebarSpace,
} from '@backstage/core-components';
import MenuIcon from '@material-ui/icons/Menu';
import SearchIcon from '@material-ui/icons/Search';
const useSidebarLogoStyles = makeStyles({
root: {
@@ -77,23 +83,33 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
<SidebarPage>
<Sidebar>
<SidebarLogo />
<SearchContextProvider>
<SidebarSearchModal />
</SearchContextProvider>
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
<SearchContextProvider>
<SidebarSearchModal />
</SearchContextProvider>{' '}
</SidebarGroup>
<SidebarDivider />
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
<SidebarItem icon={LibraryBooks} to="docs" text="Docs" />
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
{/* End global nav */}
<SidebarDivider />
<SidebarScrollWrapper>
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
</SidebarScrollWrapper>
<SidebarGroup label="Menu" icon={<MenuIcon />}>
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
<SidebarItem icon={LibraryBooks} to="docs" text="Docs" />
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
{/* End global nav */}
<SidebarDivider />
<SidebarScrollWrapper>
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
</SidebarScrollWrapper>
</SidebarGroup>
<SidebarSpace />
<SidebarDivider />
<SidebarSettings />
<SidebarGroup
label="Settings"
icon={<UserSettingsSignInAvatar />}
to="/settings"
>
<SidebarSettings />
</SidebarGroup>
</Sidebar>
{children}
</SidebarPage>
+2 -3
View File
@@ -46,8 +46,7 @@
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4",
"swagger-client": "3.16.1",
"swagger-ui-react": "^4.0.0-rc.3"
"swagger-ui-react": "^4.1.3"
},
"peerDependencies": {
"@types/react": "^16.13.1 || ^17.0.0",
@@ -63,7 +62,7 @@
"@testing-library/user-event": "^13.1.8",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"@types/swagger-ui-react": "^3.23.3",
"@types/swagger-ui-react": "^4.1.1",
"cross-fetch": "^3.0.6",
"msw": "^0.35.0"
},
@@ -148,7 +148,7 @@ export const OpenApiDefinition = ({ definition }: OpenApiDefinitionProps) => {
return (
<div className={classes.root}>
<SwaggerUI spec={def} deepLinking />
<SwaggerUI spec={def} url="" deepLinking />
</div>
);
};
+1 -1
View File
@@ -91,7 +91,7 @@ export async function readConfigs(options: ReadOptions): Promise<AppConfig[]> {
const frontendConfigs = await schema.process(
[{ data: config.get() as JsonObject, context: 'app' }],
{ visibility: ['frontend'] },
{ visibility: ['frontend'], withDeprecatedKeys: true },
);
appConfigs.push(...frontendConfigs);
} catch (error) {
@@ -36,23 +36,23 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
) {}
async entities(request?: EntitiesRequest): Promise<EntitiesResponse> {
const authorizeResponse = (
const authorizeDecision = (
await this.permissionApi.authorize(
[{ permission: catalogEntityReadPermission }],
{ token: request?.authorizationToken },
)
)[0];
if (authorizeResponse.result === AuthorizeResult.DENY) {
if (authorizeDecision.result === AuthorizeResult.DENY) {
return {
entities: [],
pageInfo: { hasNextPage: false },
};
}
if (authorizeResponse.result === AuthorizeResult.CONDITIONAL) {
if (authorizeDecision.result === AuthorizeResult.CONDITIONAL) {
const permissionFilter: EntityFilter = this.transformConditions(
authorizeResponse.conditions,
authorizeDecision.conditions,
);
return this.entitiesCatalog.entities({
...request,
@@ -28,7 +28,7 @@ export class AuthorizedRefreshService implements RefreshService {
) {}
async refresh(options: RefreshOptions) {
const authorizeResponse = (
const authorizeDecision = (
await this.permissionApi.authorize(
[
{
@@ -39,7 +39,7 @@ export class AuthorizedRefreshService implements RefreshService {
{ token: options.authorizationToken },
)
)[0];
if (authorizeResponse.result !== AuthorizeResult.ALLOW) {
if (authorizeDecision.result !== AuthorizeResult.ALLOW) {
throw new NotAllowedError();
}
await this.service.refresh(options);
@@ -72,6 +72,8 @@ describe('NextEntitiesCatalog', () => {
target_entity_ref: stringifyEntityRef(entity),
});
}
return id;
}
async function addEntityToSearch(knex: Knex, entity: Entity) {
@@ -468,4 +470,69 @@ describe('NextEntitiesCatalog', () => {
},
);
});
describe('removeEntityByUid', () => {
it.each(databases.eachSupportedId())(
'also clears parent hashes',
async databaseId => {
const { knex } = await createDatabase(databaseId);
const grandparent: Entity = {
apiVersion: 'a',
kind: 'k',
metadata: { name: 'grandparent' },
spec: {},
};
const parent1: Entity = {
apiVersion: 'a',
kind: 'k',
metadata: { name: 'parent1' },
spec: {},
};
const parent2: Entity = {
apiVersion: 'a',
kind: 'k',
metadata: { name: 'parent2' },
spec: {},
};
const root: Entity = {
apiVersion: 'a',
kind: 'k',
metadata: { name: 'root' },
spec: {},
};
const unrelated: Entity = {
apiVersion: 'a',
kind: 'k',
metadata: { name: 'unrelated' },
spec: {},
};
await addEntity(knex, grandparent, [{ source: 's' }]);
await addEntity(knex, parent1, [{ entity: grandparent }]);
await addEntity(knex, parent2, [{ entity: grandparent }]);
const uid = await addEntity(knex, root, [
{ entity: parent1 },
{ entity: parent2 },
]);
await addEntity(knex, unrelated, []);
await knex('refresh_state').update({ result_hash: 'not-changed' });
const catalog = new NextEntitiesCatalog(knex);
await catalog.removeEntityByUid(uid);
await expect(
knex
.from('refresh_state')
.select('entity_ref', 'result_hash')
.orderBy('entity_ref'),
).resolves.toEqual([
{ entity_ref: 'k:default/grandparent', result_hash: 'not-changed' },
{ entity_ref: 'k:default/parent1', result_hash: 'child-was-deleted' },
{ entity_ref: 'k:default/parent2', result_hash: 'child-was-deleted' },
{ entity_ref: 'k:default/unrelated', result_hash: 'not-changed' },
]);
},
);
});
});
@@ -204,6 +204,29 @@ export class NextEntitiesCatalog implements EntitiesCatalog {
}
async removeEntityByUid(uid: string): Promise<void> {
// Clear the hashed state of the immediate parents of the deleted entity.
// This makes sure that when they get reprocessed, their output is written
// down again. The reason for wanting to do this, is that if the user
// deletes entities that ARE still emitted by the parent, the parent
// processing will still generate the same output hash as always, which
// means it'll never try to write down the children again (it assumes that
// they already exist). This means that without the code below, the database
// never "heals" from accidental deletes.
await this.database<DbRefreshStateRow>('refresh_state')
.update({
result_hash: 'child-was-deleted',
})
.whereIn('entity_ref', function parents(builder) {
return builder
.from<DbRefreshStateRow>('refresh_state')
.innerJoin<DbRefreshStateReferencesRow>('refresh_state_references', {
'refresh_state_references.target_entity_ref':
'refresh_state.entity_ref',
})
.where('refresh_state.entity_id', '=', uid)
.select('refresh_state_references.source_entity_ref');
});
await this.database<DbRefreshStateRow>('refresh_state')
.where('entity_id', uid)
.delete();
@@ -31,6 +31,7 @@ const useStyles = makeStyles({
},
itemText: {
width: '100%',
wordBreak: 'break-all',
marginBottom: '1rem',
},
});
+15 -9
View File
@@ -79,21 +79,27 @@ To expose the plugin to your users, you can integrate the `cost-insights` route
export const AppSidebar = () => (
<Sidebar>
<SidebarLogo />
<SidebarSearch />
<SidebarGroup icon={<SearchIcon />} to="/search">
<SidebarSearch />
</SidebarGroup>
<SidebarDivider />
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="./" text="Home" />
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
<SidebarItem icon={LibraryBooks} to="/docs" text="Docs" />
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
<SidebarDivider />
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
+ <SidebarItem icon={MoneyIcon} to="cost-insights" text="Cost Insights" />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
<SidebarItem icon={HomeIcon} to="./" text="Home" />
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
<SidebarItem icon={LibraryBooks} to="/docs" text="Docs" />
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
<SidebarDivider />
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
+ <SidebarItem icon={MoneyIcon} to="cost-insights" text="Cost Insights" />
</SidebarGroup>
{/* End global nav */}
<SidebarDivider />
<SidebarSpace />
<SidebarDivider />
<SidebarSettings />
<SidebarGroup icon={<UserSettingsSignInAvatar />} to="/settings">
<SidebarSettings />
</SidebarGroup>
</Sidebar>
);
```
+2
View File
@@ -27,6 +27,7 @@ export const EntityGroupProfileCard: ({
export const EntityMembersListCard: (_props: {
entity?: GroupEntity | undefined;
memberDisplayTitle?: string | undefined;
pageSize?: number | undefined;
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "EntityOwnershipCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -67,6 +68,7 @@ export const GroupProfileCard: ({
export const MembersListCard: (_props: {
entity?: GroupEntity;
memberDisplayTitle?: string;
pageSize?: number;
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "orgPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -112,9 +112,10 @@ export const MembersListCard = (_props: {
/** @deprecated The entity is now grabbed from context instead */
entity?: GroupEntity;
memberDisplayTitle?: string;
pageSize?: number;
}) => {
const { entity: groupEntity } = useEntity<GroupEntity>();
let { memberDisplayTitle } = _props;
let { memberDisplayTitle, pageSize } = _props;
const {
metadata: { name: groupName, namespace: grpNamespace },
spec: { profile },
@@ -129,7 +130,7 @@ export const MembersListCard = (_props: {
const pageChange = (_: React.ChangeEvent<unknown>, pageIndex: number) => {
setPage(pageIndex);
};
const pageSize = 50;
pageSize = pageSize ? pageSize : 50;
memberDisplayTitle = memberDisplayTitle ? memberDisplayTitle : 'Members';
const {
@@ -192,7 +193,7 @@ export const MembersListCard = (_props: {
) : (
<Box p={2}>
<Typography>
This group has no ${memberDisplayTitle.toLocaleLowerCase()}.
This group has no {memberDisplayTitle.toLocaleLowerCase()}.
</Typography>
</Box>
)}
@@ -103,22 +103,24 @@ describe('createRouter', () => {
it('calls the permission policy', async () => {
const response = await request(app)
.post('/authorize')
.send([
{
id: '123',
permission: {
name: 'test.permission1',
attributes: {},
.send({
items: [
{
id: '123',
permission: {
name: 'test.permission1',
attributes: {},
},
},
},
{
id: '234',
permission: {
name: 'test.permission2',
attributes: {},
{
id: '234',
permission: {
name: 'test.permission2',
attributes: {},
},
},
},
]);
],
});
expect(response.status).toEqual(200);
@@ -141,10 +143,12 @@ describe('createRouter', () => {
undefined,
);
expect(response.body).toEqual([
{ id: '123', result: AuthorizeResult.DENY },
{ id: '234', result: AuthorizeResult.DENY },
]);
expect(response.body).toEqual({
items: [
{ id: '123', result: AuthorizeResult.DENY },
{ id: '234', result: AuthorizeResult.DENY },
],
});
});
it('resolves identity from the Authorization header', async () => {
@@ -152,15 +156,17 @@ describe('createRouter', () => {
const response = await request(app)
.post('/authorize')
.auth(token, { type: 'bearer' })
.send([
{
id: '123',
permission: {
name: 'test.permission',
attributes: {},
.send({
items: [
{
id: '123',
permission: {
name: 'test.permission',
attributes: {},
},
},
},
]);
],
});
expect(response.status).toEqual(200);
expect(policy.handle).toHaveBeenCalledWith(
@@ -172,9 +178,9 @@ describe('createRouter', () => {
},
{ id: 'test-user', token: 'test-token' },
);
expect(response.body).toEqual([
{ id: '123', result: AuthorizeResult.ALLOW },
]);
expect(response.body).toEqual({
items: [{ id: '123', result: AuthorizeResult.ALLOW }],
});
});
describe('conditional policy result', () => {
@@ -188,27 +194,31 @@ describe('createRouter', () => {
const response = await request(app)
.post('/authorize')
.send([
{
id: '123',
permission: {
name: 'test.permission',
resourceType: 'test-resource-1',
attributes: {},
.send({
items: [
{
id: '123',
permission: {
name: 'test.permission',
resourceType: 'test-resource-1',
attributes: {},
},
},
},
]);
],
});
expect(response.status).toEqual(200);
expect(response.body).toEqual([
{
id: '123',
result: AuthorizeResult.CONDITIONAL,
pluginId: 'test-plugin',
resourceType: 'test-resource-1',
conditions: { rule: 'test-rule', params: ['abc'] },
},
]);
expect(response.body).toEqual({
items: [
{
id: '123',
result: AuthorizeResult.CONDITIONAL,
pluginId: 'test-plugin',
resourceType: 'test-resource-1',
conditions: { rule: 'test-rule', params: ['abc'] },
},
],
});
});
it('makes separate batched requests to multiple plugin backends', async () => {
@@ -241,44 +251,46 @@ describe('createRouter', () => {
const response = await request(app)
.post('/authorize')
.auth('test-token', { type: 'bearer' })
.send([
{
id: '123',
permission: {
name: 'test.permission.1',
resourceType: 'test-resource-1',
attributes: {},
.send({
items: [
{
id: '123',
permission: {
name: 'test.permission.1',
resourceType: 'test-resource-1',
attributes: {},
},
resourceRef: 'resource:1',
},
resourceRef: 'resource:1',
},
{
id: '234',
permission: {
name: 'test.permission.2',
resourceType: 'test-resource-2',
attributes: {},
{
id: '234',
permission: {
name: 'test.permission.2',
resourceType: 'test-resource-2',
attributes: {},
},
resourceRef: 'resource:2',
},
resourceRef: 'resource:2',
},
{
id: '345',
permission: {
name: 'test.permission.3',
resourceType: 'test-resource-1',
attributes: {},
{
id: '345',
permission: {
name: 'test.permission.3',
resourceType: 'test-resource-1',
attributes: {},
},
resourceRef: 'resource:3',
},
resourceRef: 'resource:3',
},
{
id: '456',
permission: {
name: 'test.permission.4',
resourceType: 'test-resource-2',
attributes: {},
{
id: '456',
permission: {
name: 'test.permission.4',
resourceType: 'test-resource-2',
attributes: {},
},
resourceRef: 'resource:4',
},
resourceRef: 'resource:4',
},
]);
],
});
expect(mockApplyConditions).toHaveBeenCalledWith(
'plugin-1',
@@ -319,12 +331,14 @@ describe('createRouter', () => {
);
expect(response.status).toEqual(200);
expect(response.body).toEqual([
{ id: '123', result: AuthorizeResult.ALLOW },
{ id: '234', result: AuthorizeResult.ALLOW },
{ id: '345', result: AuthorizeResult.DENY },
{ id: '456', result: AuthorizeResult.DENY },
]);
expect(response.body).toEqual({
items: [
{ id: '123', result: AuthorizeResult.ALLOW },
{ id: '234', result: AuthorizeResult.ALLOW },
{ id: '345', result: AuthorizeResult.DENY },
{ id: '456', result: AuthorizeResult.DENY },
],
});
});
it('leaves definitive results unchanged', async () => {
@@ -363,60 +377,62 @@ describe('createRouter', () => {
const response = await request(app)
.post('/authorize')
.auth('test-token', { type: 'bearer' })
.send([
{
id: '123',
permission: {
name: 'test.permission.1',
resourceType: 'test-resource-1',
attributes: {},
.send({
items: [
{
id: '123',
permission: {
name: 'test.permission.1',
resourceType: 'test-resource-1',
attributes: {},
},
resourceRef: 'resource:1',
},
resourceRef: 'resource:1',
},
{
id: '234',
permission: {
name: 'test.permission.2',
resourceType: 'test-resource-2',
attributes: {},
{
id: '234',
permission: {
name: 'test.permission.2',
resourceType: 'test-resource-2',
attributes: {},
},
resourceRef: 'resource:2',
},
resourceRef: 'resource:2',
},
{
id: '345',
permission: {
name: 'test.permission.3',
resourceType: 'test-resource-1',
attributes: {},
{
id: '345',
permission: {
name: 'test.permission.3',
resourceType: 'test-resource-1',
attributes: {},
},
resourceRef: 'resource:3',
},
resourceRef: 'resource:3',
},
{
id: '456',
permission: {
name: 'test.permission.4',
resourceType: 'test-resource-1',
attributes: {},
{
id: '456',
permission: {
name: 'test.permission.4',
resourceType: 'test-resource-1',
attributes: {},
},
resourceRef: 'resource:4',
},
resourceRef: 'resource:4',
},
{
id: '567',
permission: {
name: 'test.permission.5',
resourceType: 'test-resource-2',
attributes: {},
{
id: '567',
permission: {
name: 'test.permission.5',
resourceType: 'test-resource-2',
attributes: {},
},
resourceRef: 'resource:5',
},
resourceRef: 'resource:5',
},
{
id: '678',
permission: {
name: 'test.permission.6',
attributes: {},
{
id: '678',
permission: {
name: 'test.permission.6',
attributes: {},
},
},
},
]);
],
});
expect(mockApplyConditions).toHaveBeenCalledWith(
'plugin-1',
@@ -457,14 +473,16 @@ describe('createRouter', () => {
);
expect(response.status).toEqual(200);
expect(response.body).toEqual([
{ id: '123', result: AuthorizeResult.DENY },
{ id: '234', result: AuthorizeResult.DENY },
{ id: '345', result: AuthorizeResult.ALLOW },
{ id: '456', result: AuthorizeResult.ALLOW },
{ id: '567', result: AuthorizeResult.ALLOW },
{ id: '678', result: AuthorizeResult.DENY },
]);
expect(response.body).toEqual({
items: [
{ id: '123', result: AuthorizeResult.DENY },
{ id: '234', result: AuthorizeResult.DENY },
{ id: '345', result: AuthorizeResult.ALLOW },
{ id: '456', result: AuthorizeResult.ALLOW },
{ id: '567', result: AuthorizeResult.ALLOW },
{ id: '678', result: AuthorizeResult.DENY },
],
});
});
it('leaves conditional results without resourceRefs unchanged', async () => {
@@ -494,43 +512,45 @@ describe('createRouter', () => {
const response = await request(app)
.post('/authorize')
.auth('test-token', { type: 'bearer' })
.send([
{
id: '123',
permission: {
name: 'test.permission.1',
resourceType: 'test-resource-1',
attributes: {},
.send({
items: [
{
id: '123',
permission: {
name: 'test.permission.1',
resourceType: 'test-resource-1',
attributes: {},
},
resourceRef: 'resource:1',
},
resourceRef: 'resource:1',
},
{
id: '234',
permission: {
name: 'test.permission.2',
resourceType: 'test-resource-2',
attributes: {},
{
id: '234',
permission: {
name: 'test.permission.2',
resourceType: 'test-resource-2',
attributes: {},
},
resourceRef: 'resource:2',
},
resourceRef: 'resource:2',
},
{
id: '345',
permission: {
name: 'test.permission.3',
resourceType: 'test-resource-1',
attributes: {},
{
id: '345',
permission: {
name: 'test.permission.3',
resourceType: 'test-resource-1',
attributes: {},
},
resourceRef: 'resource:3',
},
resourceRef: 'resource:3',
},
{
id: '456',
permission: {
name: 'test.permission.4',
resourceType: 'test-resource-1',
attributes: {},
{
id: '456',
permission: {
name: 'test.permission.4',
resourceType: 'test-resource-1',
attributes: {},
},
},
},
]);
],
});
expect(mockApplyConditions).toHaveBeenCalledWith(
'plugin-1',
@@ -559,18 +579,20 @@ describe('createRouter', () => {
);
expect(response.status).toEqual(200);
expect(response.body).toEqual([
{ id: '123', result: AuthorizeResult.ALLOW },
{ id: '234', result: AuthorizeResult.ALLOW },
{ id: '345', result: AuthorizeResult.ALLOW },
{
id: '456',
result: AuthorizeResult.CONDITIONAL,
pluginId: 'plugin-1',
resourceType: 'test-resource-1',
conditions: { rule: 'test-rule', params: ['abc'] },
},
]);
expect(response.body).toEqual({
items: [
{ id: '123', result: AuthorizeResult.ALLOW },
{ id: '234', result: AuthorizeResult.ALLOW },
{ id: '345', result: AuthorizeResult.ALLOW },
{
id: '456',
result: AuthorizeResult.CONDITIONAL,
pluginId: 'plugin-1',
resourceType: 'test-resource-1',
conditions: { rule: 'test-rule', params: ['abc'] },
},
],
});
});
it.each<[ApplyConditionsResponseEntry['result'], string]>([
@@ -600,26 +622,28 @@ describe('createRouter', () => {
const response = await request(app)
.post('/authorize')
.auth('test-token', { type: 'bearer' })
.send([
{
id: '123',
resourceRef: 'test/resource',
permission: {
name: 'test.permission',
resourceType: 'test-resource-1',
attributes: {},
.send({
items: [
{
id: '123',
resourceRef: 'test/resource',
permission: {
name: 'test.permission',
resourceType: 'test-resource-1',
attributes: {},
},
},
},
{
id: '234',
resourceRef: 'test/resource',
permission: {
name: 'test.permission',
resourceType: 'test-resource-1',
attributes: {},
{
id: '234',
resourceRef: 'test/resource',
permission: {
name: 'test.permission',
resourceType: 'test-resource-1',
attributes: {},
},
},
},
]);
],
});
expect(mockApplyConditions).toHaveBeenCalledWith(
'test-plugin',
@@ -641,16 +665,18 @@ describe('createRouter', () => {
);
expect(response.status).toEqual(200);
expect(response.body).toEqual([
{
id: '123',
result,
},
{
id: '234',
result,
},
]);
expect(response.body).toEqual({
items: [
{
id: '123',
result,
},
{
id: '234',
result,
},
],
});
},
);
});
@@ -660,9 +686,14 @@ describe('createRouter', () => {
'',
{},
[{ permission: { name: 'test.permission', attributes: {} } }],
[{ id: '123' }],
[{ id: '123', permission: { name: 'test.permission' } }],
[{ id: '123', permission: { attributes: { invalid: 'attribute' } } }],
{ items: [{ permission: { name: 'test.permission', attributes: {} } }] },
{ items: [{ id: '123' }] },
{ items: [{ id: '123', permission: { name: 'test.permission' } }] },
{
items: [
{ id: '123', permission: { attributes: { invalid: 'attribute' } } },
],
},
])('returns a 400 error for invalid request %#', async requestBody => {
const response = await request(app).post('/authorize').send(requestBody);
@@ -686,16 +717,18 @@ describe('createRouter', () => {
const response = await request(app)
.post('/authorize')
.send([
{
id: '123',
permission: {
name: 'test.permission',
resourceType: 'test-resource-1',
attributes: {},
.send({
items: [
{
id: '123',
permission: {
name: 'test.permission',
resourceType: 'test-resource-1',
attributes: {},
},
},
},
]);
],
});
expect(response.status).toEqual(500);
expect(response.body).toEqual(
@@ -29,9 +29,11 @@ import {
} from '@backstage/plugin-auth-backend';
import {
AuthorizeResult,
AuthorizeResponse,
AuthorizeRequest,
AuthorizeDecision,
AuthorizeQuery,
Identified,
AuthorizeRequest,
AuthorizeResponse,
} from '@backstage/plugin-permission-common';
import {
ApplyConditionsRequestEntry,
@@ -42,26 +44,28 @@ import { PermissionIntegrationClient } from './PermissionIntegrationClient';
import { memoize } from 'lodash';
import DataLoader from 'dataloader';
const requestSchema: z.ZodSchema<Identified<AuthorizeRequest>[]> = z.array(
z.object({
id: z.string(),
resourceRef: z.string().optional(),
permission: z.object({
name: z.string(),
resourceType: z.string().optional(),
attributes: z.object({
action: z
.union([
z.literal('create'),
z.literal('read'),
z.literal('update'),
z.literal('delete'),
])
.optional(),
}),
const querySchema: z.ZodSchema<Identified<AuthorizeQuery>> = z.object({
id: z.string(),
resourceRef: z.string().optional(),
permission: z.object({
name: z.string(),
resourceType: z.string().optional(),
attributes: z.object({
action: z
.union([
z.literal('create'),
z.literal('read'),
z.literal('update'),
z.literal('delete'),
])
.optional(),
}),
}),
);
});
const requestSchema: z.ZodSchema<AuthorizeRequest> = z.object({
items: z.array(querySchema),
});
/**
* Options required when constructing a new {@link express#Router} using
@@ -77,12 +81,12 @@ export interface RouterOptions {
}
const handleRequest = async (
requests: Identified<AuthorizeRequest>[],
requests: Identified<AuthorizeQuery>[],
user: BackstageIdentityResponse | undefined,
policy: PermissionPolicy,
permissionIntegrationClient: PermissionIntegrationClient,
authHeader?: string,
): Promise<Identified<AuthorizeResponse>[]> => {
): Promise<Identified<AuthorizeDecision>[]> => {
const applyConditionsLoaderFor = memoize((pluginId: string) => {
return new DataLoader<
ApplyConditionsRequestEntry,
@@ -150,8 +154,8 @@ export async function createRouter(
router.post(
'/authorize',
async (
req: Request<Identified<AuthorizeRequest>[]>,
res: Response<Identified<AuthorizeResponse>[]>,
req: Request<AuthorizeRequest>,
res: Response<AuthorizeResponse>,
) => {
const token = IdentityClient.getBearerToken(req.header('authorization'));
const user = token ? await identity.authenticate(token) : undefined;
@@ -164,15 +168,15 @@ export async function createRouter(
const body = parseResult.data;
res.json(
await handleRequest(
body,
res.json({
items: await handleRequest(
body.items,
user,
policy,
permissionIntegrationClient,
req.header('authorization'),
),
);
});
},
);
+23 -13
View File
@@ -6,25 +6,35 @@
import { Config } from '@backstage/config';
// @public
export type AuthorizeRequest = {
export type AuthorizeDecision =
| {
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
}
| {
result: AuthorizeResult.CONDITIONAL;
conditions: PermissionCriteria<PermissionCondition>;
};
// @public
export type AuthorizeQuery = {
permission: Permission;
resourceRef?: string;
};
// @public
export type AuthorizeRequest = {
items: Identified<AuthorizeQuery>[];
};
// @public
export type AuthorizeRequestOptions = {
token?: string;
};
// @public
export type AuthorizeResponse =
| {
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
}
| {
result: AuthorizeResult.CONDITIONAL;
conditions: PermissionCriteria<PermissionCondition>;
};
export type AuthorizeResponse = {
items: Identified<AuthorizeDecision>[];
};
// @public
export enum AuthorizeResult {
@@ -71,18 +81,18 @@ export type PermissionAttributes = {
export interface PermissionAuthorizer {
// (undocumented)
authorize(
requests: AuthorizeRequest[],
queries: AuthorizeQuery[],
options?: AuthorizeRequestOptions,
): Promise<AuthorizeResponse[]>;
): Promise<AuthorizeDecision[]>;
}
// @public
export class PermissionClient implements PermissionAuthorizer {
constructor(options: { discovery: DiscoveryApi; config: Config });
authorize(
requests: AuthorizeRequest[],
queries: AuthorizeQuery[],
options?: AuthorizeRequestOptions,
): Promise<AuthorizeResponse[]>;
): Promise<AuthorizeDecision[]>;
}
// @public
@@ -18,7 +18,7 @@ import { RestContext, rest } from 'msw';
import { setupServer } from 'msw/node';
import { ConfigReader } from '@backstage/config';
import { PermissionClient } from './PermissionClient';
import { AuthorizeRequest, AuthorizeResult, Identified } from './types/api';
import { AuthorizeQuery, AuthorizeResult, Identified } from './types/api';
import { DiscoveryApi } from './types/discovery';
import { Permission } from './types/permission';
@@ -42,7 +42,7 @@ const mockPermission: Permission = {
resourceType: 'test-resource',
};
const mockAuthorizeRequest = {
const mockAuthorizeQuery = {
permission: mockPermission,
resourceRef: 'foo',
};
@@ -54,12 +54,12 @@ describe('PermissionClient', () => {
describe('authorize', () => {
const mockAuthorizeHandler = jest.fn((req, res, { json }: RestContext) => {
const responses = req.body.map((a: Identified<AuthorizeRequest>) => ({
const responses = req.body.items.map((a: Identified<AuthorizeQuery>) => ({
id: a.id,
result: AuthorizeResult.ALLOW,
}));
return res(json(responses));
return res(json({ items: responses }));
});
beforeEach(() => {
@@ -71,38 +71,41 @@ describe('PermissionClient', () => {
});
it('should fetch entities from correct endpoint', async () => {
await client.authorize([mockAuthorizeRequest]);
await client.authorize([mockAuthorizeQuery]);
expect(mockAuthorizeHandler).toHaveBeenCalled();
});
it('should include a request body', async () => {
await client.authorize([mockAuthorizeRequest]);
await client.authorize([mockAuthorizeQuery]);
const request = mockAuthorizeHandler.mock.calls[0][0];
expect(request.body[0]).toEqual(
expect.objectContaining({
permission: mockPermission,
resourceRef: 'foo',
}),
);
expect(request.body).toEqual({
items: [
expect.objectContaining({
permission: mockPermission,
resourceRef: 'foo',
}),
],
});
});
it('should return the response from the fetch request', async () => {
const response = await client.authorize([mockAuthorizeRequest]);
const response = await client.authorize([mockAuthorizeQuery]);
expect(response[0]).toEqual(
expect.objectContaining({ result: AuthorizeResult.ALLOW }),
);
});
it('should not include authorization headers if no token is supplied', async () => {
await client.authorize([mockAuthorizeRequest]);
await client.authorize([mockAuthorizeQuery]);
const request = mockAuthorizeHandler.mock.calls[0][0];
expect(request.headers.has('authorization')).toEqual(false);
});
it('should include correctly-constructed authorization header if token is supplied', async () => {
await client.authorize([mockAuthorizeRequest], { token });
await client.authorize([mockAuthorizeQuery], { token });
const request = mockAuthorizeHandler.mock.calls[0][0];
expect(request.headers.get('authorization')).toEqual('Bearer fake-token');
@@ -115,53 +118,59 @@ describe('PermissionClient', () => {
},
);
await expect(
client.authorize([mockAuthorizeRequest], { token }),
client.authorize([mockAuthorizeQuery], { token }),
).rejects.toThrowError(/request failed with 401/i);
});
it('should reject responses with missing ids', async () => {
mockAuthorizeHandler.mockImplementationOnce(
(_req, res, { json }: RestContext) => {
return res(json([{ id: 'wrong-id', result: AuthorizeResult.ALLOW }]));
return res(
json({
items: [{ id: 'wrong-id', result: AuthorizeResult.ALLOW }],
}),
);
},
);
await expect(
client.authorize([mockAuthorizeRequest], { token }),
client.authorize([mockAuthorizeQuery], { token }),
).rejects.toThrowError(/Unexpected authorization response/i);
});
it('should reject invalid responses', async () => {
mockAuthorizeHandler.mockImplementationOnce(
(req, res, { json }: RestContext) => {
const responses = req.body.map((a: Identified<AuthorizeRequest>) => ({
id: a.id,
outcome: AuthorizeResult.ALLOW,
}));
const responses = req.body.items.map(
(a: Identified<AuthorizeQuery>) => ({
id: a.id,
outcome: AuthorizeResult.ALLOW,
}),
);
return res(json(responses));
return res(json({ items: responses }));
},
);
await expect(
client.authorize([mockAuthorizeRequest], { token }),
client.authorize([mockAuthorizeQuery], { token }),
).rejects.toThrowError(/invalid input/i);
});
it('should allow all when permission.enabled is false', async () => {
mockAuthorizeHandler.mockImplementationOnce(
(req, res, { json }: RestContext) => {
const responses = req.body.map((a: Identified<AuthorizeRequest>) => ({
const responses = req.body.map((a: Identified<AuthorizeQuery>) => ({
id: a.id,
outcome: AuthorizeResult.DENY,
result: AuthorizeResult.DENY,
}));
return res(json(responses));
return res(json({ items: responses }));
},
);
const disabled = new PermissionClient({
discovery,
config: new ConfigReader({ permission: { enabled: false } }),
});
const response = await disabled.authorize([mockAuthorizeRequest]);
const response = await disabled.authorize([mockAuthorizeQuery]);
expect(response[0]).toEqual(
expect.objectContaining({ result: AuthorizeResult.ALLOW }),
);
@@ -171,7 +180,7 @@ describe('PermissionClient', () => {
it('should allow all when permission.enabled is not configured', async () => {
mockAuthorizeHandler.mockImplementationOnce(
(req, res, { json }: RestContext) => {
const responses = req.body.map((a: Identified<AuthorizeRequest>) => ({
const responses = req.body.map((a: Identified<AuthorizeQuery>) => ({
id: a.id,
outcome: AuthorizeResult.DENY,
}));
@@ -183,7 +192,7 @@ describe('PermissionClient', () => {
discovery,
config: new ConfigReader({}),
});
const response = await disabled.authorize([mockAuthorizeRequest]);
const response = await disabled.authorize([mockAuthorizeQuery]);
expect(response[0]).toEqual(
expect.objectContaining({ result: AuthorizeResult.ALLOW }),
);
@@ -21,11 +21,13 @@ import * as uuid from 'uuid';
import { z } from 'zod';
import {
AuthorizeResult,
AuthorizeRequest,
AuthorizeResponse,
AuthorizeQuery,
AuthorizeDecision,
Identified,
PermissionCriteria,
PermissionCondition,
AuthorizeResponse,
AuthorizeRequest,
} from './types/api';
import { DiscoveryApi } from './types/discovery';
import {
@@ -46,22 +48,24 @@ const permissionCriteriaSchema: z.ZodSchema<
.or(z.object({ not: permissionCriteriaSchema })),
);
const responseSchema = z.array(
z
.object({
id: z.string(),
result: z
.literal(AuthorizeResult.ALLOW)
.or(z.literal(AuthorizeResult.DENY)),
})
.or(
z.object({
const responseSchema = z.object({
items: z.array(
z
.object({
id: z.string(),
result: z.literal(AuthorizeResult.CONDITIONAL),
conditions: permissionCriteriaSchema,
}),
),
);
result: z
.literal(AuthorizeResult.ALLOW)
.or(z.literal(AuthorizeResult.DENY)),
})
.or(
z.object({
id: z.string(),
result: z.literal(AuthorizeResult.CONDITIONAL),
conditions: permissionCriteriaSchema,
}),
),
),
});
/**
* An isomorphic client for requesting authorization for Backstage permissions.
@@ -94,29 +98,29 @@ export class PermissionClient implements PermissionAuthorizer {
* @public
*/
async authorize(
requests: AuthorizeRequest[],
queries: AuthorizeQuery[],
options?: AuthorizeRequestOptions,
): Promise<AuthorizeResponse[]> {
): Promise<AuthorizeDecision[]> {
// TODO(permissions): it would be great to provide some kind of typing guarantee that
// conditional responses will only ever be returned for requests containing a resourceType
// but no resourceRef. That way clients who aren't prepared to handle filtering according
// to conditions can be guaranteed that they won't unexpectedly get a CONDITIONAL response.
if (!this.enabled) {
return requests.map(_ => ({ result: AuthorizeResult.ALLOW }));
return queries.map(_ => ({ result: AuthorizeResult.ALLOW }));
}
const identifiedRequests: Identified<AuthorizeRequest>[] = requests.map(
request => ({
const request: AuthorizeRequest = {
items: queries.map(query => ({
id: uuid.v4(),
...request,
}),
);
...query,
})),
};
const permissionApi = await this.discovery.getBaseUrl('permission');
const response = await fetch(`${permissionApi}/authorize`, {
method: 'POST',
body: JSON.stringify(identifiedRequests),
body: JSON.stringify(request),
headers: {
...this.getAuthorizationHeader(options?.token),
'content-type': 'application/json',
@@ -126,28 +130,30 @@ export class PermissionClient implements PermissionAuthorizer {
throw await ResponseError.fromResponse(response);
}
const identifiedResponses = await response.json();
this.assertValidResponses(identifiedRequests, identifiedResponses);
const responseBody = await response.json();
this.assertValidResponse(request, responseBody);
const responsesById = identifiedResponses.reduce((acc, r) => {
const responsesById = responseBody.items.reduce((acc, r) => {
acc[r.id] = r;
return acc;
}, {} as Record<string, Identified<AuthorizeResponse>>);
}, {} as Record<string, Identified<AuthorizeDecision>>);
return identifiedRequests.map(request => responsesById[request.id]);
return request.items.map(query => responsesById[query.id]);
}
private getAuthorizationHeader(token?: string): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}
private assertValidResponses(
requests: Identified<AuthorizeRequest>[],
private assertValidResponse(
request: AuthorizeRequest,
json: any,
): asserts json is Identified<AuthorizeResponse>[] {
): asserts json is AuthorizeResponse {
const authorizedResponses = responseSchema.parse(json);
const responseIds = authorizedResponses.map(r => r.id);
const hasAllRequestIds = requests.every(r => responseIds.includes(r.id));
const responseIds = authorizedResponses.items.map(r => r.id);
const hasAllRequestIds = request.items.every(r =>
responseIds.includes(r.id),
);
if (!hasAllRequestIds) {
throw new Error(
'Unexpected authorization response from permission-backend',
+21 -5
View File
@@ -43,12 +43,20 @@ export enum AuthorizeResult {
}
/**
* An authorization request for {@link PermissionClient#authorize}.
* An individual authorization request for {@link PermissionClient#authorize}.
* @public
*/
export type AuthorizeQuery = {
permission: Permission;
resourceRef?: string;
};
/**
* A batch of authorization requests from {@link PermissionClient#authorize}.
* @public
*/
export type AuthorizeRequest = {
permission: Permission;
resourceRef?: string;
items: Identified<AuthorizeQuery>[];
};
/**
@@ -75,12 +83,20 @@ export type PermissionCriteria<TQuery> =
| TQuery;
/**
* An authorization response from {@link PermissionClient#authorize}.
* An individual authorization response from {@link PermissionClient#authorize}.
* @public
*/
export type AuthorizeResponse =
export type AuthorizeDecision =
| { result: AuthorizeResult.ALLOW | AuthorizeResult.DENY }
| {
result: AuthorizeResult.CONDITIONAL;
conditions: PermissionCriteria<PermissionCondition>;
};
/**
* A batch of authorization responses from {@link PermissionClient#authorize}.
* @public
*/
export type AuthorizeResponse = {
items: Identified<AuthorizeDecision>[];
};
@@ -16,7 +16,9 @@
export { AuthorizeResult } from './api';
export type {
AuthorizeQuery,
AuthorizeRequest,
AuthorizeDecision,
AuthorizeResponse,
Identified,
PermissionCondition,
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { AuthorizeRequest, AuthorizeResponse } from './api';
import { AuthorizeQuery, AuthorizeDecision } from './api';
/**
* The attributes related to a given permission; these should be generic and widely applicable to
@@ -48,9 +48,9 @@ export type Permission = {
*/
export interface PermissionAuthorizer {
authorize(
requests: AuthorizeRequest[],
queries: AuthorizeQuery[],
options?: AuthorizeRequestOptions,
): Promise<AuthorizeResponse[]>;
): Promise<AuthorizeDecision[]>;
}
/**
+6 -6
View File
@@ -3,9 +3,9 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AuthorizeRequest } from '@backstage/plugin-permission-common';
import { AuthorizeDecision } from '@backstage/plugin-permission-common';
import { AuthorizeQuery } from '@backstage/plugin-permission-common';
import { AuthorizeRequestOptions } from '@backstage/plugin-permission-common';
import { AuthorizeResponse } from '@backstage/plugin-permission-common';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { BackstageIdentityResponse } from '@backstage/plugin-auth-backend';
import { Config } from '@backstage/config';
@@ -129,7 +129,7 @@ export const makeCreatePermissionRule: <TResource, TQuery>() => <
export interface PermissionPolicy {
// (undocumented)
handle(
request: PolicyAuthorizeRequest,
request: PolicyAuthorizeQuery,
user?: BackstageIdentityResponse,
): Promise<PolicyDecision>;
}
@@ -147,7 +147,7 @@ export type PermissionRule<
};
// @public
export type PolicyAuthorizeRequest = Omit<AuthorizeRequest, 'resourceRef'>;
export type PolicyAuthorizeQuery = Omit<AuthorizeQuery, 'resourceRef'>;
// @public
export type PolicyDecision =
@@ -158,9 +158,9 @@ export type PolicyDecision =
export class ServerPermissionClient implements PermissionAuthorizer {
// (undocumented)
authorize(
requests: AuthorizeRequest[],
queries: AuthorizeQuery[],
options?: AuthorizeRequestOptions,
): Promise<AuthorizeResponse[]>;
): Promise<AuthorizeDecision[]>;
// (undocumented)
static fromConfig(
config: Config,
@@ -18,7 +18,7 @@ import { ServerPermissionClient } from './ServerPermissionClient';
import {
Permission,
Identified,
AuthorizeRequest,
AuthorizeQuery,
AuthorizeResult,
} from '@backstage/plugin-permission-common';
import { ConfigReader } from '@backstage/config';
@@ -32,12 +32,12 @@ import { RestContext, rest } from 'msw';
const server = setupServer();
const mockAuthorizeHandler = jest.fn((req, res, { json }: RestContext) => {
const responses = req.body.map((r: Identified<AuthorizeRequest>) => ({
const responses = req.body.items.map((r: Identified<AuthorizeQuery>) => ({
id: r.id,
result: AuthorizeResult.ALLOW,
}));
return res(json(responses));
return res(json({ items: responses }));
});
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
const discovery: PluginEndpointDiscovery = {
@@ -20,9 +20,9 @@ import {
} from '@backstage/backend-common';
import { Config } from '@backstage/config';
import {
AuthorizeRequest,
AuthorizeQuery,
AuthorizeRequestOptions,
AuthorizeResponse,
AuthorizeDecision,
AuthorizeResult,
PermissionClient,
PermissionAuthorizer,
@@ -78,9 +78,9 @@ export class ServerPermissionClient implements PermissionAuthorizer {
}
async authorize(
requests: AuthorizeRequest[],
queries: AuthorizeQuery[],
options?: AuthorizeRequestOptions,
): Promise<AuthorizeResponse[]> {
): Promise<AuthorizeDecision[]> {
// Check if permissions are enabled before validating the server token. That
// way when permissions are disabled, the noop token manager can be used
// without fouling up the logic inside the ServerPermissionClient, because
@@ -89,9 +89,9 @@ export class ServerPermissionClient implements PermissionAuthorizer {
!this.permissionEnabled ||
(await this.isValidServerToken(options?.token))
) {
return requests.map(_ => ({ result: AuthorizeResult.ALLOW }));
return queries.map(_ => ({ result: AuthorizeResult.ALLOW }));
}
return this.permissionClient.authorize(requests, options);
return this.permissionClient.authorize(queries, options);
}
private async isValidServerToken(
+1 -1
View File
@@ -18,6 +18,6 @@ export type {
ConditionalPolicyDecision,
DefinitivePolicyDecision,
PermissionPolicy,
PolicyAuthorizeRequest,
PolicyAuthorizeQuery,
PolicyDecision,
} from './types';
+5 -5
View File
@@ -15,7 +15,7 @@
*/
import {
AuthorizeRequest,
AuthorizeQuery,
AuthorizeResult,
PermissionCondition,
PermissionCriteria,
@@ -27,13 +27,13 @@ import { BackstageIdentityResponse } from '@backstage/plugin-auth-backend';
*
* @remarks
*
* This differs from {@link @backstage/permission-common#AuthorizeRequest} in that `resourceRef`
* This differs from {@link @backstage/permission-common#AuthorizeQuery} in that `resourceRef`
* should never be provided. This forces policies to be written in a way that's compatible with
* filtering collections of resources at data load time.
*
* @public
*/
export type PolicyAuthorizeRequest = Omit<AuthorizeRequest, 'resourceRef'>;
export type PolicyAuthorizeQuery = Omit<AuthorizeQuery, 'resourceRef'>;
/**
* A definitive result to an authorization request, returned by the {@link PermissionPolicy}.
@@ -57,7 +57,7 @@ export type DefinitivePolicyDecision = {
* conditions hold when evaluated. The conditions will be evaluated by the corresponding plugin
* which knows about the referenced permission rules.
*
* Similar to {@link @backstage/permission-common#AuthorizeResult}, but with the plugin and resource
* Similar to {@link @backstage/permission-common#AuthorizeDecision}, but with the plugin and resource
* identifiers needed to evaluate the returned conditions.
* @public
*/
@@ -95,7 +95,7 @@ export type PolicyDecision =
*/
export interface PermissionPolicy {
handle(
request: PolicyAuthorizeRequest,
request: PolicyAuthorizeQuery,
user?: BackstageIdentityResponse,
): Promise<PolicyDecision>;
}
+1 -1
View File
@@ -18,7 +18,7 @@ import type { PermissionCriteria } from '@backstage/plugin-permission-common';
/**
* A conditional rule that can be provided in an
* {@link @backstage/permission-common#AuthorizeResult} response to an authorization request.
* {@link @backstage/permission-common#AuthorizeDecision} response to an authorization request.
*
* @remarks
*
+4 -4
View File
@@ -4,8 +4,8 @@
```ts
import { ApiRef } from '@backstage/core-plugin-api';
import { AuthorizeRequest } from '@backstage/plugin-permission-common';
import { AuthorizeResponse } from '@backstage/plugin-permission-common';
import { AuthorizeDecision } from '@backstage/plugin-permission-common';
import { AuthorizeQuery } from '@backstage/plugin-permission-common';
import { ComponentProps } from 'react';
import { Config } from '@backstage/config';
import { DiscoveryApi } from '@backstage/core-plugin-api';
@@ -24,7 +24,7 @@ export type AsyncPermissionResult = {
// @public
export class IdentityPermissionApi implements PermissionApi {
// (undocumented)
authorize(request: AuthorizeRequest): Promise<AuthorizeResponse>;
authorize(request: AuthorizeQuery): Promise<AuthorizeDecision>;
// (undocumented)
static create(options: {
config: Config;
@@ -35,7 +35,7 @@ export class IdentityPermissionApi implements PermissionApi {
// @public
export type PermissionApi = {
authorize(request: AuthorizeRequest): Promise<AuthorizeResponse>;
authorize(request: AuthorizeQuery): Promise<AuthorizeDecision>;
};
// @public
@@ -17,8 +17,8 @@
import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
import { PermissionApi } from './PermissionApi';
import {
AuthorizeRequest,
AuthorizeResponse,
AuthorizeQuery,
AuthorizeDecision,
PermissionClient,
} from '@backstage/plugin-permission-common';
import { Config } from '@backstage/config';
@@ -44,7 +44,7 @@ export class IdentityPermissionApi implements PermissionApi {
return new IdentityPermissionApi(permissionClient, identity);
}
async authorize(request: AuthorizeRequest): Promise<AuthorizeResponse> {
async authorize(request: AuthorizeQuery): Promise<AuthorizeDecision> {
const response = await this.permissionClient.authorize(
[request],
await this.identityApi.getCredentials(),
@@ -15,8 +15,8 @@
*/
import {
AuthorizeRequest,
AuthorizeResponse,
AuthorizeQuery,
AuthorizeDecision,
} from '@backstage/plugin-permission-common';
import { ApiRef, createApiRef } from '@backstage/core-plugin-api';
@@ -27,7 +27,7 @@ import { ApiRef, createApiRef } from '@backstage/core-plugin-api';
* @public
*/
export type PermissionApi = {
authorize(request: AuthorizeRequest): Promise<AuthorizeResponse>;
authorize(request: AuthorizeQuery): Promise<AuthorizeDecision>;
};
/**
+1 -1
View File
@@ -31,7 +31,7 @@ import { alertApiRef, useApi } from '@backstage/core-plugin-api';
const useStyles = makeStyles(theme => ({
card: {
width: 400,
maxWidth: 400,
},
header: {
marginBottom: theme.spacing(1),

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