Merge branch 'master' into go/cache-azure-tokens

This commit is contained in:
goenning
2022-05-16 15:41:33 +01:00
53 changed files with 569 additions and 121 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Add ability to customize `Read More` destination with `readMoreUrl` prop for `MissingAnnotationEmptyState` component.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
The Auth0 adapter no longer disables session refreshing.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend-module-elasticsearch': patch
---
Fix issue where `nextPageCursor` is defined on the last page of results
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Fix `EntityTypeFilter` so generating available types is case insensitive
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-graphiql': patch
'@backstage/plugin-api-docs': patch
---
Add the dependency `graphql-ws` to fix the latest release of `@graphiql/toolkit` that has a missing dependency
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-react': patch
---
Added overload signatures for `createTechDocsAddonExtension` to handle TechDocs addons without props.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-backend': patch
---
Fixed a bug that could cause TechDocs index generation to hang and fail when an underlying TechDocs site's `search_index.json` was empty.
@@ -0,0 +1,5 @@
---
'@techdocs/cli': patch
---
Update `techdocs-cli serve`'s `proxyEndpoint` to match the base URL of the embedded techdocs app.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-module-addons-contrib': patch
---
Introducing the Expandable Navigation addon, which lets you expand and collapse the TechDocs main navigation and store your preference in local storage.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Added a chevron Indicator when the sidebar is collapsed and has a sub-menu
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': minor
---
Removed the explicit `disableRefresh` option from `OAuthAdapter`. Refresh can still be disabled for a provider by not implementing the `refresh` method.
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
console.log(`creating comment on PR #${pull.number}`);
const body = `
Thanks for the contribution!
All commits need to be DCO signed before merging. Please refer to the the [DCO section in CONTRIBUTING.md](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md#developer-certificate-of-origin) or the [DCO](${checks.data.check_runs[0].html_url}) status for more info.
All commits need to be DCO signed before they are reviewed. Please refer to the the [DCO section in CONTRIBUTING.md](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md#developer-certificate-of-origin) or the [DCO](${checks.data.check_runs[0].html_url}) status for more info.
<!-- dco -->`;
await github.rest.issues.createComment({
repo,
-1
View File
@@ -153,7 +153,6 @@ export const createOktaProvider: AuthProviderFactory = ({
// Wrap the OAuthProviderHandlers with OAuthProvider, which implements AuthProviderRouteHandlers
return OAuthProvider.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
});
+5 -4
View File
@@ -111,10 +111,11 @@ page header, TechDocs Addons whose location is `Header` will not be rendered.
Addons can, in principle, be provided by any plugin! To make it easier to
discover available Addons, we've compiled a list of them here:
| Addon | Package/Plugin | Description |
| ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`<ReportIssue />`](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.reportissue) | `@backstage/plugin-techdocs-module-addons-contrib` | Allows TechDocs users to select a portion of text on a TechDocs page and open an issue against the repository that contains the documentation, populating the issue description with the selected text according to a configurable template. |
| [`<TextSize />`](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.textsize) | `@backstage/plugin-techdocs-module-addons-contrib` | This TechDocs addon allows users to customize text size on documentation pages, they can select how much they want to increase or decrease the font size via slider or buttons. The default value for font size is 100% and this setting is kept in the browser's local storage whenever it is changed. |
| Addon | Package/Plugin | Description |
| ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`<ExpandableNavigation />`](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.expandablenavigation) | `@backstage/plugin-techdocs-module-addons-contrib` | Allows TechDocs users to expand or collapse the entire TechDocs main navigation, and keeps the user's preferred state between documentation sites. |
| [`<ReportIssue />`](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.reportissue) | `@backstage/plugin-techdocs-module-addons-contrib` | Allows TechDocs users to select a portion of text on a TechDocs page and open an issue against the repository that contains the documentation, populating the issue description with the selected text according to a configurable template. |
| [`<TextSize />`](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.textsize) | `@backstage/plugin-techdocs-module-addons-contrib` | This TechDocs addon allows users to customize text size on documentation pages, they can select how much they want to increase or decrease the font size via slider or buttons. The default value for font size is 100% and this setting is kept in the browser's local storage whenever it is changed. |
Got an Addon to contribute? Feel free to add a row above!
+1 -1
View File
@@ -1,7 +1,7 @@
---
title: Adopters Community Sessions
date: April 20, 2022
category: Upcoming
category: Meetup
description: Adopters Community Session ✨. It's the monthly meetup where we all come together to listen to the latest maintainer updates, learn from each other about adopting, share exciting new demos or discuss any relevant topic like developer effectiveness, developer experience, developer portals, etc.
youtubeUrl: https://youtu.be/mFi_X58igzk
youtubeImgUrl: https://backstage.io/img/b-sessions.png
+1 -1
View File
@@ -1,7 +1,7 @@
---
title: Contributor Community Sessions
date: April 27, 2022
category: Upcoming
category: Meetup
description: Join the maintainers and contributors for the Contributor Community Sessions
youtubeUrl: https://youtu.be/evf_LV0KzIk
youtubeImgUrl: https://backstage.io/img/b-sessions.png
@@ -0,0 +1,9 @@
---
title: Adopters Community Sessions
date: May 18, 2022
category: Upcoming
description: Adopters Community Session ✨. It's the monthly meetup where we all come together to listen to the latest maintainer updates, learn from each other about adopting, share exciting new demos or discuss any relevant topic like developer effectiveness, developer experience, developer portals, etc.
youtubeUrl: https://youtu.be/dEd1fl3wRv
youtubeImgUrl: https://backstage.io/img/b-sessions.png
rsvpUrl: https://calendar.google.com/calendar/embed?src=c_qup9gbhn9sqpuao6trttd8mk5s@group.calendar.google.com
eventUrl: https://github.com/backstage/community/issues/46
@@ -0,0 +1,9 @@
---
title: Configuration as Data
author: kpt
authorUrl: https://kpt.dev
category: Configuration
description: Configuration GUI over GitOps using kpt, with WYSIWYG editing, review and approval, versioning and undo, and package cloning and upgrades.
documentation: https://github.com/GoogleContainerTools/kpt-backstage-plugins/tree/main/plugins/cad
iconUrl: https://github.com/GoogleContainerTools/kpt/blob/main/logo/KptLogoSmall.png?raw=true
npmPackageName: '@kpt/backstage-plugin-cad'
+2 -2
View File
@@ -34,7 +34,7 @@ const Background = props => {
<Block.Container style={{ justifyContent: 'flex-start' }}>
<Block.MediaFrame>
<iframe
src="https://www.youtube.com/embed/mFi_X58igzk"
src="https://www.youtube.com/embed/dEd1fl3wRvo"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
@@ -45,7 +45,7 @@ const Background = props => {
<iframe
width="300"
height="500"
src="https://www.youtube.com/live_chat?v=mFi_X58igzk&embed_domain=backstage.io&dark_theme=1"
src="https://www.youtube.com/live_chat?v=dEd1fl3wRvo&embed_domain=backstage.io&dark_theme=1"
></iframe>
</Block.MediaFrame>
</Block.Container>
+2
View File
@@ -71,6 +71,7 @@ import {
} from '@backstage/plugin-techdocs';
import { TechDocsAddons } from '@backstage/plugin-techdocs-react';
import {
ExpandableNavigation,
ReportIssue,
TextSize,
} from '@backstage/plugin-techdocs-module-addons-contrib';
@@ -185,6 +186,7 @@ const routes = (
>
{techDocsPage}
<TechDocsAddons>
<ExpandableNavigation />
<ReportIssue />
<TextSize />
</TechDocsAddons>
@@ -37,6 +37,7 @@ spec:
type Props = {
annotation: string;
readMoreUrl?: string;
};
export type MissingAnnotationEmptyStateClassKey = 'code';
@@ -53,7 +54,10 @@ const useStyles = makeStyles<BackstageTheme>(
);
export function MissingAnnotationEmptyState(props: Props) {
const { annotation } = props;
const { annotation, readMoreUrl } = props;
const url =
readMoreUrl ||
'https://backstage.io/docs/features/software-catalog/well-known-annotations';
const classes = useStyles();
const description = (
<>
@@ -81,11 +85,7 @@ export function MissingAnnotationEmptyState(props: Props) {
customStyle={{ background: 'inherit', fontSize: '115%' }}
/>
</div>
<Button
color="primary"
component={Link}
to="https://backstage.io/docs/features/software-catalog/well-known-annotations"
>
<Button color="primary" component={Link} to={url}>
Read more
</Button>
</>
@@ -369,6 +369,16 @@ const SidebarItemBase = forwardRef<any, SidebarItemProps>((props, ref) => {
// Still waiting on a Q answered to fine tune the implementation
const { isOpen } = useContext(SidebarContext);
const divStyle =
!isOpen && children ? { display: 'flex', marginLeft: '24px' } : {};
const displayItemIcon = (
<div style={divStyle}>
<Icon fontSize="small" />
{!isOpen && children ? <ArrowRightIcon /> : <></>}
</div>
);
const itemIcon = (
<Badge
color="secondary"
@@ -377,7 +387,7 @@ const SidebarItemBase = forwardRef<any, SidebarItemProps>((props, ref) => {
invisible={!hasNotifications}
className={classnames({ [classes.closedItemIcon]: !isOpen })}
>
<Icon fontSize="small" />
{displayItemIcon}
</Badge>
);
+4 -5
View File
@@ -32,7 +32,7 @@ export default class HTTPServer {
mkdocsPort: number,
verbose: boolean,
) {
this.proxyEndpoint = '/api/';
this.proxyEndpoint = '/api/techdocs/';
this.backstageBundleDir = backstageBundleDir;
this.backstagePort = backstagePort;
this.mkdocsPort = mkdocsPort;
@@ -46,10 +46,9 @@ export default class HTTPServer {
});
return (request: http.IncomingMessage): [httpProxy, string] => {
// If the request goes to /api/ we want to remove /api/ from the prefix of the request URL.
// e.g. ['/', 'api', pathChunks]
const [, , ...pathChunks] = request.url?.split('/') ?? [];
const forwardPath = pathChunks.join('/');
// If the request path is prefixed with this.proxyEndpoint, remove it.
const proxyEndpointPath = new RegExp(`^${this.proxyEndpoint}`, 'i');
const forwardPath = request.url?.replace(proxyEndpointPath, '') || '';
return [proxy, forwardPath];
};
+1
View File
@@ -44,6 +44,7 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"graphiql": "^1.8.8",
"graphql-ws": "^5.4.1",
"graphql": "^16.0.0",
"isomorphic-form-data": "^2.0.0",
"react-router": "6.0.0-beta.0",
+1 -5
View File
@@ -586,11 +586,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
handlers: OAuthHandlers,
options: Pick<
Options,
| 'providerId'
| 'persistScopes'
| 'disableRefresh'
| 'tokenIssuer'
| 'callbackUrl'
'providerId' | 'persistScopes' | 'tokenIssuer' | 'callbackUrl'
>,
): OAuthAdapter;
// (undocumented)
@@ -60,7 +60,6 @@ describe('OAuthAdapter', () => {
const oAuthProviderOptions = {
providerId: 'test-provider',
secure: false,
disableRefresh: true,
appOrigin: 'http://localhost:3000',
cookieDomain: 'example.com',
cookiePath: '/auth/test-provider',
@@ -110,7 +109,6 @@ describe('OAuthAdapter', () => {
it('sets the refresh cookie if refresh is enabled', async () => {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
isOriginAllowed: () => false,
});
@@ -153,7 +151,6 @@ describe('OAuthAdapter', () => {
};
const oauthProvider = new OAuthAdapter(handlers, {
...oAuthProviderOptions,
disableRefresh: false,
persistScopes: true,
});
@@ -238,36 +235,9 @@ describe('OAuthAdapter', () => {
);
});
it('does not set the refresh cookie if refresh is disabled', async () => {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
isOriginAllowed: () => false,
});
const mockRequest = {
cookies: {
'test-provider-nonce': 'nonce',
},
query: {
state: 'nonce',
},
} as unknown as express.Request;
const mockResponse = {
cookie: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
} as unknown as express.Response;
await oauthProvider.frameHandler(mockRequest, mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledTimes(0);
});
it('removes refresh cookie when logging out', async () => {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
isOriginAllowed: () => false,
});
@@ -292,10 +262,8 @@ describe('OAuthAdapter', () => {
});
it('gets new access-token when refreshing', async () => {
oAuthProviderOptions.disableRefresh = false;
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
isOriginAllowed: () => false,
});
@@ -327,26 +295,6 @@ describe('OAuthAdapter', () => {
});
});
it('handles refresh without capabilities', async () => {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
isOriginAllowed: () => false,
});
const mockRequest = {
header: () => 'XMLHttpRequest',
} as unknown as express.Request;
const mockResponse = {} as unknown as express.Response;
await expect(
oauthProvider.refresh(mockRequest, mockResponse),
).rejects.toThrow(
'Refresh token is not supported for provider test-provider',
);
});
it('sets the correct cookie configuration using a callbackUrl', async () => {
const config = {
baseUrl: 'http://domain.org/auth',
@@ -48,7 +48,6 @@ export const TEN_MINUTES_MS = 600 * 1000;
export type Options = {
providerId: string;
secure: boolean;
disableRefresh?: boolean;
persistScopes?: boolean;
cookieDomain: string;
cookiePath: string;
@@ -64,11 +63,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
handlers: OAuthHandlers,
options: Pick<
Options,
| 'providerId'
| 'persistScopes'
| 'disableRefresh'
| 'tokenIssuer'
| 'callbackUrl'
'providerId' | 'persistScopes' | 'tokenIssuer' | 'callbackUrl'
>,
): OAuthAdapter {
const { origin: appOrigin } = new URL(config.appUrl);
@@ -170,7 +165,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
response.providerInfo.scope = state.scope;
}
if (refreshToken && !this.options.disableRefresh) {
if (refreshToken) {
// set new refresh token
this.setRefreshTokenCookie(res, refreshToken);
}
@@ -210,7 +205,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
throw new AuthenticationError('Invalid X-Requested-With header');
}
if (!this.handlers.refresh || this.options.disableRefresh) {
if (!this.handlers.refresh) {
throw new InputError(
`Refresh token is not supported for provider ${this.options.providerId}`,
);
@@ -243,7 +243,6 @@ export const auth0 = createAuthProviderIntegration({
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: true,
providerId,
callbackUrl,
});
@@ -261,7 +261,6 @@ export const bitbucket = createAuthProviderIntegration({
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
callbackUrl,
});
@@ -248,7 +248,6 @@ export const gitlab = createAuthProviderIntegration({
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
callbackUrl,
});
@@ -238,7 +238,6 @@ export const google = createAuthProviderIntegration({
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
callbackUrl,
});
@@ -267,7 +267,6 @@ export const microsoft = createAuthProviderIntegration({
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
callbackUrl,
});
@@ -43,6 +43,7 @@ import {
SignInResolver,
} from '../types';
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
import { InputError } from '@backstage/errors';
type PrivateInfo = {
refreshToken: string;
@@ -56,6 +57,7 @@ export type OAuth2AuthProviderOptions = OAuthProviderOptions & {
scope?: string;
resolverContext: AuthResolverContext;
includeBasicAuth?: boolean;
disableRefresh?: boolean;
};
export class OAuth2AuthProvider implements OAuthHandlers {
@@ -63,11 +65,13 @@ export class OAuth2AuthProvider implements OAuthHandlers {
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
private readonly resolverContext: AuthResolverContext;
private readonly disableRefresh: boolean;
constructor(options: OAuth2AuthProviderOptions) {
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.resolverContext = options.resolverContext;
this.disableRefresh = options.disableRefresh ?? false;
this._strategy = new OAuth2Strategy(
{
@@ -132,6 +136,9 @@ export class OAuth2AuthProvider implements OAuthHandlers {
}
async refresh(req: OAuthRefreshRequest) {
if (this.disableRefresh) {
throw new InputError('Session refreshes have been disabled');
}
const refreshTokenResponse = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
@@ -243,10 +250,10 @@ export const oauth2 = createAuthProviderIntegration({
scope,
includeBasicAuth,
resolverContext,
disableRefresh,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh,
providerId,
callbackUrl,
});
@@ -262,7 +262,6 @@ export const oidc = createAuthProviderIntegration({
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
callbackUrl,
});
@@ -271,7 +271,6 @@ export const okta = createAuthProviderIntegration({
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
callbackUrl,
});
@@ -238,7 +238,6 @@ export const onelogin = createAuthProviderIntegration({
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
callbackUrl,
});
@@ -96,7 +96,13 @@ export function useEntityTypeFilter(): {
}
// Sort by facet count descending, so the most common types appear on top
const newTypes = sortBy(facets, f => -f.count).map(f => f.value);
const newTypes = [
...new Set(
sortBy(facets, f => -f.count).map(f =>
f.value.toLocaleLowerCase('en-US'),
),
),
];
setAvailableTypes(newTypes);
// Update type filter to only valid values when the list of available types has changed
+1
View File
@@ -41,6 +41,7 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"graphiql": "^1.5.12",
"graphql-ws": "^5.4.1",
"graphql": "^16.0.0",
"react-use": "^17.2.4"
},
@@ -438,6 +438,50 @@ describe('ElasticSearchSearchEngine', () => {
});
});
it('should perform search query with less results than one page', async () => {
mock.clear({
method: 'POST',
path: '/*__search/_search',
});
mock.add(
{
method: 'POST',
path: '/*__search/_search',
},
() => {
return {
hits: {
total: { value: 20, relation: 'eq' },
hits: Array(20)
.fill(null)
.map((_, i) => ({
_index: 'mytype-index__',
_source: {
value: `${i}`,
},
})),
},
};
},
);
const mockedSearchResult = await testSearchEngine.query({
term: 'testTerm',
filters: {},
});
expect(mockedSearchResult).toMatchObject({
results: expect.arrayContaining(
Array(20)
.fill(null)
.map((_, i) => ({
type: 'mytype',
document: { value: `${i}` },
})),
),
});
});
it('should perform search query with more results than one page', async () => {
mock.clear({
method: 'POST',
@@ -268,7 +268,7 @@ export class ElasticSearchSearchEngine implements SearchEngine {
body: elasticSearchQuery,
});
const { page } = decodePageCursor(query.pageCursor);
const hasNextPage = result.body.hits.total.value > page * pageSize;
const hasNextPage = result.body.hits.total.value > (page + 1) * pageSize;
const hasPreviousPage = page > 0;
const nextPageCursor = hasNextPage
? encodePageCursor({ page: page + 1 })
@@ -179,7 +179,18 @@ export class DefaultTechDocsCollatorFactory implements DocumentCollatorFactory {
},
},
);
const searchIndex = await searchIndexResponse.json();
// todo(@backstage/techdocs-core): remove Promise.race() when node-fetch is 3.x+
// workaround for fetch().json() hanging in node-fetch@2.x.x, fixed in 3.x.x
// https://github.com/node-fetch/node-fetch/issues/665
const searchIndex = await Promise.race([
searchIndexResponse.json(),
new Promise((_resolve, reject) => {
setTimeout(() => {
reject('Could not parse JSON in 5 seconds.');
}, 5000);
}),
]);
return searchIndex.docs.map((doc: MkSearchIndexDoc) => ({
title: unescape(doc.title),
@@ -7,6 +7,9 @@
import { BackstagePlugin } from '@backstage/core-plugin-api';
// @public
export const ExpandableNavigation: () => JSX.Element | null;
// @public
export const ReportIssue: (props: ReportIssueProps) => JSX.Element | null;
@@ -33,5 +36,5 @@ export type ReportIssueTemplateBuilder = ({
export const techdocsModuleAddonsContribPlugin: BackstagePlugin<{}, {}>;
// @public
export const TextSize: (props: unknown) => JSX.Element | null;
export const TextSize: () => JSX.Element | null;
```
@@ -43,6 +43,7 @@
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"@react-hookz/web": "^13.0.0",
"git-url-parse": "^11.6.0",
"react-use": "^17.2.4"
},
@@ -0,0 +1,146 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TechDocsAddonTester } from '@backstage/plugin-techdocs-addons-test-utils';
import React from 'react';
import { fireEvent, waitFor } from '@testing-library/react';
import { ExpandableNavigation } from '../plugin';
const mockNavWithSublevels = (
<div data-md-component="navigation">
<nav>
<ul>
<li className="md-nav__item md-nav__item--nested">
<input
id="nav-2"
type="checkbox"
data-md-toggle="nav-2"
className="md-nav__toggle md-toggle"
/>
<ul data-md-scrollfix="" className="md-nav__list">
<li className="md-nav__item">
<a className="md-nav__link" title="First Level Nested" href="/">
First Level Nested
</a>
</li>
<li className="md-nav__item md-nav__item--nested">
<input
id="nav-2-2"
type="checkbox"
data-md-toggle="nav-2-2"
className="md-nav__toggle md-toggle"
/>
<ul data-md-scrollfix="" className="md-nav__list">
<li className="md-nav__item">
<a
className="md-nav__link"
title="Second Level Nested"
href="/"
>
Second Level Nested
</a>
</li>
</ul>
</li>
</ul>
</li>
</ul>
</nav>
</div>
);
const mockNavWithoutSublevels = (
<div data-md-component="navigation">
<nav>
<ul>
<li className="md-nav__item">
<a className="md-nav__link" title="Nav Item" href="/">
Nav Item
</a>
</li>
<li className="md-nav__item">
<a className="md-nav__link" title="Second Nav Item" href="/">
Second Nav Item
</a>
</li>
</ul>
</nav>
</div>
);
describe('ExpandableNavigation', () => {
it('renders without exploding', async () => {
const { getByRole } = await TechDocsAddonTester.buildAddonsInTechDocs([
<ExpandableNavigation />,
])
.withDom(mockNavWithSublevels)
.renderWithEffects();
expect(getByRole('button', { name: 'expand-nav' })).toBeInTheDocument();
});
it('expands and collapses navigation', async () => {
const { getByRole, shadowRoot } =
await TechDocsAddonTester.buildAddonsInTechDocs([
<ExpandableNavigation />,
])
.withDom(mockNavWithSublevels)
.renderWithEffects();
const toggles =
shadowRoot!.querySelectorAll<HTMLInputElement>('.md-toggle');
expect(toggles).toHaveLength(2);
toggles.forEach(item => {
expect(item).not.toBeChecked();
});
const expandButton = getByRole('button', { name: 'expand-nav' });
fireEvent.click(expandButton);
await waitFor(() => {
expect(getByRole('button', { name: 'collapse-nav' })).toBeInTheDocument();
toggles.forEach(item => {
expect(item).toBeChecked();
});
});
const collapseButton = getByRole('button', { name: 'collapse-nav' });
fireEvent.click(collapseButton);
await waitFor(() => {
toggles.forEach(item => {
expect(item).not.toBeChecked();
});
});
});
it('does not render when navigation has no sublevels', async () => {
const { queryByRole } = await TechDocsAddonTester.buildAddonsInTechDocs([
<ExpandableNavigation />,
])
.withDom(mockNavWithoutSublevels)
.renderWithEffects();
expect(
queryByRole('button', { name: 'expand-nav' }),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,126 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect, useCallback, useState } from 'react';
import { useLocalStorageValue } from '@react-hookz/web';
import { Button, withStyles } from '@material-ui/core';
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { useShadowRootElements } from '@backstage/plugin-techdocs-react';
const NESTED_LIST_TOGGLE = '.md-nav__item--nested .md-toggle';
const EXPANDABLE_NAVIGATION_LOCAL_STORAGE =
'@backstage/techdocs-addons/nav-expanded';
const StyledButton = withStyles({
root: {
position: 'absolute',
left: '220px',
top: '19px',
padding: 0,
minWidth: 0,
},
})(Button);
const CollapsedIcon = withStyles({
root: {
height: '20px',
width: '20px',
},
})(ChevronRightIcon);
const ExpandedIcon = withStyles({
root: {
height: '20px',
width: '20px',
},
})(ExpandMoreIcon);
type expandableNavigationLocalStorage = {
expandAllNestedNavs: boolean;
};
/**
* Show expand/collapse navigation button next to site name in main
* navigation menu if documentation site has nested navigation.
*/
export const ExpandableNavigationAddon = () => {
const defaultValue = { expandAllNestedNavs: false };
const [expanded, setExpanded] =
useLocalStorageValue<expandableNavigationLocalStorage>(
EXPANDABLE_NAVIGATION_LOCAL_STORAGE,
defaultValue,
);
const [hasNavSubLevels, setHasNavSubLevels] = useState<boolean>(false);
const [...checkboxToggles] = useShadowRootElements<HTMLInputElement>([
NESTED_LIST_TOGGLE,
]);
const shouldToggle = useCallback(
(item: HTMLInputElement) => {
const isExpanded = item.checked;
const shouldExpand = expanded?.expandAllNestedNavs;
// Is collapsed but should expand
if (shouldExpand && !isExpanded) {
return true;
}
// Is expanded but should collapse
if (!shouldExpand && isExpanded) {
return true;
}
return false;
},
[expanded],
);
useEffect(() => {
// There is no nested navs
if (!checkboxToggles?.length) return;
setHasNavSubLevels(true);
checkboxToggles.forEach(item => {
if (shouldToggle(item)) item.click();
});
}, [expanded, shouldToggle, checkboxToggles]);
const handleState = () => {
setExpanded(prevState => ({
expandAllNestedNavs: !prevState?.expandAllNestedNavs,
}));
};
return (
<>
{hasNavSubLevels ? (
<StyledButton
size="small"
onClick={handleState}
aria-label={
expanded?.expandAllNestedNavs ? 'collapse-nav' : 'expand-nav'
}
>
{expanded?.expandAllNestedNavs ? <ExpandedIcon /> : <CollapsedIcon />}
</StyledButton>
) : null}
</>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './ExpandableNavigation';
@@ -20,7 +20,7 @@ import React from 'react';
import { fireEvent, waitFor } from '@testing-library/react';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
import { ReportIssue } from '..';
import { ReportIssue } from '../plugin';
const byUrl = jest.fn();
@@ -20,7 +20,7 @@ import React from 'react';
import { fireEvent, waitFor } from '@testing-library/react';
import { TextSize } from '..';
import { TextSize } from '../plugin';
describe('TextSize', () => {
it('renders without exploding', async () => {
@@ -22,6 +22,7 @@
export {
techdocsModuleAddonsContribPlugin,
ExpandableNavigation,
ReportIssue,
TextSize,
} from './plugin';
@@ -19,6 +19,7 @@ import {
createTechDocsAddonExtension,
TechDocsAddonLocations,
} from '@backstage/plugin-techdocs-react';
import { ExpandableNavigationAddon } from './ExpandableNavigation';
import { ReportIssueAddon, ReportIssueProps } from './ReportIssue';
import { TextSizeAddon } from './TextSize';
@@ -32,6 +33,52 @@ export const techdocsModuleAddonsContribPlugin = createPlugin({
id: 'techdocsModuleAddonsContrib',
});
/**
* TechDocs addon that lets you expand/collapse the TechDocs main navigation
* and keep the preferred state in local storage. The addon will render as
* a button next to the site name if the documentation has nested navigation.
*
* @example
* Here's a simple example:
* ```
* import {
* DefaultTechDocsHome,
* TechDocsIndexPage,
* TechDocsReaderPage,
* } from '@backstage/plugin-techdocs';
* import { TechDocsAddons } from '@backstage/plugin-techdocs-react/alpha';
* import { ExpandableNavigation } from '@backstage/plugin-techdocs-module-addons-contrib';
*
*
* const AppRoutes = () => {
* <FlatRoutes>
* // other plugin routes
* <Route path="/docs" element={<TechDocsIndexPage />}>
* <DefaultTechDocsHome />
* </Route>
* <Route
* path="/docs/:namespace/:kind/:name/*"
* element={<TechDocsReaderPage />}
* >
* <TechDocsAddons>
* <ExpandableNavigation />
* </TechDocsAddons>
* </Route>
* </FlatRoutes>;
* };
* ```
*
* @public
*/
export const ExpandableNavigation = techdocsModuleAddonsContribPlugin.provide(
createTechDocsAddonExtension({
name: 'ExpandableNavigation',
location: TechDocsAddonLocations.PrimarySidebar,
component: ExpandableNavigationAddon,
}),
);
/**
* TechDocs addon that lets you select text and open GitHub/Gitlab issues
*
+5
View File
@@ -14,6 +14,11 @@ import { default as React_2 } from 'react';
import { ReactNode } from 'react';
import { SetStateAction } from 'react';
// @public
export function createTechDocsAddonExtension(
options: TechDocsAddonOptions,
): Extension<() => JSX.Element | null>;
// @public
export function createTechDocsAddonExtension<TComponentProps>(
options: TechDocsAddonOptions<TComponentProps>,
+17 -1
View File
@@ -48,7 +48,23 @@ const getDataKeyByName = (name: string) => {
};
/**
* Create a TechDocs addon.
* Create a TechDocs addon overload signature without props.
* @public
*/
export function createTechDocsAddonExtension(
options: TechDocsAddonOptions,
): Extension<() => JSX.Element | null>;
/**
* Create a TechDocs addon overload signature with props.
* @public
*/
export function createTechDocsAddonExtension<TComponentProps>(
options: TechDocsAddonOptions<TComponentProps>,
): Extension<(props: TComponentProps) => JSX.Element | null>;
/**
* Create a TechDocs addon implementation.
* @public
*/
export function createTechDocsAddonExtension<TComponentProps>(
+18 -18
View File
@@ -2246,9 +2246,9 @@
xdg-basedir "^4.0.0"
"@graphiql/toolkit@^0.4.4":
version "0.4.4"
resolved "https://registry.npmjs.org/@graphiql/toolkit/-/toolkit-0.4.4.tgz#c5f2cd0d7fc47707f68406655836aedc5e6ca2b1"
integrity sha512-HrppQ5u0uJPYpImLqQUJWNrS9E97Cmz8DO3Dn0dXeCg1iBoKjRnZoFf2rjFF5ge9Uc9RihL181Uph/Iw9YYMFg==
version "0.4.5"
resolved "https://registry.npmjs.org/@graphiql/toolkit/-/toolkit-0.4.5.tgz#9fb5020444712562eae9581724695c235f610836"
integrity sha512-QXuuMSSK/0GfBS7tltrGZdyhIvm6oe9TK4VW9pfa8dALYttpzyJ64Q4Sx9I1Ng++yOMJWziM/ksa043zkNHsjQ==
dependencies:
"@n1ru4l/push-pull-async-iterable-iterator" "^3.1.0"
meros "^1.1.4"
@@ -6284,9 +6284,9 @@
integrity sha512-qjd88DrCxupx/kJD5yQgZdcYKZKSIGBVDIBE1/LTGcNm3d2Np/jxojkdePDdfnBHJc5W7vSMpbJ1aB7p/Py69A==
"@types/node@^16.0.0", "@types/node@^16.11.26", "@types/node@^16.9.2":
version "16.11.34"
resolved "https://registry.npmjs.org/@types/node/-/node-16.11.34.tgz#520224e4be4448c279ecad09639ab460cc441a50"
integrity sha512-UrWGDyLAlQ2Z8bNOGWTsqbP9ZcBeTYBVuTRNxXTztBy5KhWUFI3BaeDWoCP/CzV/EVGgO1NTYzv9ZytBI9GAEw==
version "16.11.35"
resolved "https://registry.npmjs.org/@types/node/-/node-16.11.35.tgz#6642bdce5b5cee23314b91a7c069981c6bd68791"
integrity sha512-QXu45LyepgnhUfnIAj/FyT4uM87ug5KpIrgXfQtUPNAlx8w5hmd8z8emqCLNvG11QkpRSCG9Qg2buMxvqfjfsQ==
"@types/normalize-package-data@^2.4.0":
version "2.4.1"
@@ -7914,9 +7914,9 @@ aws-sdk-mock@^5.2.1:
traverse "^0.6.6"
aws-sdk@^2.1122.0, aws-sdk@^2.840.0, aws-sdk@^2.948.0:
version "2.1132.0"
resolved "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1132.0.tgz#0cb615e97db5a5133914ba0f2bdc8ea10eef4069"
integrity sha512-NPDesfTrNx8UMQ5VuosQNlFFFhswJ8cGVcVltZBXKVl1kW0BCp52XQBySSruIznaRX7vG6Ir2+nox0NdL05qBQ==
version "2.1133.0"
resolved "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1133.0.tgz#db530faaa8dbe98503fd600b4e95efdd1c3996c7"
integrity sha512-TK7lcDxkrAc15Lmx030u4Wn3P798oxBVs79DBsD8+cZcW5Y8Gvat+evIZpkDoqh8YsfegvlI2jFg4ECQxKYPVA==
dependencies:
buffer "4.9.2"
events "1.1.1"
@@ -11108,9 +11108,9 @@ dompurify@=2.3.3:
integrity sha512-dqnqRkPMAjOZE0FogZ+ceJNM2dZ3V/yNOuFB7+39qpO93hHhfRpHw3heYQC7DPK9FqbQTfBKUJhiSfz4MvXYwg==
dompurify@^2.2.7, dompurify@^2.2.9, dompurify@^2.3.6:
version "2.3.6"
resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.3.6.tgz#2e019d7d7617aacac07cbbe3d88ae3ad354cf875"
integrity sha512-OFP2u/3T1R5CEgWCEONuJ1a5+MFKnOYpkywpUSxv/dj1LeBT1erK+JwM7zK0ROy2BRhqVCf0LRw/kHqKuMkVGg==
version "2.3.8"
resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.3.8.tgz#224fe9ae57d7ebd9a1ae1ac18c1c1ca3f532226f"
integrity sha512-eVhaWoVibIzqdGYjwsBWodIQIaXFSB+cKDf4cfxLMsK0xiud6SE+/WCVx/Xw/UwQsa4cS3T2eITcdtmTg2UKcw==
domutils@^2.5.2, domutils@^2.6.0:
version "2.8.0"
@@ -14111,9 +14111,9 @@ immer@9.0.7:
integrity sha512-KGllzpbamZDvOIxnmJ0jI840g7Oikx58lBPWV0hUh7dtAyZpFqqrBZdKka5GlTwMTZ1Tjc/bKKW4VSFAt6BqMA==
immer@^9.0.1, immer@^9.0.7:
version "9.0.13"
resolved "https://registry.npmjs.org/immer/-/immer-9.0.13.tgz#cee90eb7d46513d58daab0d7c2e6ef69aa4c1786"
integrity sha512-LufFIoBO2q3CZoUiObiV6ICK7QsrxOIo8ReQHggmYHA7aSbQdsAiZ+mkR3cjRD5ZxC/0P6EMdx2kk6oj1qyFLw==
version "9.0.14"
resolved "https://registry.npmjs.org/immer/-/immer-9.0.14.tgz#e05b83b63999d26382bb71676c9d827831248a48"
integrity sha512-ubBeqQutOSLIFCUBN03jGeOS6a3DoYlSYwYJTa+gSKEZKU5redJIqkIdZ3JVv/4RZpfcXdAWH5zCNLWPRv2WDw==
immutable@^3.x.x:
version "3.8.2"
@@ -25857,9 +25857,9 @@ zip-stream@^4.1.0:
readable-stream "^3.6.0"
zod@^3.11.6, zod@^3.9.5:
version "3.15.1"
resolved "https://registry.npmjs.org/zod/-/zod-3.15.1.tgz#9e404cd8002ccffb03baa94cff2e1638ed49d82f"
integrity sha512-WAdjcoOxa4S9oc/u7fTbC3CC7uVqptLLU0LKqS8RDBOrCXp2t5avM8BUfgNVZJymGWAx6SEUYxWPPoYuQ5rgwQ==
version "3.16.0"
resolved "https://registry.npmjs.org/zod/-/zod-3.16.0.tgz#edfdbf77fcc9a5af13a2630a44bdda5b90e759b7"
integrity sha512-szrIkryADbTM+xBt2a1KoS2CJQXec4f9xG78bj5MJeEH/XqmmHpnO+fG3IE115AKBJak+2HrbxLZkc9mhdbDKA==
zustand@3.6.9:
version "3.6.9"