Merge branch 'master' into rugvip/depr
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-permission-react': minor
|
||||
---
|
||||
|
||||
**BREAKING**: Update to use renamed request and response types from @backstage/plugin-permission-common.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
'@backstage/plugin-api-docs': patch
|
||||
---
|
||||
|
||||
chore(deps): bump `react-syntax-highligher` and `swagger-ui-react`
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-org': patch
|
||||
---
|
||||
|
||||
Fixed typo in `MembersListCard` component
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs': patch
|
||||
---
|
||||
|
||||
use lighter color for block quotes and horizontal rulers
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
```
|
||||
|
||||
@@ -556,6 +556,7 @@ Options:
|
||||
--package <name> 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 <path> Config files to load instead of app-config.yaml (default: [])
|
||||
-h, --help display help for command
|
||||
```
|
||||
|
||||
@@ -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
@@ -41,8 +41,8 @@ If you have specific questions about the roadmap, please create an
|
||||
|
||||
The Backstage roadmap lays out both [“what’s 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 doesn’t 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 doesn’t 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 doesn’t represent a commitment to develop and the
|
||||
list order doesn’t reflect any priority or importance. But these features are on
|
||||
The following feature list doesn’t represent a commitment to develop, and the
|
||||
list order doesn’t 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 [what’s 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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,10 @@ export type AppConfig = {
|
||||
context: string;
|
||||
data: JsonObject_2;
|
||||
filteredKeys?: string[];
|
||||
deprecatedKeys?: {
|
||||
key: string;
|
||||
description: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
// @public
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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 }[];
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
//
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
function makeCreateEnv(config: Config) {
|
||||
...
|
||||
+ const permissions = ServerPerimssionClient.fromConfig(config, {
|
||||
+ const permissions = ServerPermissionClient.fromConfig(config, {
|
||||
+ discovery,
|
||||
+ tokenManager,
|
||||
+ });
|
||||
|
||||
+34
-18
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
```
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -18,6 +18,6 @@ export type {
|
||||
ConditionalPolicyDecision,
|
||||
DefinitivePolicyDecision,
|
||||
PermissionPolicy,
|
||||
PolicyAuthorizeRequest,
|
||||
PolicyAuthorizeQuery,
|
||||
PolicyDecision,
|
||||
} from './types';
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,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>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user