Merge branch 'master' into mobile-sidebar
Signed-off-by: Philipp Hugenroth <philipph@spotify.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-test-utils': patch
|
||||
---
|
||||
|
||||
Lazy-load `testcontainers` module in order to avoid side-effects.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Use the default CSP policy provided by `helmet` directly rather than a copy.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
---
|
||||
|
||||
Update `auth0` and `onelogin` providers to allow for `authHandler` and `signIn.resolver` configuration.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': minor
|
||||
---
|
||||
|
||||
Avoid ever returning OAuth refresh tokens back to the client, and always exchange refresh tokens for a new one when available for all providers.
|
||||
|
||||
This comes with a breaking change to the TypeScript API for custom auth providers. The `refresh` method of `OAuthHandlers` implementation must now return a `{ response, refreshToken }` object rather than a direct response. Existing `refresh` implementations are typically migrated by changing an existing return expression that looks like this:
|
||||
|
||||
```ts
|
||||
return await this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
});
|
||||
```
|
||||
|
||||
Into the following:
|
||||
|
||||
```ts
|
||||
return {
|
||||
response: await this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
}),
|
||||
refreshToken,
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search': patch
|
||||
---
|
||||
|
||||
Internal cleanup of the exports structure
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Removed unused templating asset.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-app-backend': patch
|
||||
---
|
||||
|
||||
Set `X-Frame-Options: deny` rather than the default `sameorigin` for all content served by the `app-backend`.`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Add a comment to the default backend about the fallback 404 handler.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-search': patch
|
||||
---
|
||||
|
||||
Introduces a `<SearchType.Accordion />` variant, which operates on the same part of a search query as the existing `<SearchType />`, but in a more opinionated way (as a single-select control surface suitable for faceted search UIs).
|
||||
|
||||
Check the [search plugin storybook](https://backstage.io/storybook/?path=/story/plugins-search-searchtype--accordion) to see how it can be used.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search': patch
|
||||
---
|
||||
|
||||
Captures the search term entered in the SearchBarBase as a `search` event.
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
The `<SearchType />` filter in the composed `SearchPage.tsx` was replaced with the `<SearchType.Accordion />` variant.
|
||||
|
||||
This is an entirely optional change; if you wish to display a control surface for search `types` as a single-select accordion (as opposed to the current multi-select of checkboxes), you can make the following (or similar) changes to your search page layout:
|
||||
|
||||
```diff
|
||||
--- a/packages/app/src/components/search/SearchPage.tsx
|
||||
+++ b/packages/app/src/components/search/SearchPage.tsx
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
SearchType,
|
||||
DefaultResultListItem,
|
||||
} from '@backstage/plugin-search';
|
||||
-import { Content, Header, Page } from '@backstage/core-components';
|
||||
+import { CatalogIcon, Content, DocsIcon, Header, Page } from '@backstage/core-components';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => ({
|
||||
bar: {
|
||||
@@ -19,6 +19,7 @@ const useStyles = makeStyles((theme: Theme) => ({
|
||||
},
|
||||
filters: {
|
||||
padding: theme.spacing(2),
|
||||
+ marginTop: theme.spacing(2),
|
||||
},
|
||||
filter: {
|
||||
'& + &': {
|
||||
@@ -41,12 +42,23 @@ const SearchPage = () => {
|
||||
</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}>
|
||||
- <SearchType
|
||||
- values={['techdocs', 'software-catalog']}
|
||||
- name="type"
|
||||
- defaultValue="software-catalog"
|
||||
- />
|
||||
<SearchFilter.Select
|
||||
className={classes.filter}
|
||||
name="kind"
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-api-docs': patch
|
||||
---
|
||||
|
||||
Display entity title on `ApiDefinitionCard` if defined
|
||||
@@ -55,10 +55,11 @@ learn how to contribute the integration yourself!
|
||||
The following table summarizes events that, depending on the plugins you have
|
||||
installed, may be captured.
|
||||
|
||||
| Action | Provided By | Subject |
|
||||
| ---------- | -------------- | ----------------------------------------- |
|
||||
| `navigate` | Backstage Core | The URL of the page that was navigated to |
|
||||
| `click` | Backstage Core | The text of the link that was clicked on |
|
||||
| Action | Provided By | Subject |
|
||||
| ---------- | -------------- | --------------------------------------------------- |
|
||||
| `navigate` | Backstage Core | The URL of the page that was navigated to |
|
||||
| `click` | Backstage Core | The text of the link that was clicked on |
|
||||
| `search` | Backstage Core | The search term entered in any search bar component |
|
||||
|
||||
If there is an event you'd like to see captured, please [open an
|
||||
issue][add-event] describing the event you want to see and the questions it
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
CatalogIcon,
|
||||
Content,
|
||||
DocsIcon,
|
||||
Header,
|
||||
Lifecycle,
|
||||
Page,
|
||||
@@ -45,6 +47,7 @@ const useStyles = makeStyles((theme: Theme) => ({
|
||||
},
|
||||
filters: {
|
||||
padding: theme.spacing(2),
|
||||
marginTop: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -64,12 +67,23 @@ const SearchPage = () => {
|
||||
</Grid>
|
||||
{!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 />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Paper className={classes.filters}>
|
||||
<SearchType
|
||||
values={['techdocs', 'software-catalog']}
|
||||
name="type"
|
||||
defaultValue="software-catalog"
|
||||
/>
|
||||
<SearchFilter.Select
|
||||
className={classes.filter}
|
||||
name="kind"
|
||||
|
||||
@@ -19,6 +19,7 @@ import compression from 'compression';
|
||||
import cors from 'cors';
|
||||
import express, { Router, ErrorRequestHandler } from 'express';
|
||||
import helmet from 'helmet';
|
||||
import { ContentSecurityPolicyOptions } from 'helmet/dist/middlewares/content-security-policy';
|
||||
import * as http from 'http';
|
||||
import stoppable from 'stoppable';
|
||||
import { Logger } from 'winston';
|
||||
@@ -43,19 +44,6 @@ import { createHttpServer, createHttpsServer } from './hostFactory';
|
||||
export const DEFAULT_PORT = 7007;
|
||||
// '' is express default, which listens to all interfaces
|
||||
const DEFAULT_HOST = '';
|
||||
// taken from the helmet source code - don't seem to be exported
|
||||
const DEFAULT_CSP = {
|
||||
'default-src': ["'self'"],
|
||||
'base-uri': ["'self'"],
|
||||
'block-all-mixed-content': [],
|
||||
'font-src': ["'self'", 'https:', 'data:'],
|
||||
'frame-ancestors': ["'self'"],
|
||||
'img-src': ["'self'", 'data:'],
|
||||
'object-src': ["'none'"],
|
||||
'script-src': ["'self'", "'unsafe-eval'"],
|
||||
'script-src-attr': ["'none'"],
|
||||
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
|
||||
};
|
||||
|
||||
export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
private port: number | undefined;
|
||||
@@ -236,8 +224,9 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
|
||||
export function applyCspDirectives(
|
||||
directives: Record<string, string[] | false> | undefined,
|
||||
): CspOptions | undefined {
|
||||
const result: CspOptions = { ...DEFAULT_CSP };
|
||||
): ContentSecurityPolicyOptions['directives'] {
|
||||
const result: ContentSecurityPolicyOptions['directives'] =
|
||||
helmet.contentSecurityPolicy.getDefaultDirectives();
|
||||
|
||||
if (directives) {
|
||||
for (const [key, value] of Object.entries(directives)) {
|
||||
|
||||
@@ -42,8 +42,6 @@ export type CertificateAttributes = {
|
||||
|
||||
/**
|
||||
* A map from CSP directive names to their values.
|
||||
*
|
||||
* Added here since helmet doesn't export this type publicly.
|
||||
*/
|
||||
export type CspOptions = Record<string, string[]>;
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import createConnection, { Knex } from 'knex';
|
||||
import { GenericContainer } from 'testcontainers';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
async function waitForMysqlReady(
|
||||
@@ -50,6 +49,9 @@ export async function startMysqlContainer(image: string) {
|
||||
const user = 'root';
|
||||
const password = uuid();
|
||||
|
||||
// Lazy-load to avoid side-effect of importing testcontainers
|
||||
const { GenericContainer } = await import('testcontainers');
|
||||
|
||||
const container = await new GenericContainer(image)
|
||||
.withExposedPorts(3306)
|
||||
.withEnv('MYSQL_ROOT_PASSWORD', password)
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import createConnection, { Knex } from 'knex';
|
||||
import { GenericContainer } from 'testcontainers';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
async function waitForPostgresReady(
|
||||
@@ -50,6 +49,9 @@ export async function startPostgresContainer(image: string) {
|
||||
const user = 'postgres';
|
||||
const password = uuid();
|
||||
|
||||
// Lazy-load to avoid side-effect of importing testcontainers
|
||||
const { GenericContainer } = await import('testcontainers');
|
||||
|
||||
const container = await new GenericContainer(image)
|
||||
.withExposedPorts(5432)
|
||||
.withEnv('POSTGRES_PASSWORD', password)
|
||||
|
||||
+24
-6
@@ -11,7 +11,13 @@ import {
|
||||
SearchType,
|
||||
DefaultResultListItem,
|
||||
} from '@backstage/plugin-search';
|
||||
import { Content, Header, Page } from '@backstage/core-components';
|
||||
import {
|
||||
CatalogIcon,
|
||||
Content,
|
||||
DocsIcon,
|
||||
Header,
|
||||
Page,
|
||||
} from '@backstage/core-components';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => ({
|
||||
bar: {
|
||||
@@ -19,6 +25,7 @@ const useStyles = makeStyles((theme: Theme) => ({
|
||||
},
|
||||
filters: {
|
||||
padding: theme.spacing(2),
|
||||
marginTop: theme.spacing(2),
|
||||
},
|
||||
filter: {
|
||||
'& + &': {
|
||||
@@ -41,12 +48,23 @@ const SearchPage = () => {
|
||||
</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}>
|
||||
<SearchType
|
||||
values={['techdocs', 'software-catalog']}
|
||||
name="type"
|
||||
defaultValue="software-catalog"
|
||||
/>
|
||||
<SearchFilter.Select
|
||||
className={classes.filter}
|
||||
name="kind"
|
||||
|
||||
@@ -81,6 +81,8 @@ async function main() {
|
||||
apiRouter.use('/techdocs', await techdocs(techdocsEnv));
|
||||
apiRouter.use('/proxy', await proxy(proxyEnv));
|
||||
apiRouter.use('/search', await search(searchEnv));
|
||||
|
||||
// Add backends ABOVE this line; this 404 handler is the catch-all fallback
|
||||
apiRouter.use(notFoundHandler());
|
||||
|
||||
const service = createServiceBuilder(module)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Backstage is an open platform for building developer portals"
|
||||
/>
|
||||
<title>Backstage</title>
|
||||
</head>
|
||||
<body style="margin: 0">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
@@ -62,6 +62,7 @@ paths:
|
||||
kind: 'API',
|
||||
metadata: {
|
||||
name: 'my-name',
|
||||
title: 'My Name',
|
||||
},
|
||||
spec: {
|
||||
type: 'openapi',
|
||||
@@ -88,7 +89,7 @@ paths:
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(/my-name/i)).toBeInTheDocument();
|
||||
expect(getByText(/My Name/i)).toBeInTheDocument();
|
||||
expect(getByText(/OpenAPI/)).toBeInTheDocument();
|
||||
expect(getByText(/Raw/i)).toBeInTheDocument();
|
||||
expect(getByText(/List all artists/i)).toBeInTheDocument();
|
||||
@@ -101,6 +102,7 @@ paths:
|
||||
kind: 'API',
|
||||
metadata: {
|
||||
name: 'my-name',
|
||||
title: 'My Name',
|
||||
},
|
||||
spec: {
|
||||
type: 'custom-type',
|
||||
@@ -118,7 +120,7 @@ paths:
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(getByText(/my-name/i)).toBeInTheDocument();
|
||||
expect(getByText(/My Name/i)).toBeInTheDocument();
|
||||
expect(getByText(/custom-type/i)).toBeInTheDocument();
|
||||
expect(
|
||||
getAllByText(
|
||||
|
||||
@@ -39,10 +39,11 @@ export const ApiDefinitionCard = (_: Props) => {
|
||||
}
|
||||
|
||||
const definitionWidget = getApiDefinitionWidget(entity);
|
||||
const entityTitle = entity.metadata.title ?? entity.metadata.name;
|
||||
|
||||
if (definitionWidget) {
|
||||
return (
|
||||
<TabbedCard title={entity.metadata.name}>
|
||||
<TabbedCard title={entityTitle}>
|
||||
<CardTab label={definitionWidget.title} key="widget">
|
||||
{definitionWidget.component(entity.spec.definition)}
|
||||
</CardTab>
|
||||
@@ -58,7 +59,7 @@ export const ApiDefinitionCard = (_: Props) => {
|
||||
|
||||
return (
|
||||
<TabbedCard
|
||||
title={entity.metadata.name}
|
||||
title={entityTitle}
|
||||
children={[
|
||||
// Has to be an array, otherwise typescript doesn't like that this has only a single child
|
||||
<CardTab label={entity.spec.type} key="raw">
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"fs-extra": "9.1.0",
|
||||
"helmet": "^4.0.0",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { notFoundHandler, resolvePackagePath } from '@backstage/backend-common';
|
||||
import { Config } from '@backstage/config';
|
||||
import helmet from 'helmet';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import fs from 'fs-extra';
|
||||
@@ -89,6 +90,8 @@ export async function createRouter(
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(helmet.frameguard({ action: 'deny' }));
|
||||
|
||||
// Use a separate router for static content so that a fallback can be provided by backend
|
||||
const staticRouter = Router();
|
||||
staticRouter.use(express.static(resolvePath(appDistDir, 'static')));
|
||||
|
||||
@@ -27,10 +27,13 @@ export class AtlassianAuthProvider implements OAuthHandlers {
|
||||
// (undocumented)
|
||||
handler(req: express.Request): Promise<{
|
||||
response: OAuthResponse;
|
||||
refreshToken: string;
|
||||
refreshToken: string | undefined;
|
||||
}>;
|
||||
// (undocumented)
|
||||
refresh(req: OAuthRefreshRequest): Promise<OAuthResponse>;
|
||||
refresh(req: OAuthRefreshRequest): Promise<{
|
||||
response: OAuthResponse;
|
||||
refreshToken: string | undefined;
|
||||
}>;
|
||||
// Warning: (ae-forgotten-export) The symbol "RedirectInfo" needs to be exported by the entry point index.d.ts
|
||||
//
|
||||
// (undocumented)
|
||||
@@ -47,6 +50,14 @@ export type AtlassianProviderOptions = {
|
||||
};
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type Auth0ProviderOptions = {
|
||||
authHandler?: AuthHandler<OAuthResult>;
|
||||
signIn?: {
|
||||
resolver: SignInResolver<OAuthResult>;
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AuthHandler<AuthResult> = (
|
||||
input: AuthResult,
|
||||
@@ -219,6 +230,11 @@ export const createAtlassianProvider: (
|
||||
options?: AtlassianProviderOptions | undefined,
|
||||
) => AuthProviderFactory;
|
||||
|
||||
// @public (undocumented)
|
||||
export const createAuth0Provider: (
|
||||
options?: Auth0ProviderOptions | undefined,
|
||||
) => AuthProviderFactory;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "createAwsAlbProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
@@ -282,6 +298,11 @@ export const createOktaProvider: (
|
||||
_options?: OktaProviderOptions | undefined,
|
||||
) => AuthProviderFactory;
|
||||
|
||||
// @public (undocumented)
|
||||
export const createOneLoginProvider: (
|
||||
options?: OneLoginProviderOptions | undefined,
|
||||
) => AuthProviderFactory;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "createOriginFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
@@ -470,7 +491,10 @@ export interface OAuthHandlers {
|
||||
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
|
||||
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
|
||||
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
|
||||
refresh?(req: OAuthRefreshRequest): Promise<OAuthResponse>;
|
||||
refresh?(req: OAuthRefreshRequest): Promise<{
|
||||
response: OAuthResponse;
|
||||
refreshToken?: string;
|
||||
}>;
|
||||
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
|
||||
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
|
||||
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
|
||||
@@ -485,7 +509,6 @@ export type OAuthProviderInfo = {
|
||||
idToken?: string;
|
||||
expiresInSeconds?: number;
|
||||
scope: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "OAuthProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
@@ -572,6 +595,14 @@ export type OktaProviderOptions = {
|
||||
};
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type OneLoginProviderOptions = {
|
||||
authHandler?: AuthHandler<OAuthResult>;
|
||||
signIn?: {
|
||||
resolver: SignInResolver<OAuthResult>;
|
||||
};
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "postMessageResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
@@ -677,11 +708,11 @@ export type WebMessageResponse =
|
||||
//
|
||||
// src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/aws-alb/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "AwsAlbResult" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/github/provider.d.ts:71:58 - (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
|
||||
// src/providers/github/provider.d.ts:71:90 - (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
|
||||
// src/providers/github/provider.d.ts:71:89 - (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag
|
||||
// src/providers/github/provider.d.ts:71:67 - (tsdoc-malformed-html-name) Invalid HTML element: Expecting an HTML name
|
||||
// src/providers/github/provider.d.ts:71:68 - (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@"
|
||||
// src/providers/github/provider.d.ts:78:5 - (ae-forgotten-export) The symbol "StateEncoder" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/github/provider.d.ts:74:58 - (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
|
||||
// src/providers/github/provider.d.ts:74:90 - (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
|
||||
// src/providers/github/provider.d.ts:74:89 - (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag
|
||||
// src/providers/github/provider.d.ts:74:67 - (tsdoc-malformed-html-name) Invalid HTML element: Expecting an HTML name
|
||||
// src/providers/github/provider.d.ts:74:68 - (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@"
|
||||
// src/providers/github/provider.d.ts:81:5 - (ae-forgotten-export) The symbol "StateEncoder" needs to be exported by the entry point index.d.ts
|
||||
// src/providers/types.d.ts:100:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts
|
||||
```
|
||||
|
||||
@@ -57,7 +57,10 @@ describe('OAuthAdapter', () => {
|
||||
};
|
||||
}
|
||||
async refresh() {
|
||||
return mockResponseData;
|
||||
return {
|
||||
response: mockResponseData,
|
||||
refreshToken: 'token',
|
||||
};
|
||||
}
|
||||
}
|
||||
const providerInstance = new MyAuthProvider();
|
||||
@@ -257,7 +260,10 @@ describe('OAuthAdapter', () => {
|
||||
});
|
||||
|
||||
it('correctly populates incomplete identities', async () => {
|
||||
const mockRefresh = jest.fn<Promise<OAuthResponse>, [express.Request]>();
|
||||
const mockRefresh = jest.fn<
|
||||
Promise<{ response: OAuthResponse }>,
|
||||
[express.Request]
|
||||
>();
|
||||
|
||||
const oauthProvider = new OAuthAdapter(
|
||||
{
|
||||
@@ -291,10 +297,12 @@ describe('OAuthAdapter', () => {
|
||||
|
||||
// Without a token
|
||||
mockRefresh.mockResolvedValueOnce({
|
||||
...mockResponseData,
|
||||
backstageIdentity: {
|
||||
id: 'foo',
|
||||
token: '',
|
||||
response: {
|
||||
...mockResponseData,
|
||||
backstageIdentity: {
|
||||
id: 'foo',
|
||||
token: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
await oauthProvider.refresh(mockRequest, mockResponse);
|
||||
@@ -315,10 +323,12 @@ describe('OAuthAdapter', () => {
|
||||
|
||||
// With a token
|
||||
mockRefresh.mockResolvedValueOnce({
|
||||
...mockResponseData,
|
||||
backstageIdentity: {
|
||||
id: 'foo',
|
||||
token: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`,
|
||||
response: {
|
||||
...mockResponseData,
|
||||
backstageIdentity: {
|
||||
id: 'foo',
|
||||
token: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`,
|
||||
},
|
||||
},
|
||||
});
|
||||
await oauthProvider.refresh(mockRequest, mockResponse);
|
||||
|
||||
@@ -212,19 +212,15 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
|
||||
const forwardReq = Object.assign(req, { scope, refreshToken });
|
||||
|
||||
// get new access_token
|
||||
const response = await this.handlers.refresh(
|
||||
forwardReq as OAuthRefreshRequest,
|
||||
);
|
||||
const { response, refreshToken: newRefreshToken } =
|
||||
await this.handlers.refresh(forwardReq as OAuthRefreshRequest);
|
||||
|
||||
const backstageIdentity = await this.populateIdentity(
|
||||
response.backstageIdentity,
|
||||
);
|
||||
|
||||
if (
|
||||
response.providerInfo.refreshToken &&
|
||||
response.providerInfo.refreshToken !== refreshToken
|
||||
) {
|
||||
this.setRefreshTokenCookie(res, response.providerInfo.refreshToken);
|
||||
if (newRefreshToken && newRefreshToken !== refreshToken) {
|
||||
this.setRefreshTokenCookie(res, newRefreshToken);
|
||||
}
|
||||
|
||||
res.status(200).json({ ...response, backstageIdentity });
|
||||
|
||||
@@ -79,10 +79,6 @@ export type OAuthProviderInfo = {
|
||||
* Scopes granted for the access token.
|
||||
*/
|
||||
scope: string;
|
||||
/**
|
||||
* A refresh token issued for the signed in user
|
||||
*/
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
export type OAuthState = {
|
||||
@@ -130,7 +126,10 @@ export interface OAuthHandlers {
|
||||
* @param {string} refreshToken
|
||||
* @param {string} scope
|
||||
*/
|
||||
refresh?(req: OAuthRefreshRequest): Promise<OAuthResponse>;
|
||||
refresh?(req: OAuthRefreshRequest): Promise<{
|
||||
response: OAuthResponse;
|
||||
refreshToken?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* (Optional) Sign out of the auth provider.
|
||||
|
||||
@@ -78,20 +78,22 @@ describe('createAtlassianProvider', () => {
|
||||
refreshToken: 'wacka',
|
||||
},
|
||||
});
|
||||
const { response } = await provider.handler({} as any);
|
||||
expect(response).toEqual({
|
||||
providerInfo: {
|
||||
accessToken: 'accessToken',
|
||||
expiresInSeconds: 123,
|
||||
idToken: 'idToken',
|
||||
scope: 'scope',
|
||||
refreshToken: 'wacka',
|
||||
},
|
||||
profile: {
|
||||
email: 'conrad@example.com',
|
||||
displayName: 'Conrad',
|
||||
picture: 'http://google.com/lols',
|
||||
const result = await provider.handler({} as any);
|
||||
expect(result).toEqual({
|
||||
response: {
|
||||
providerInfo: {
|
||||
accessToken: 'accessToken',
|
||||
expiresInSeconds: 123,
|
||||
idToken: 'idToken',
|
||||
scope: 'scope',
|
||||
},
|
||||
profile: {
|
||||
email: 'conrad@example.com',
|
||||
displayName: 'Conrad',
|
||||
picture: 'http://google.com/lols',
|
||||
},
|
||||
},
|
||||
refreshToken: 'wacka',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,20 +129,22 @@ describe('createAtlassianProvider', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const response = await provider.refresh({} as any);
|
||||
const result = await provider.refresh({} as any);
|
||||
|
||||
expect(response).toEqual({
|
||||
profile: {
|
||||
displayName: 'Mocked User',
|
||||
email: 'mockuser@gmail.com',
|
||||
picture: 'http://google.com/lols',
|
||||
},
|
||||
providerInfo: {
|
||||
accessToken: 'a.b.c',
|
||||
idToken: 'my-id',
|
||||
refreshToken: 'dont-forget-to-send-refresh',
|
||||
scope: 'read_user',
|
||||
expect(result).toEqual({
|
||||
response: {
|
||||
profile: {
|
||||
displayName: 'Mocked User',
|
||||
email: 'mockuser@gmail.com',
|
||||
picture: 'http://google.com/lols',
|
||||
},
|
||||
providerInfo: {
|
||||
accessToken: 'a.b.c',
|
||||
idToken: 'my-id',
|
||||
scope: 'read_user',
|
||||
},
|
||||
},
|
||||
refreshToken: 'dont-forget-to-send-refresh',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,9 +107,7 @@ export class AtlassianAuthProvider implements OAuthHandlers {
|
||||
});
|
||||
}
|
||||
|
||||
async handler(
|
||||
req: express.Request,
|
||||
): Promise<{ response: OAuthResponse; refreshToken: string }> {
|
||||
async handler(req: express.Request) {
|
||||
const { result } = await executeFrameHandlerStrategy<OAuthResult>(
|
||||
req,
|
||||
this._strategy,
|
||||
@@ -117,7 +115,7 @@ export class AtlassianAuthProvider implements OAuthHandlers {
|
||||
|
||||
return {
|
||||
response: await this.handleResult(result),
|
||||
refreshToken: result.refreshToken ?? '',
|
||||
refreshToken: result.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,7 +126,6 @@ export class AtlassianAuthProvider implements OAuthHandlers {
|
||||
providerInfo: {
|
||||
idToken: result.params.id_token,
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
scope: result.params.scope,
|
||||
expiresInSeconds: result.params.expires_in,
|
||||
},
|
||||
@@ -152,28 +149,27 @@ export class AtlassianAuthProvider implements OAuthHandlers {
|
||||
return response;
|
||||
}
|
||||
|
||||
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
|
||||
const {
|
||||
accessToken,
|
||||
params,
|
||||
refreshToken: newRefreshToken,
|
||||
} = await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
async refresh(req: OAuthRefreshRequest) {
|
||||
const { accessToken, params, refreshToken } =
|
||||
await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
|
||||
const fullProfile = await executeFetchUserProfileStrategy(
|
||||
this._strategy,
|
||||
accessToken,
|
||||
);
|
||||
|
||||
return this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
});
|
||||
return {
|
||||
response: await this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
}),
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,15 @@ import {
|
||||
makeProfileInfo,
|
||||
PassportDoneCallback,
|
||||
} from '../../lib/passport';
|
||||
import { RedirectInfo, AuthProviderFactory } from '../types';
|
||||
import {
|
||||
RedirectInfo,
|
||||
AuthProviderFactory,
|
||||
AuthHandler,
|
||||
SignInResolver,
|
||||
} from '../types';
|
||||
import { CatalogIdentityClient } from '../../lib/catalog';
|
||||
import { TokenIssuer } from '../../identity';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
type PrivateInfo = {
|
||||
refreshToken: string;
|
||||
@@ -44,12 +52,27 @@ type PrivateInfo = {
|
||||
|
||||
export type Auth0AuthProviderOptions = OAuthProviderOptions & {
|
||||
domain: string;
|
||||
signInResolver?: SignInResolver<OAuthResult>;
|
||||
authHandler: AuthHandler<OAuthResult>;
|
||||
tokenIssuer: TokenIssuer;
|
||||
catalogIdentityClient: CatalogIdentityClient;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
export class Auth0AuthProvider implements OAuthHandlers {
|
||||
private readonly _strategy: Auth0Strategy;
|
||||
private readonly signInResolver?: SignInResolver<OAuthResult>;
|
||||
private readonly authHandler: AuthHandler<OAuthResult>;
|
||||
private readonly tokenIssuer: TokenIssuer;
|
||||
private readonly catalogIdentityClient: CatalogIdentityClient;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(options: Auth0AuthProviderOptions) {
|
||||
this.signInResolver = options.signInResolver;
|
||||
this.authHandler = options.authHandler;
|
||||
this.tokenIssuer = options.tokenIssuer;
|
||||
this.catalogIdentityClient = options.catalogIdentityClient;
|
||||
this.logger = options.logger;
|
||||
this._strategy = new Auth0Strategy(
|
||||
{
|
||||
clientID: options.clientId,
|
||||
@@ -90,88 +113,144 @@ export class Auth0AuthProvider implements OAuthHandlers {
|
||||
});
|
||||
}
|
||||
|
||||
async handler(
|
||||
req: express.Request,
|
||||
): Promise<{ response: OAuthResponse; refreshToken: string }> {
|
||||
async handler(req: express.Request) {
|
||||
const { result, privateInfo } = await executeFrameHandlerStrategy<
|
||||
OAuthResult,
|
||||
PrivateInfo
|
||||
>(req, this._strategy);
|
||||
|
||||
const profile = makeProfileInfo(result.fullProfile, result.params.id_token);
|
||||
|
||||
return {
|
||||
response: await this.populateIdentity({
|
||||
profile,
|
||||
providerInfo: {
|
||||
idToken: result.params.id_token,
|
||||
accessToken: result.accessToken,
|
||||
scope: result.params.scope,
|
||||
expiresInSeconds: result.params.expires_in,
|
||||
},
|
||||
}),
|
||||
response: await this.handleResult(result),
|
||||
refreshToken: privateInfo.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
|
||||
const { accessToken, params } = await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
async refresh(req: OAuthRefreshRequest) {
|
||||
const { accessToken, refreshToken, params } =
|
||||
await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
|
||||
const fullProfile = await executeFetchUserProfileStrategy(
|
||||
this._strategy,
|
||||
accessToken,
|
||||
);
|
||||
const profile = makeProfileInfo(fullProfile, params.id_token);
|
||||
|
||||
return this.populateIdentity({
|
||||
providerInfo: {
|
||||
return {
|
||||
response: await this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
idToken: params.id_token,
|
||||
expiresInSeconds: params.expires_in,
|
||||
scope: params.scope,
|
||||
},
|
||||
profile,
|
||||
});
|
||||
}),
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
// Use this function to grab the user profile info from the token
|
||||
// Then populate the profile with it
|
||||
private async populateIdentity(
|
||||
response: OAuthResponse,
|
||||
): Promise<OAuthResponse> {
|
||||
const { profile } = response;
|
||||
private async handleResult(result: OAuthResult) {
|
||||
const { profile } = await this.authHandler(result);
|
||||
|
||||
if (!profile.email) {
|
||||
throw new Error('Profile does not contain an email');
|
||||
const response: OAuthResponse = {
|
||||
providerInfo: {
|
||||
idToken: result.params.id_token,
|
||||
accessToken: result.accessToken,
|
||||
scope: result.params.scope,
|
||||
expiresInSeconds: result.params.expires_in,
|
||||
},
|
||||
profile,
|
||||
};
|
||||
|
||||
if (this.signInResolver) {
|
||||
response.backstageIdentity = await this.signInResolver(
|
||||
{
|
||||
result,
|
||||
profile,
|
||||
},
|
||||
{
|
||||
tokenIssuer: this.tokenIssuer,
|
||||
catalogIdentityClient: this.catalogIdentityClient,
|
||||
logger: this.logger,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const id = profile.email.split('@')[0];
|
||||
|
||||
return { ...response, backstageIdentity: { id, token: '' } };
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export type Auth0ProviderOptions = {};
|
||||
const defaultSignInResolver: SignInResolver<OAuthResult> = async info => {
|
||||
const { profile } = info;
|
||||
|
||||
if (!profile.email) {
|
||||
throw new Error('Profile does not contain an email');
|
||||
}
|
||||
|
||||
const id = profile.email.split('@')[0];
|
||||
|
||||
return { id, token: '' };
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export type Auth0ProviderOptions = {
|
||||
/**
|
||||
* The profile transformation function used to verify and convert the auth response
|
||||
* into the profile that will be presented to the user.
|
||||
*/
|
||||
authHandler?: AuthHandler<OAuthResult>;
|
||||
|
||||
/**
|
||||
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
|
||||
*/
|
||||
signIn?: {
|
||||
/**
|
||||
* Maps an auth result to a Backstage identity for the user.
|
||||
*/
|
||||
resolver: SignInResolver<OAuthResult>;
|
||||
};
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export const createAuth0Provider = (
|
||||
_options?: Auth0ProviderOptions,
|
||||
options?: Auth0ProviderOptions,
|
||||
): AuthProviderFactory => {
|
||||
return ({ providerId, globalConfig, config, tokenIssuer }) =>
|
||||
return ({
|
||||
providerId,
|
||||
globalConfig,
|
||||
config,
|
||||
tokenIssuer,
|
||||
catalogApi,
|
||||
logger,
|
||||
}) =>
|
||||
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
|
||||
const clientId = envConfig.getString('clientId');
|
||||
const clientSecret = envConfig.getString('clientSecret');
|
||||
const domain = envConfig.getString('domain');
|
||||
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
|
||||
|
||||
const catalogIdentityClient = new CatalogIdentityClient({
|
||||
catalogApi,
|
||||
tokenIssuer,
|
||||
});
|
||||
|
||||
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
|
||||
? options.authHandler
|
||||
: async ({ fullProfile, params }) => ({
|
||||
profile: makeProfileInfo(fullProfile, params.id_token),
|
||||
});
|
||||
|
||||
const signInResolver = options?.signIn?.resolver ?? defaultSignInResolver;
|
||||
|
||||
const provider = new Auth0AuthProvider({
|
||||
clientId,
|
||||
clientSecret,
|
||||
callbackUrl,
|
||||
domain,
|
||||
authHandler,
|
||||
signInResolver,
|
||||
tokenIssuer,
|
||||
catalogIdentityClient,
|
||||
logger,
|
||||
});
|
||||
|
||||
return OAuthAdapter.fromConfig(globalConfig, provider, {
|
||||
|
||||
@@ -138,9 +138,7 @@ export class BitbucketAuthProvider implements OAuthHandlers {
|
||||
});
|
||||
}
|
||||
|
||||
async handler(
|
||||
req: express.Request,
|
||||
): Promise<{ response: OAuthResponse; refreshToken: string }> {
|
||||
async handler(req: express.Request) {
|
||||
const { result, privateInfo } = await executeFrameHandlerStrategy<
|
||||
OAuthResult,
|
||||
PrivateInfo
|
||||
@@ -152,22 +150,25 @@ export class BitbucketAuthProvider implements OAuthHandlers {
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
|
||||
const { accessToken, params } = await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
async refresh(req: OAuthRefreshRequest) {
|
||||
const { accessToken, refreshToken, params } =
|
||||
await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
const fullProfile = await executeFetchUserProfileStrategy(
|
||||
this._strategy,
|
||||
accessToken,
|
||||
);
|
||||
return this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
refreshToken: req.refreshToken,
|
||||
});
|
||||
return {
|
||||
response: await this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
}),
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleResult(result: BitbucketOAuthResult) {
|
||||
|
||||
@@ -316,24 +316,26 @@ describe('GithubAuthProvider', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const response = await provider.refresh({} as any);
|
||||
const result = await provider.refresh({} as any);
|
||||
|
||||
expect(response).toEqual({
|
||||
backstageIdentity: {
|
||||
id: 'mockuser',
|
||||
token: 'token-for-mockuser',
|
||||
},
|
||||
profile: {
|
||||
displayName: 'Mocked User',
|
||||
email: 'mockuser@gmail.com',
|
||||
picture: undefined,
|
||||
},
|
||||
providerInfo: {
|
||||
accessToken: 'a.b.c',
|
||||
refreshToken: 'dont-forget-to-send-refresh',
|
||||
expiresInSeconds: 123,
|
||||
scope: 'read_user',
|
||||
expect(result).toEqual({
|
||||
response: {
|
||||
backstageIdentity: {
|
||||
id: 'mockuser',
|
||||
token: 'token-for-mockuser',
|
||||
},
|
||||
profile: {
|
||||
displayName: 'Mocked User',
|
||||
email: 'mockuser@gmail.com',
|
||||
picture: undefined,
|
||||
},
|
||||
providerInfo: {
|
||||
accessToken: 'a.b.c',
|
||||
expiresInSeconds: 123,
|
||||
scope: 'read_user',
|
||||
},
|
||||
},
|
||||
refreshToken: 'dont-forget-to-send-refresh',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,26 +129,26 @@ export class GithubAuthProvider implements OAuthHandlers {
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
|
||||
const {
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
params,
|
||||
} = await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
async refresh(req: OAuthRefreshRequest) {
|
||||
const { accessToken, refreshToken, params } =
|
||||
await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
const fullProfile = await executeFetchUserProfileStrategy(
|
||||
this._strategy,
|
||||
accessToken,
|
||||
);
|
||||
return this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
});
|
||||
|
||||
return {
|
||||
response: await this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
}),
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleResult(result: GithubOAuthResult) {
|
||||
@@ -158,7 +158,6 @@ export class GithubAuthProvider implements OAuthHandlers {
|
||||
const response: OAuthResponse = {
|
||||
providerInfo: {
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken, // GitHub expires the old refresh token when used
|
||||
scope: result.params.scope,
|
||||
expiresInSeconds:
|
||||
expiresInStr === undefined ? undefined : Number(expiresInStr),
|
||||
|
||||
@@ -184,23 +184,25 @@ describe('GitlabAuthProvider', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const response = await provider.refresh({} as any);
|
||||
const result = await provider.refresh({} as any);
|
||||
|
||||
expect(response).toEqual({
|
||||
backstageIdentity: {
|
||||
id: 'mockuser',
|
||||
},
|
||||
profile: {
|
||||
displayName: 'Mocked User',
|
||||
email: 'mockuser@gmail.com',
|
||||
picture: 'http://gitlab.com/lols',
|
||||
},
|
||||
providerInfo: {
|
||||
accessToken: 'a.b.c',
|
||||
idToken: 'my-id',
|
||||
refreshToken: 'dont-forget-to-send-refresh',
|
||||
scope: 'read_user',
|
||||
expect(result).toEqual({
|
||||
response: {
|
||||
backstageIdentity: {
|
||||
id: 'mockuser',
|
||||
},
|
||||
profile: {
|
||||
displayName: 'Mocked User',
|
||||
email: 'mockuser@gmail.com',
|
||||
picture: 'http://gitlab.com/lols',
|
||||
},
|
||||
providerInfo: {
|
||||
accessToken: 'a.b.c',
|
||||
idToken: 'my-id',
|
||||
scope: 'read_user',
|
||||
},
|
||||
},
|
||||
refreshToken: 'dont-forget-to-send-refresh',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,9 +132,7 @@ export class GitlabAuthProvider implements OAuthHandlers {
|
||||
});
|
||||
}
|
||||
|
||||
async handler(
|
||||
req: express.Request,
|
||||
): Promise<{ response: OAuthResponse; refreshToken: string }> {
|
||||
async handler(req: express.Request) {
|
||||
const { result, privateInfo } = await executeFrameHandlerStrategy<
|
||||
OAuthResult,
|
||||
PrivateInfo
|
||||
@@ -146,28 +144,26 @@ export class GitlabAuthProvider implements OAuthHandlers {
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
|
||||
const {
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
params,
|
||||
} = await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
async refresh(req: OAuthRefreshRequest) {
|
||||
const { accessToken, refreshToken, params } =
|
||||
await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
|
||||
const fullProfile = await executeFetchUserProfileStrategy(
|
||||
this._strategy,
|
||||
accessToken,
|
||||
);
|
||||
|
||||
return this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
});
|
||||
return {
|
||||
response: await this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
}),
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleResult(result: OAuthResult): Promise<OAuthResponse> {
|
||||
@@ -177,7 +173,6 @@ export class GitlabAuthProvider implements OAuthHandlers {
|
||||
providerInfo: {
|
||||
idToken: result.params.id_token,
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken, // GitLab expires the old refresh token when used
|
||||
scope: result.params.scope,
|
||||
expiresInSeconds: result.params.expires_in,
|
||||
},
|
||||
|
||||
@@ -113,9 +113,7 @@ export class GoogleAuthProvider implements OAuthHandlers {
|
||||
});
|
||||
}
|
||||
|
||||
async handler(
|
||||
req: express.Request,
|
||||
): Promise<{ response: OAuthResponse; refreshToken: string }> {
|
||||
async handler(req: express.Request) {
|
||||
const { result, privateInfo } = await executeFrameHandlerStrategy<
|
||||
OAuthResult,
|
||||
PrivateInfo
|
||||
@@ -127,22 +125,26 @@ export class GoogleAuthProvider implements OAuthHandlers {
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
|
||||
const { accessToken, params } = await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
async refresh(req: OAuthRefreshRequest) {
|
||||
const { accessToken, refreshToken, params } =
|
||||
await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
const fullProfile = await executeFetchUserProfileStrategy(
|
||||
this._strategy,
|
||||
accessToken,
|
||||
);
|
||||
return this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
refreshToken: req.refreshToken,
|
||||
});
|
||||
|
||||
return {
|
||||
response: await this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
}),
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleResult(result: OAuthResult) {
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './atlassian';
|
||||
export * from './auth0';
|
||||
export * from './aws-alb';
|
||||
export * from './bitbucket';
|
||||
export * from './github';
|
||||
export * from './gitlab';
|
||||
export * from './google';
|
||||
@@ -21,9 +25,7 @@ export * from './microsoft';
|
||||
export * from './oauth2';
|
||||
export * from './oidc';
|
||||
export * from './okta';
|
||||
export * from './bitbucket';
|
||||
export * from './atlassian';
|
||||
export * from './aws-alb';
|
||||
export * from './onelogin';
|
||||
export * from './saml';
|
||||
|
||||
export { factories as defaultAuthProviderFactories } from './factories';
|
||||
|
||||
@@ -104,9 +104,7 @@ export class MicrosoftAuthProvider implements OAuthHandlers {
|
||||
});
|
||||
}
|
||||
|
||||
async handler(
|
||||
req: express.Request,
|
||||
): Promise<{ response: OAuthResponse; refreshToken: string }> {
|
||||
async handler(req: express.Request) {
|
||||
const { result, privateInfo } = await executeFrameHandlerStrategy<
|
||||
OAuthResult,
|
||||
PrivateInfo
|
||||
@@ -118,24 +116,27 @@ export class MicrosoftAuthProvider implements OAuthHandlers {
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
|
||||
const { accessToken, params } = await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
async refresh(req: OAuthRefreshRequest) {
|
||||
const { accessToken, refreshToken, params } =
|
||||
await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
|
||||
const fullProfile = await executeFetchUserProfileStrategy(
|
||||
this._strategy,
|
||||
accessToken,
|
||||
);
|
||||
|
||||
return this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
refreshToken: req.refreshToken,
|
||||
});
|
||||
return {
|
||||
response: await this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
}),
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleResult(result: OAuthResult) {
|
||||
|
||||
@@ -127,9 +127,7 @@ export class OAuth2AuthProvider implements OAuthHandlers {
|
||||
});
|
||||
}
|
||||
|
||||
async handler(
|
||||
req: express.Request,
|
||||
): Promise<{ response: OAuthResponse; refreshToken: string }> {
|
||||
async handler(req: express.Request) {
|
||||
const { result, privateInfo } = await executeFrameHandlerStrategy<
|
||||
OAuthResult,
|
||||
PrivateInfo
|
||||
@@ -141,29 +139,27 @@ export class OAuth2AuthProvider implements OAuthHandlers {
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
|
||||
async refresh(req: OAuthRefreshRequest) {
|
||||
const refreshTokenResponse = await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
const {
|
||||
accessToken,
|
||||
params,
|
||||
refreshToken: updatedRefreshToken,
|
||||
} = refreshTokenResponse;
|
||||
const { accessToken, params, refreshToken } = refreshTokenResponse;
|
||||
|
||||
const fullProfile = await executeFetchUserProfileStrategy(
|
||||
this._strategy,
|
||||
accessToken,
|
||||
);
|
||||
|
||||
return this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
refreshToken: updatedRefreshToken,
|
||||
});
|
||||
return {
|
||||
response: await this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
}),
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleResult(result: OAuthResult) {
|
||||
@@ -175,7 +171,6 @@ export class OAuth2AuthProvider implements OAuthHandlers {
|
||||
accessToken: result.accessToken,
|
||||
scope: result.params.scope,
|
||||
expiresInSeconds: result.params.expires_in,
|
||||
refreshToken: result.refreshToken,
|
||||
},
|
||||
profile,
|
||||
};
|
||||
|
||||
@@ -112,34 +112,31 @@ export class OidcAuthProvider implements OAuthHandlers {
|
||||
return await executeRedirectStrategy(req, strategy, options);
|
||||
}
|
||||
|
||||
async handler(
|
||||
req: express.Request,
|
||||
): Promise<{ response: OAuthResponse; refreshToken?: string }> {
|
||||
async handler(req: express.Request) {
|
||||
const { strategy } = await this.implementation;
|
||||
const strategyResponse = await executeFrameHandlerStrategy<
|
||||
const { result, privateInfo } = await executeFrameHandlerStrategy<
|
||||
OidcAuthResult,
|
||||
PrivateInfo
|
||||
>(req, strategy);
|
||||
const {
|
||||
result: { userinfo, tokenset },
|
||||
privateInfo,
|
||||
} = strategyResponse;
|
||||
|
||||
const identityResponse = await this.handleResult({ tokenset, userinfo });
|
||||
return {
|
||||
response: identityResponse,
|
||||
response: await this.handleResult(result),
|
||||
refreshToken: privateInfo.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
|
||||
async refresh(req: OAuthRefreshRequest) {
|
||||
const { client } = await this.implementation;
|
||||
const tokenset = await client.refresh(req.refreshToken);
|
||||
if (!tokenset.access_token) {
|
||||
throw new Error('Refresh failed');
|
||||
}
|
||||
const profile = await client.userinfo(tokenset.access_token);
|
||||
return this.handleResult({ tokenset, userinfo: profile });
|
||||
const userinfo = await client.userinfo(tokenset.access_token);
|
||||
|
||||
return {
|
||||
response: await this.handleResult({ tokenset, userinfo }),
|
||||
refreshToken: tokenset.refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
private async setupStrategy(options: Options): Promise<OidcImpl> {
|
||||
@@ -190,7 +187,6 @@ export class OidcAuthProvider implements OAuthHandlers {
|
||||
providerInfo: {
|
||||
idToken: result.tokenset.id_token,
|
||||
accessToken: result.tokenset.access_token!,
|
||||
refreshToken: result.tokenset.refresh_token,
|
||||
scope: result.tokenset.scope!,
|
||||
expiresInSeconds: result.tokenset.expires_in,
|
||||
},
|
||||
|
||||
@@ -133,9 +133,7 @@ export class OktaAuthProvider implements OAuthHandlers {
|
||||
});
|
||||
}
|
||||
|
||||
async handler(
|
||||
req: express.Request,
|
||||
): Promise<{ response: OAuthResponse; refreshToken: string }> {
|
||||
async handler(req: express.Request) {
|
||||
const { result, privateInfo } = await executeFrameHandlerStrategy<
|
||||
OAuthResult,
|
||||
PrivateInfo
|
||||
@@ -147,7 +145,7 @@ export class OktaAuthProvider implements OAuthHandlers {
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
|
||||
async refresh(req: OAuthRefreshRequest) {
|
||||
const { accessToken, refreshToken, params } =
|
||||
await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
@@ -160,12 +158,14 @@ export class OktaAuthProvider implements OAuthHandlers {
|
||||
accessToken,
|
||||
);
|
||||
|
||||
return this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
return {
|
||||
response: await this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
}),
|
||||
refreshToken,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private async handleResult(result: OAuthResult) {
|
||||
@@ -177,7 +177,6 @@ export class OktaAuthProvider implements OAuthHandlers {
|
||||
accessToken: result.accessToken,
|
||||
scope: result.params.scope,
|
||||
expiresInSeconds: result.params.expires_in,
|
||||
refreshToken: result.refreshToken,
|
||||
},
|
||||
profile,
|
||||
};
|
||||
|
||||
@@ -36,7 +36,15 @@ import {
|
||||
executeFetchUserProfileStrategy,
|
||||
PassportDoneCallback,
|
||||
} from '../../lib/passport';
|
||||
import { RedirectInfo, AuthProviderFactory } from '../types';
|
||||
import {
|
||||
RedirectInfo,
|
||||
AuthProviderFactory,
|
||||
AuthHandler,
|
||||
SignInResolver,
|
||||
} from '../types';
|
||||
import { CatalogIdentityClient } from '../../lib/catalog';
|
||||
import { Logger } from 'winston';
|
||||
import { TokenIssuer } from '../../identity';
|
||||
|
||||
type PrivateInfo = {
|
||||
refreshToken: string;
|
||||
@@ -44,12 +52,27 @@ type PrivateInfo = {
|
||||
|
||||
export type Options = OAuthProviderOptions & {
|
||||
issuer: string;
|
||||
signInResolver?: SignInResolver<OAuthResult>;
|
||||
authHandler: AuthHandler<OAuthResult>;
|
||||
tokenIssuer: TokenIssuer;
|
||||
catalogIdentityClient: CatalogIdentityClient;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
export class OneLoginProvider implements OAuthHandlers {
|
||||
private readonly _strategy: any;
|
||||
private readonly signInResolver?: SignInResolver<OAuthResult>;
|
||||
private readonly authHandler: AuthHandler<OAuthResult>;
|
||||
private readonly tokenIssuer: TokenIssuer;
|
||||
private readonly catalogIdentityClient: CatalogIdentityClient;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(options: Options) {
|
||||
this.signInResolver = options.signInResolver;
|
||||
this.authHandler = options.authHandler;
|
||||
this.tokenIssuer = options.tokenIssuer;
|
||||
this.catalogIdentityClient = options.catalogIdentityClient;
|
||||
this.logger = options.logger;
|
||||
this._strategy = new OneLoginStrategy(
|
||||
{
|
||||
issuer: options.issuer,
|
||||
@@ -89,86 +112,144 @@ export class OneLoginProvider implements OAuthHandlers {
|
||||
});
|
||||
}
|
||||
|
||||
async handler(
|
||||
req: express.Request,
|
||||
): Promise<{ response: OAuthResponse; refreshToken: string }> {
|
||||
async handler(req: express.Request) {
|
||||
const { result, privateInfo } = await executeFrameHandlerStrategy<
|
||||
OAuthResult,
|
||||
PrivateInfo
|
||||
>(req, this._strategy);
|
||||
|
||||
const profile = makeProfileInfo(result.fullProfile, result.params.id_token);
|
||||
|
||||
return {
|
||||
response: await this.populateIdentity({
|
||||
profile,
|
||||
providerInfo: {
|
||||
idToken: result.params.id_token,
|
||||
accessToken: result.accessToken,
|
||||
scope: result.params.scope,
|
||||
expiresInSeconds: result.params.expires_in,
|
||||
},
|
||||
}),
|
||||
response: await this.handleResult(result),
|
||||
refreshToken: privateInfo.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
|
||||
const { accessToken, params } = await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
async refresh(req: OAuthRefreshRequest) {
|
||||
const { accessToken, refreshToken, params } =
|
||||
await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
|
||||
const fullProfile = await executeFetchUserProfileStrategy(
|
||||
this._strategy,
|
||||
accessToken,
|
||||
);
|
||||
const profile = makeProfileInfo(fullProfile, params.id_token);
|
||||
|
||||
return this.populateIdentity({
|
||||
providerInfo: {
|
||||
return {
|
||||
response: await this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
idToken: params.id_token,
|
||||
expiresInSeconds: params.expires_in,
|
||||
scope: params.scope,
|
||||
},
|
||||
profile,
|
||||
});
|
||||
}),
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
private async populateIdentity(
|
||||
response: OAuthResponse,
|
||||
): Promise<OAuthResponse> {
|
||||
const { profile } = response;
|
||||
private async handleResult(result: OAuthResult) {
|
||||
const { profile } = await this.authHandler(result);
|
||||
|
||||
if (!profile.email) {
|
||||
throw new Error('OIDC profile contained no email');
|
||||
const response: OAuthResponse = {
|
||||
providerInfo: {
|
||||
idToken: result.params.id_token,
|
||||
accessToken: result.accessToken,
|
||||
scope: result.params.scope,
|
||||
expiresInSeconds: result.params.expires_in,
|
||||
},
|
||||
profile,
|
||||
};
|
||||
|
||||
if (this.signInResolver) {
|
||||
response.backstageIdentity = await this.signInResolver(
|
||||
{
|
||||
result,
|
||||
profile,
|
||||
},
|
||||
{
|
||||
tokenIssuer: this.tokenIssuer,
|
||||
catalogIdentityClient: this.catalogIdentityClient,
|
||||
logger: this.logger,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const id = profile.email.split('@')[0];
|
||||
|
||||
return { ...response, backstageIdentity: { id, token: '' } };
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export type OneLoginProviderOptions = {};
|
||||
const defaultSignInResolver: SignInResolver<OAuthResult> = async info => {
|
||||
const { profile } = info;
|
||||
|
||||
if (!profile.email) {
|
||||
throw new Error('OIDC profile contained no email');
|
||||
}
|
||||
|
||||
const id = profile.email.split('@')[0];
|
||||
|
||||
return { id, token: '' };
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export type OneLoginProviderOptions = {
|
||||
/**
|
||||
* The profile transformation function used to verify and convert the auth response
|
||||
* into the profile that will be presented to the user.
|
||||
*/
|
||||
authHandler?: AuthHandler<OAuthResult>;
|
||||
|
||||
/**
|
||||
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
|
||||
*/
|
||||
signIn?: {
|
||||
/**
|
||||
* Maps an auth result to a Backstage identity for the user.
|
||||
*/
|
||||
resolver: SignInResolver<OAuthResult>;
|
||||
};
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export const createOneLoginProvider = (
|
||||
_options?: OneLoginProviderOptions,
|
||||
options?: OneLoginProviderOptions,
|
||||
): AuthProviderFactory => {
|
||||
return ({ providerId, globalConfig, config, tokenIssuer }) =>
|
||||
return ({
|
||||
providerId,
|
||||
globalConfig,
|
||||
config,
|
||||
tokenIssuer,
|
||||
catalogApi,
|
||||
logger,
|
||||
}) =>
|
||||
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
|
||||
const clientId = envConfig.getString('clientId');
|
||||
const clientSecret = envConfig.getString('clientSecret');
|
||||
const issuer = envConfig.getString('issuer');
|
||||
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
|
||||
|
||||
const catalogIdentityClient = new CatalogIdentityClient({
|
||||
catalogApi,
|
||||
tokenIssuer,
|
||||
});
|
||||
|
||||
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
|
||||
? options.authHandler
|
||||
: async ({ fullProfile, params }) => ({
|
||||
profile: makeProfileInfo(fullProfile, params.id_token),
|
||||
});
|
||||
|
||||
const signInResolver = options?.signIn?.resolver ?? defaultSignInResolver;
|
||||
|
||||
const provider = new OneLoginProvider({
|
||||
clientId,
|
||||
clientSecret,
|
||||
callbackUrl,
|
||||
issuer,
|
||||
authHandler,
|
||||
signInResolver,
|
||||
tokenIssuer,
|
||||
catalogIdentityClient,
|
||||
logger,
|
||||
});
|
||||
|
||||
return OAuthAdapter.fromConfig(globalConfig, provider, {
|
||||
|
||||
@@ -213,16 +213,32 @@ export const SearchResult: ({
|
||||
// @public (undocumented)
|
||||
export const SearchResultPager: () => JSX.Element;
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "SearchTypeProps" needs to be exported by the entry point index.d.ts
|
||||
// Warning: (ae-missing-release-tag) "SearchType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const SearchType: ({
|
||||
values,
|
||||
className,
|
||||
name,
|
||||
defaultValue,
|
||||
}: SearchTypeProps) => JSX.Element;
|
||||
export const SearchType: {
|
||||
(props: SearchTypeProps): JSX.Element;
|
||||
Accordion(props: SearchTypeAccordionProps): JSX.Element;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type SearchTypeAccordionProps = {
|
||||
name: string;
|
||||
types: Array<{
|
||||
value: string;
|
||||
name: string;
|
||||
icon: JSX.Element;
|
||||
}>;
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type SearchTypeProps = {
|
||||
className?: string;
|
||||
name: string;
|
||||
values?: string[];
|
||||
defaultValue?: string[] | string | null;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "SidebarSearch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
|
||||
+34
-43
@@ -14,17 +14,28 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@backstage/core-components';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import FindInPageIcon from '@material-ui/icons/FindInPage';
|
||||
import GroupIcon from '@material-ui/icons/Group';
|
||||
import { Button } from '@backstage/core-components';
|
||||
import { DefaultResultListItem } from '../index';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { DefaultResultListItem } from './DefaultResultListItem';
|
||||
|
||||
export default {
|
||||
title: 'Plugins/Search/DefaultResultListItem',
|
||||
component: DefaultResultListItem,
|
||||
decorators: [
|
||||
(Story: () => JSX.Element) => (
|
||||
<MemoryRouter>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={12}>
|
||||
<Story />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</MemoryRouter>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
const mockSearchResult = {
|
||||
@@ -35,54 +46,34 @@ const mockSearchResult = {
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={12}>
|
||||
<DefaultResultListItem result={mockSearchResult} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</MemoryRouter>
|
||||
);
|
||||
return <DefaultResultListItem result={mockSearchResult} />;
|
||||
};
|
||||
|
||||
export const WithIcon = () => {
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={12}>
|
||||
<DefaultResultListItem
|
||||
result={mockSearchResult}
|
||||
icon={<FindInPageIcon color="primary" />}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</MemoryRouter>
|
||||
<DefaultResultListItem
|
||||
result={mockSearchResult}
|
||||
icon={<FindInPageIcon color="primary" />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithSecondaryAction = () => {
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={12}>
|
||||
<DefaultResultListItem
|
||||
result={mockSearchResult}
|
||||
secondaryAction={
|
||||
<Button
|
||||
to="#"
|
||||
size="small"
|
||||
aria-label="owner"
|
||||
variant="text"
|
||||
startIcon={<GroupIcon />}
|
||||
style={{ textTransform: 'lowercase' }}
|
||||
>
|
||||
{mockSearchResult.owner}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</MemoryRouter>
|
||||
<DefaultResultListItem
|
||||
result={mockSearchResult}
|
||||
secondaryAction={
|
||||
<Button
|
||||
to="#"
|
||||
size="small"
|
||||
aria-label="owner"
|
||||
variant="text"
|
||||
startIcon={<GroupIcon />}
|
||||
style={{ textTransform: 'lowercase' }}
|
||||
>
|
||||
{mockSearchResult.owner}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Grid } from '@material-ui/core';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useDebounce } from 'react-use';
|
||||
|
||||
@@ -14,88 +14,58 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Paper, Grid, makeStyles } from '@material-ui/core';
|
||||
import { SearchBar, SearchContext } from '../index';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { Grid, makeStyles, Paper } from '@material-ui/core';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories';
|
||||
import { SearchBar } from './SearchBar';
|
||||
|
||||
export default {
|
||||
title: 'Plugins/Search/SearchBar',
|
||||
component: SearchBar,
|
||||
};
|
||||
|
||||
const defaultValue = {
|
||||
term: '',
|
||||
setTerm: () => {},
|
||||
decorators: [
|
||||
(Story: ComponentType<{}>) => (
|
||||
<SearchContextProvider>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={12}>
|
||||
<Story />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SearchContextProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<MemoryRouter>
|
||||
{/* @ts-ignore (defaultValue requires more than what is used here) */}
|
||||
<SearchContext.Provider value={defaultValue}>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={12}>
|
||||
<Paper style={{ padding: '8px 0' }}>
|
||||
<SearchBar />
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SearchContext.Provider>
|
||||
</MemoryRouter>
|
||||
<Paper style={{ padding: '8px 0' }}>
|
||||
<SearchBar />
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export const CustomPlaceholder = () => {
|
||||
return (
|
||||
<MemoryRouter>
|
||||
{/* @ts-ignore (defaultValue requires more than what is used here) */}
|
||||
<SearchContext.Provider value={defaultValue}>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={12}>
|
||||
<Paper style={{ padding: '8px 0' }}>
|
||||
<SearchBar placeholder="This is a custom placeholder" />
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SearchContext.Provider>
|
||||
</MemoryRouter>
|
||||
<Paper style={{ padding: '8px 0' }}>
|
||||
<SearchBar placeholder="This is a custom placeholder" />
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export const Focused = () => {
|
||||
return (
|
||||
<MemoryRouter>
|
||||
{/* @ts-ignore (defaultValue requires more than what is used here) */}
|
||||
<SearchContext.Provider value={defaultValue}>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={12}>
|
||||
<Paper style={{ padding: '8px 0' }}>
|
||||
{/* decision up to adopter, read https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-autofocus.md#no-autofocus */}
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<SearchBar autoFocus />
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SearchContext.Provider>
|
||||
</MemoryRouter>
|
||||
<Paper style={{ padding: '8px 0' }}>
|
||||
{/* decision up to adopter, read https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-autofocus.md#no-autofocus */}
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<SearchBar autoFocus />
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithoutClearButton = () => {
|
||||
return (
|
||||
<MemoryRouter>
|
||||
{/* @ts-ignore (defaultValue requires more than what is used here) */}
|
||||
<SearchContext.Provider value={defaultValue}>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={12}>
|
||||
<Paper style={{ padding: '8px 0' }}>
|
||||
<SearchBar clearButton={false} />
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SearchContext.Provider>
|
||||
</MemoryRouter>
|
||||
<Paper style={{ padding: '8px 0' }}>
|
||||
<SearchBar clearButton={false} />
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -112,17 +82,8 @@ const useStyles = makeStyles({
|
||||
export const CustomStyles = () => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<MemoryRouter>
|
||||
{/* @ts-ignore (defaultValue requires more than what is used here) */}
|
||||
<SearchContext.Provider value={defaultValue}>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={12}>
|
||||
<Paper className={classes.search}>
|
||||
<SearchBar />
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SearchContext.Provider>
|
||||
</MemoryRouter>
|
||||
<Paper className={classes.search}>
|
||||
<SearchBar />
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,10 +20,10 @@ import userEvent from '@testing-library/user-event';
|
||||
import { SearchContextProvider } from '../SearchContext';
|
||||
|
||||
import { SearchBar } from './SearchBar';
|
||||
import { configApiRef } from '@backstage/core-plugin-api';
|
||||
import { configApiRef, analyticsApiRef } from '@backstage/core-plugin-api';
|
||||
import { ApiProvider, ConfigReader } from '@backstage/core-app-api';
|
||||
import { searchApiRef } from '../../apis';
|
||||
import { TestApiRegistry } from '@backstage/test-utils';
|
||||
import { MockAnalyticsApi, TestApiRegistry } from '@backstage/test-utils';
|
||||
|
||||
jest.mock('@backstage/core-plugin-api', () => ({
|
||||
...jest.requireActual('@backstage/core-plugin-api'),
|
||||
@@ -38,9 +38,16 @@ describe('SearchBar', () => {
|
||||
};
|
||||
|
||||
const query = jest.fn().mockResolvedValue({});
|
||||
const analyticsApiSpy = new MockAnalyticsApi();
|
||||
let apiRegistry: TestApiRegistry;
|
||||
|
||||
const apiRegistry = TestApiRegistry.from(
|
||||
[configApiRef, new ConfigReader({ app: { title: 'Mock title' } })],
|
||||
apiRegistry = TestApiRegistry.from(
|
||||
[
|
||||
configApiRef,
|
||||
new ConfigReader({
|
||||
app: { title: 'Mock title' },
|
||||
}),
|
||||
],
|
||||
[searchApiRef, { query }],
|
||||
);
|
||||
|
||||
@@ -210,4 +217,128 @@ describe('SearchBar', () => {
|
||||
expect.objectContaining({ term: value }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not capture analytics event if not enabled in app', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const debounceTime = 600;
|
||||
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchBar debounceTime={debounceTime} />
|
||||
</SearchContextProvider>
|
||||
,
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const textbox = screen.getByRole('textbox', { name });
|
||||
|
||||
const value = 'value';
|
||||
|
||||
userEvent.type(textbox, value);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(debounceTime);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(textbox).toHaveValue(value));
|
||||
|
||||
expect(analyticsApiSpy.getEvents()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('captures analytics events if enabled in app', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const debounceTime = 600;
|
||||
|
||||
apiRegistry = TestApiRegistry.from(
|
||||
[analyticsApiRef, analyticsApiSpy],
|
||||
[
|
||||
configApiRef,
|
||||
new ConfigReader({
|
||||
app: {
|
||||
title: 'Mock title',
|
||||
analytics: {
|
||||
ga: {
|
||||
trackingId: 'xyz123',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
[searchApiRef, { query }],
|
||||
);
|
||||
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider
|
||||
initialState={{
|
||||
term: '',
|
||||
types: ['techdocs', 'software-catalog'],
|
||||
filters: {},
|
||||
}}
|
||||
>
|
||||
<SearchBar debounceTime={debounceTime} />
|
||||
</SearchContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const textbox = screen.getByRole('textbox', { name });
|
||||
|
||||
const value = 'value';
|
||||
|
||||
userEvent.type(textbox, value);
|
||||
|
||||
expect(analyticsApiSpy.getEvents()).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(debounceTime);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(textbox).toHaveValue(value));
|
||||
|
||||
expect(analyticsApiSpy.getEvents()).toHaveLength(1);
|
||||
expect(analyticsApiSpy.getEvents()[0]).toEqual({
|
||||
action: 'search',
|
||||
context: {
|
||||
extension: 'App',
|
||||
pluginId: 'root',
|
||||
routeRef: 'unknown',
|
||||
searchTypes: 'software-catalog,techdocs',
|
||||
},
|
||||
subject: 'value',
|
||||
});
|
||||
|
||||
userEvent.clear(textbox);
|
||||
|
||||
// make sure new term is captured
|
||||
userEvent.type(textbox, 'new value');
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(debounceTime);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(textbox).toHaveValue('new value'));
|
||||
|
||||
expect(analyticsApiSpy.getEvents()).toHaveLength(2);
|
||||
expect(analyticsApiSpy.getEvents()[1]).toEqual({
|
||||
action: 'search',
|
||||
context: {
|
||||
extension: 'App',
|
||||
pluginId: 'root',
|
||||
routeRef: 'unknown',
|
||||
searchTypes: 'software-catalog,techdocs',
|
||||
},
|
||||
subject: 'new value',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ import SearchIcon from '@material-ui/icons/Search';
|
||||
import ClearButton from '@material-ui/icons/Clear';
|
||||
|
||||
import { useSearch } from '../SearchContext';
|
||||
import { TrackSearch } from '../SearchTracker';
|
||||
|
||||
/**
|
||||
* Props for {@link SearchBarBase}.
|
||||
@@ -119,18 +120,20 @@ export const SearchBarBase = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<InputBase
|
||||
data-testid="search-bar-next"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
startAdornment={startAdornment}
|
||||
endAdornment={clearButton ? endAdornment : defaultEndAdornment}
|
||||
inputProps={{ 'aria-label': 'Search', ...defaultInputProps }}
|
||||
fullWidth={fullWidth}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
<TrackSearch>
|
||||
<InputBase
|
||||
data-testid="search-bar-next"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
startAdornment={startAdornment}
|
||||
endAdornment={clearButton ? endAdornment : defaultEndAdornment}
|
||||
inputProps={{ 'aria-label': 'Search', ...defaultInputProps }}
|
||||
fullWidth={fullWidth}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
</TrackSearch>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -150,8 +153,11 @@ export const SearchBar = ({ onChange, ...props }: SearchBarProps) => {
|
||||
const { term, setTerm } = useSearch();
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
setTerm(newValue);
|
||||
if (onChange) onChange(newValue);
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
} else {
|
||||
setTerm(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
return <SearchBarBase value={term} onChange={handleChange} {...props} />;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { useApi, AnalyticsContext } from '@backstage/core-plugin-api';
|
||||
import { SearchResultSet } from '@backstage/search-common';
|
||||
import React, {
|
||||
createContext,
|
||||
@@ -130,7 +130,11 @@ export const SearchContextProvider = ({
|
||||
fetchPreviousPage: hasPreviousPage ? fetchPreviousPage : undefined,
|
||||
};
|
||||
|
||||
return <SearchContext.Provider value={value} children={children} />;
|
||||
return (
|
||||
<AnalyticsContext attributes={{ searchTypes: types.sort().join(',') }}>
|
||||
<SearchContext.Provider value={value} children={children} />
|
||||
</AnalyticsContext>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSearch = () => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2021 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 { ApiProvider } from '@backstage/core-app-api';
|
||||
import { SearchResultSet } from '@backstage/search-common';
|
||||
import { TestApiRegistry } from '@backstage/test-utils';
|
||||
import React, { ComponentProps } from 'react';
|
||||
import { searchApiRef } from '../../apis';
|
||||
import { SearchContextProvider as RealSearchContextProvider } from './SearchContext';
|
||||
|
||||
type QueryResultProps = {
|
||||
mockedResults?: SearchResultSet;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility context provider only for use in Storybook stories. You should use
|
||||
* the real `<SearchContextProvider>` exported by `@backstage/plugin-search` in
|
||||
* your app instead of this! In some cases (like the search page) it may
|
||||
* already be provided on your behalf.
|
||||
*/
|
||||
export const SearchContextProvider = (
|
||||
props: ComponentProps<typeof RealSearchContextProvider> & QueryResultProps,
|
||||
) => {
|
||||
const { mockedResults, ...contextProps } = props;
|
||||
const query: any = () => Promise.resolve(mockedResults || {});
|
||||
const apiRegistry = TestApiRegistry.from([searchApiRef, { query }]);
|
||||
|
||||
return (
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<RealSearchContextProvider {...contextProps} />
|
||||
</ApiProvider>
|
||||
);
|
||||
};
|
||||
@@ -14,56 +14,45 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Grid, Paper } from '@material-ui/core';
|
||||
import { SearchFilter, SearchContext } from '../index';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories';
|
||||
import { SearchFilter } from './SearchFilter';
|
||||
|
||||
export default {
|
||||
title: 'Plugins/Search/SearchFilter',
|
||||
component: SearchFilter,
|
||||
};
|
||||
|
||||
const defaultValue = {
|
||||
filters: {},
|
||||
decorators: [
|
||||
(Story: ComponentType<{}>) => (
|
||||
<SearchContextProvider>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={4}>
|
||||
<Story />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SearchContextProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const CheckBoxFilter = () => {
|
||||
return (
|
||||
<MemoryRouter>
|
||||
{/* @ts-ignore (defaultValue requires more than what is used here) */}
|
||||
<SearchContext.Provider value={defaultValue}>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={4}>
|
||||
<Paper style={{ padding: 10 }}>
|
||||
<SearchFilter.Checkbox
|
||||
name="Search Checkbox Filter"
|
||||
values={['value1', 'value2']}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SearchContext.Provider>
|
||||
</MemoryRouter>
|
||||
<Paper style={{ padding: 10 }}>
|
||||
<SearchFilter.Checkbox
|
||||
name="Search Checkbox Filter"
|
||||
values={['value1', 'value2']}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export const SelectFilter = () => {
|
||||
return (
|
||||
<MemoryRouter>
|
||||
{/* @ts-ignore (defaultValue requires more than what is used here) */}
|
||||
<SearchContext.Provider value={defaultValue}>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={4}>
|
||||
<Paper style={{ padding: 10 }}>
|
||||
<SearchFilter.Select
|
||||
name="Search Select Filter"
|
||||
values={['value1', 'value2']}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SearchContext.Provider>
|
||||
</MemoryRouter>
|
||||
<Paper style={{ padding: 10 }}>
|
||||
<SearchFilter.Select
|
||||
name="Search Select Filter"
|
||||
values={['value1', 'value2']}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,62 +14,52 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ComponentType } from 'react';
|
||||
import { Button } from '@material-ui/core';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { SearchModal } from '../index';
|
||||
import { useSearch, SearchContextProvider } from '../SearchContext';
|
||||
import { searchApiRef } from '../../apis';
|
||||
import { Button } from '@material-ui/core';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { rootRouteRef } from '../../plugin';
|
||||
import { useSearch } from '../SearchContext';
|
||||
import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories';
|
||||
import { SearchModal } from './SearchModal';
|
||||
|
||||
const mockSearchApi = {
|
||||
query: () =>
|
||||
Promise.resolve({
|
||||
results: [
|
||||
{
|
||||
type: 'custom-result-item',
|
||||
document: {
|
||||
location: 'search/search-result-1',
|
||||
title: 'Search Result 1',
|
||||
text: 'some text from the search result',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'no-custom-result-item',
|
||||
document: {
|
||||
location: 'search/search-result-2',
|
||||
title: 'Search Result 2',
|
||||
text: 'some text from the search result',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'no-custom-result-item',
|
||||
document: {
|
||||
location: 'search/search-result-3',
|
||||
title: 'Search Result 3',
|
||||
text: 'some text from the search result',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
const mockResults = {
|
||||
results: [
|
||||
{
|
||||
type: 'custom-result-item',
|
||||
document: {
|
||||
location: 'search/search-result-1',
|
||||
title: 'Search Result 1',
|
||||
text: 'some text from the search result',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'no-custom-result-item',
|
||||
document: {
|
||||
location: 'search/search-result-2',
|
||||
title: 'Search Result 2',
|
||||
text: 'some text from the search result',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'no-custom-result-item',
|
||||
document: {
|
||||
location: 'search/search-result-3',
|
||||
title: 'Search Result 3',
|
||||
text: 'some text from the search result',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const apiRegistry = () => ApiRegistry.from([[searchApiRef, mockSearchApi]]);
|
||||
|
||||
export default {
|
||||
title: 'Plugins/Search/SearchModal',
|
||||
component: SearchModal,
|
||||
decorators: [
|
||||
(Story: ComponentType<{}>) =>
|
||||
wrapInTestApp(
|
||||
<>
|
||||
<ApiProvider apis={apiRegistry()}>
|
||||
<SearchContextProvider>
|
||||
<Story />
|
||||
</SearchContextProvider>
|
||||
</ApiProvider>
|
||||
</>,
|
||||
<SearchContextProvider mockedResults={mockResults}>
|
||||
<Story />
|
||||
</SearchContextProvider>,
|
||||
{ mountedRoutes: { '/search': rootRouteRef } },
|
||||
),
|
||||
],
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { renderInTestApp } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { useLocation, useOutlet } from 'react-router';
|
||||
import { useSearch } from '../SearchContext';
|
||||
import { SearchPage } from './';
|
||||
import { SearchPage } from './SearchPage';
|
||||
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
|
||||
@@ -14,83 +14,83 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { List, ListItem } from '@material-ui/core';
|
||||
import { SearchResult, SearchContext, DefaultResultListItem } from '../index';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { Link } from '@backstage/core-components';
|
||||
import { List, ListItem } from '@material-ui/core';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { DefaultResultListItem } from '../DefaultResultListItem';
|
||||
import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories';
|
||||
import { SearchResult } from './SearchResult';
|
||||
|
||||
const mockResults = {
|
||||
results: [
|
||||
{
|
||||
type: 'custom-result-item',
|
||||
document: {
|
||||
location: 'search/search-result-1',
|
||||
title: 'Search Result 1',
|
||||
text: 'some text from the search result',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'no-custom-result-item',
|
||||
document: {
|
||||
location: 'search/search-result-2',
|
||||
title: 'Search Result 2',
|
||||
text: 'some text from the search result',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'no-custom-result-item',
|
||||
document: {
|
||||
location: 'search/search-result-3',
|
||||
title: 'Search Result 3',
|
||||
text: 'some text from the search result',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default {
|
||||
title: 'Plugins/Search/SearchResult',
|
||||
component: SearchResult,
|
||||
};
|
||||
|
||||
const defaultValue = {
|
||||
result: {
|
||||
loading: false,
|
||||
error: '',
|
||||
value: {
|
||||
results: [
|
||||
{
|
||||
type: 'custom-result-item',
|
||||
document: {
|
||||
location: 'search/search-result-1',
|
||||
title: 'Search Result 1',
|
||||
text: 'some text from the search result',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'no-custom-result-item',
|
||||
document: {
|
||||
location: 'search/search-result-2',
|
||||
title: 'Search Result 2',
|
||||
text: 'some text from the search result',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'no-custom-result-item',
|
||||
document: {
|
||||
location: 'search/search-result-3',
|
||||
title: 'Search Result 3',
|
||||
text: 'some text from the search result',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story: ComponentType<{}>) => (
|
||||
<MemoryRouter>
|
||||
<SearchContextProvider mockedResults={mockResults}>
|
||||
<Story />
|
||||
</SearchContextProvider>
|
||||
</MemoryRouter>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<MemoryRouter>
|
||||
{/* @ts-ignore (defaultValue requires more than what is used here) */}
|
||||
<SearchContext.Provider value={defaultValue}>
|
||||
<SearchResult>
|
||||
{({ results }) => (
|
||||
<List>
|
||||
{results.map(({ type, document }) => {
|
||||
switch (type) {
|
||||
case 'custom-result-item':
|
||||
return (
|
||||
<DefaultResultListItem
|
||||
key={document.location}
|
||||
result={document}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<ListItem>
|
||||
<Link to={document.location}>
|
||||
{document.title} - {document.text}
|
||||
</Link>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</SearchResult>
|
||||
</SearchContext.Provider>
|
||||
</MemoryRouter>
|
||||
<SearchResult>
|
||||
{({ results }) => (
|
||||
<List>
|
||||
{results.map(({ type, document }) => {
|
||||
switch (type) {
|
||||
case 'custom-result-item':
|
||||
return (
|
||||
<DefaultResultListItem
|
||||
key={document.location}
|
||||
result={document}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<ListItem>
|
||||
<Link to={document.location}>
|
||||
{document.title} - {document.text}
|
||||
</Link>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</SearchResult>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2021 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 } from 'react';
|
||||
import { useAnalytics } from '@backstage/core-plugin-api';
|
||||
import { useSearch } from '../SearchContext';
|
||||
|
||||
/**
|
||||
* Capture search event on term change.
|
||||
*/
|
||||
export const TrackSearch = ({ children }: { children: React.ReactChild }) => {
|
||||
const analytics = useAnalytics();
|
||||
const { term } = useSearch();
|
||||
|
||||
useEffect(() => {
|
||||
if (term) {
|
||||
// Capture analytics search event with search term provided as value
|
||||
analytics.captureEvent('search', term);
|
||||
}
|
||||
}, [analytics, term]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
+2
-15
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
* Copyright 2021 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.
|
||||
@@ -13,17 +13,4 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './DefaultResultListItem';
|
||||
export * from './Filters';
|
||||
export * from './SearchBar';
|
||||
export * from './SearchContext';
|
||||
export * from './SearchFilter';
|
||||
export * from './SearchModal';
|
||||
export * from './SearchPage';
|
||||
export * from './SearchResult';
|
||||
export * from './SearchResultPager';
|
||||
export * from './SearchType';
|
||||
export * from './SidebarSearch';
|
||||
export * from './SidebarSearchModal';
|
||||
export * from './HomePageComponent';
|
||||
export { TrackSearch } from './SearchTracker';
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ApiProvider } from '@backstage/core-app-api';
|
||||
import { TestApiRegistry } from '@backstage/test-utils';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import user from '@testing-library/user-event';
|
||||
|
||||
import { searchApiRef } from '../../apis';
|
||||
import { SearchContext, SearchContextProvider } from '../SearchContext';
|
||||
import { SearchType } from './SearchType';
|
||||
|
||||
describe('SearchType.Accordion', () => {
|
||||
const query = jest.fn();
|
||||
const mockApis = TestApiRegistry.from([searchApiRef, { query }]);
|
||||
|
||||
const contextSpy = {
|
||||
result: { loading: false, value: { results: [] } },
|
||||
term: '',
|
||||
types: [],
|
||||
filters: {},
|
||||
toggleModal: jest.fn(),
|
||||
setTerm: jest.fn(),
|
||||
setTypes: jest.fn(),
|
||||
setFilters: jest.fn(),
|
||||
setPageCursor: jest.fn(),
|
||||
};
|
||||
|
||||
const expectedLabel = 'Expected Label';
|
||||
const expectedType = {
|
||||
value: 'expected-type',
|
||||
name: 'Expected Type',
|
||||
icon: <></>,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
query.mockResolvedValue({ results: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should render as expected', async () => {
|
||||
const { getByText } = render(
|
||||
<ApiProvider apis={mockApis}>
|
||||
<SearchContextProvider>
|
||||
<SearchType.Accordion name={expectedLabel} types={[expectedType]} />
|
||||
</SearchContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
// The given label should be rendered.
|
||||
expect(getByText(expectedLabel)).toBeInTheDocument();
|
||||
|
||||
// "Collapse" is visible by default (element is not collapsed)
|
||||
expect(getByText('Collapse')).toBeInTheDocument();
|
||||
|
||||
// The default "all" type should be rendered.
|
||||
expect(getByText('All')).toBeInTheDocument();
|
||||
|
||||
// The given type is also visible
|
||||
expect(getByText(expectedType.name)).toBeInTheDocument();
|
||||
|
||||
await act(() => Promise.resolve());
|
||||
});
|
||||
|
||||
it('should set entire types array when a type is selected', () => {
|
||||
const { getByText } = render(
|
||||
<SearchContext.Provider value={contextSpy}>
|
||||
<SearchType.Accordion name={expectedLabel} types={[expectedType]} />
|
||||
</SearchContext.Provider>,
|
||||
);
|
||||
|
||||
user.click(getByText(expectedType.name));
|
||||
|
||||
expect(contextSpy.setTypes).toHaveBeenCalledWith([expectedType.value]);
|
||||
});
|
||||
|
||||
it('should reset types array when all is selected', () => {
|
||||
const { getByText } = render(
|
||||
<SearchContext.Provider value={contextSpy}>
|
||||
<SearchType.Accordion
|
||||
name={expectedLabel}
|
||||
defaultValue={expectedType.value}
|
||||
types={[expectedType]}
|
||||
/>
|
||||
</SearchContext.Provider>,
|
||||
);
|
||||
|
||||
user.click(getByText('All'));
|
||||
|
||||
expect(contextSpy.setTypes).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('should reset page cursor when a new type is selected', () => {
|
||||
const { getByText } = render(
|
||||
<SearchContext.Provider value={contextSpy}>
|
||||
<SearchType.Accordion name={expectedLabel} types={[expectedType]} />
|
||||
</SearchContext.Provider>,
|
||||
);
|
||||
|
||||
user.click(getByText(expectedType.name));
|
||||
|
||||
expect(contextSpy.setPageCursor).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('should collapse when a new type is selected', () => {
|
||||
const { getByText, queryByText } = render(
|
||||
<SearchContext.Provider value={contextSpy}>
|
||||
<SearchType.Accordion name={expectedLabel} types={[expectedType]} />
|
||||
</SearchContext.Provider>,
|
||||
);
|
||||
|
||||
user.click(getByText(expectedType.name));
|
||||
|
||||
expect(queryByText('Collapse')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright 2021 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, { cloneElement, Fragment, useEffect, useState } from 'react';
|
||||
import { useSearch } from '../SearchContext';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
import AllIcon from '@material-ui/icons/FontDownload';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
card: {
|
||||
backgroundColor: 'rgba(0, 0, 0, .11)',
|
||||
},
|
||||
cardContent: {
|
||||
paddingTop: theme.spacing(1),
|
||||
},
|
||||
icon: {
|
||||
color: theme.palette.common.black,
|
||||
},
|
||||
list: {
|
||||
width: '100%',
|
||||
},
|
||||
listItemIcon: {
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
},
|
||||
accordion: {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
},
|
||||
accordionSummary: {
|
||||
minHeight: 'auto',
|
||||
'&.Mui-expanded': {
|
||||
minHeight: 'auto',
|
||||
},
|
||||
},
|
||||
accordionSummaryContent: {
|
||||
margin: theme.spacing(2, 0),
|
||||
'&.Mui-expanded': {
|
||||
margin: theme.spacing(2, 0),
|
||||
},
|
||||
},
|
||||
accordionDetails: {
|
||||
padding: theme.spacing(0, 0, 1),
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type SearchTypeAccordionProps = {
|
||||
name: string;
|
||||
types: Array<{
|
||||
value: string;
|
||||
name: string;
|
||||
icon: JSX.Element;
|
||||
}>;
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
export const SearchTypeAccordion = (props: SearchTypeAccordionProps) => {
|
||||
const classes = useStyles();
|
||||
const { setPageCursor, setTypes, types } = useSearch();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const { defaultValue, name, types: givenTypes } = props;
|
||||
|
||||
const toggleExpanded = () => setExpanded(prevState => !prevState);
|
||||
const handleClick = (type: string) => {
|
||||
return () => {
|
||||
setTypes(type !== '' ? [type] : []);
|
||||
setPageCursor(undefined);
|
||||
setExpanded(false);
|
||||
};
|
||||
};
|
||||
|
||||
// Handle any provided defaultValue
|
||||
useEffect(() => {
|
||||
if (defaultValue) {
|
||||
setTypes([defaultValue]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const definedTypes = [
|
||||
{
|
||||
value: '',
|
||||
name: 'All',
|
||||
icon: <AllIcon />,
|
||||
},
|
||||
...givenTypes,
|
||||
];
|
||||
const selected = types[0] || '';
|
||||
|
||||
return (
|
||||
<Card className={classes.card}>
|
||||
<CardHeader title={name} titleTypographyProps={{ variant: 'overline' }} />
|
||||
<CardContent className={classes.cardContent}>
|
||||
<Accordion
|
||||
className={classes.accordion}
|
||||
expanded={expanded}
|
||||
onChange={toggleExpanded}
|
||||
>
|
||||
<AccordionSummary
|
||||
classes={{
|
||||
root: classes.accordionSummary,
|
||||
content: classes.accordionSummaryContent,
|
||||
}}
|
||||
expandIcon={<ExpandMoreIcon className={classes.icon} />}
|
||||
IconButtonProps={{ size: 'small' }}
|
||||
>
|
||||
{expanded
|
||||
? 'Collapse'
|
||||
: definedTypes.filter(t => t.value === selected)[0]!.name}
|
||||
</AccordionSummary>
|
||||
<AccordionDetails classes={{ root: classes.accordionDetails }}>
|
||||
<List
|
||||
className={classes.list}
|
||||
component="nav"
|
||||
aria-label="filter by type"
|
||||
disablePadding
|
||||
dense
|
||||
>
|
||||
{definedTypes.map(type => (
|
||||
<Fragment key={type.value}>
|
||||
<Divider />
|
||||
<ListItem
|
||||
selected={
|
||||
types[0] === type.value ||
|
||||
(types.length === 0 && type.value === '')
|
||||
}
|
||||
onClick={handleClick(type.value)}
|
||||
button
|
||||
>
|
||||
<ListItemIcon>
|
||||
{cloneElement(type.icon, {
|
||||
className: classes.listItemIcon,
|
||||
})}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={type.name} />
|
||||
</ListItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -13,24 +13,51 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { SearchType } from '../index';
|
||||
import { SearchContext } from '../SearchContext';
|
||||
import { Grid, Paper } from '@material-ui/core';
|
||||
import CatalogIcon from '@material-ui/icons/MenuBook';
|
||||
import DocsIcon from '@material-ui/icons/Description';
|
||||
import UsersGroupsIcon from '@material-ui/icons/Person';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories';
|
||||
import { SearchType } from './SearchType';
|
||||
|
||||
export default {
|
||||
title: 'Plugins/Search/SearchType',
|
||||
component: SearchType,
|
||||
decorators: [
|
||||
(Story: ComponentType<{}>) => (
|
||||
<SearchContextProvider>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={4}>
|
||||
<Story />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SearchContextProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
const values = ['value-1', 'value-2', 'value-3'];
|
||||
|
||||
export const Default = () => {
|
||||
const [types, setTypes] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<SearchContext.Provider value={{ types, setTypes } as any}>
|
||||
<Paper style={{ padding: 10 }}>
|
||||
<SearchType name="Search type" values={values} defaultValue={values[0]} />
|
||||
</SearchContext.Provider>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export const Accordion = () => {
|
||||
return (
|
||||
<SearchType.Accordion
|
||||
name="Result Types"
|
||||
defaultValue="value-1"
|
||||
types={[
|
||||
{ value: 'value-1', name: 'Value One', icon: <CatalogIcon /> },
|
||||
{ value: 'value-2', name: 'Value Two', icon: <DocsIcon /> },
|
||||
{ value: 'value-3', name: 'Value Three', icon: <UsersGroupsIcon /> },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,6 +25,10 @@ import {
|
||||
} from '@material-ui/core';
|
||||
import React, { ChangeEvent } from 'react';
|
||||
import { useEffectOnce } from 'react-use';
|
||||
import {
|
||||
SearchTypeAccordion,
|
||||
SearchTypeAccordionProps,
|
||||
} from './SearchType.Accordion';
|
||||
import { useSearch } from '../SearchContext';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
@@ -41,6 +45,9 @@ const useStyles = makeStyles(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type SearchTypeProps = {
|
||||
className?: string;
|
||||
name: string;
|
||||
@@ -48,12 +55,8 @@ export type SearchTypeProps = {
|
||||
defaultValue?: string[] | string | null;
|
||||
};
|
||||
|
||||
const SearchType = ({
|
||||
values = [],
|
||||
className,
|
||||
name,
|
||||
defaultValue,
|
||||
}: SearchTypeProps) => {
|
||||
const SearchType = (props: SearchTypeProps) => {
|
||||
const { className, defaultValue, name, values = [] } = props;
|
||||
const classes = useStyles();
|
||||
const { types, setTypes } = useSearch();
|
||||
|
||||
@@ -112,4 +115,14 @@ const SearchType = ({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A control surface for the search query's "types" property, displayed as a
|
||||
* single-select collapsible accordion suitable for use in faceted search UIs.
|
||||
* @public
|
||||
*/
|
||||
SearchType.Accordion = (props: SearchTypeAccordionProps) => {
|
||||
return <SearchTypeAccordion {...props} />;
|
||||
};
|
||||
|
||||
export { SearchType };
|
||||
export type { SearchTypeAccordionProps };
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
export { SearchType } from './SearchType';
|
||||
export type { SearchTypeAccordionProps, SearchTypeProps } from './SearchType';
|
||||
|
||||
+22
-22
@@ -22,30 +22,30 @@
|
||||
|
||||
export { searchApiRef } from './apis';
|
||||
export type { SearchApi } from './apis';
|
||||
export {
|
||||
Filters,
|
||||
FiltersButton,
|
||||
SearchBar,
|
||||
SearchBarBase,
|
||||
SearchContextProvider,
|
||||
SearchFilter,
|
||||
SearchFilterNext,
|
||||
SearchModal,
|
||||
SearchPage as Router,
|
||||
SearchResultPager,
|
||||
SearchType,
|
||||
SidebarSearch,
|
||||
useSearch,
|
||||
} from './components';
|
||||
|
||||
export { Filters, FiltersButton } from './components/Filters';
|
||||
export type { FiltersState } from './components/Filters';
|
||||
export type { HomePageSearchBarProps } from './components/HomePageComponent';
|
||||
export { SearchBar, SearchBarBase } from './components/SearchBar';
|
||||
export type {
|
||||
SearchModalProps,
|
||||
SidebarSearchModalProps,
|
||||
HomePageSearchBarProps,
|
||||
SidebarSearchProps,
|
||||
FiltersState,
|
||||
SearchBarProps,
|
||||
SearchBarBaseProps,
|
||||
} from './components';
|
||||
SearchBarProps,
|
||||
} from './components/SearchBar';
|
||||
export { SearchContextProvider, useSearch } from './components/SearchContext';
|
||||
export { SearchFilter, SearchFilterNext } from './components/SearchFilter';
|
||||
export { SearchModal } from './components/SearchModal';
|
||||
export type { SearchModalProps } from './components/SearchModal';
|
||||
export { SearchPage as Router } from './components/SearchPage';
|
||||
export { SearchResultPager } from './components/SearchResultPager';
|
||||
export { SearchType } from './components/SearchType';
|
||||
export type {
|
||||
SearchTypeAccordionProps,
|
||||
SearchTypeProps,
|
||||
} from './components/SearchType';
|
||||
export { SidebarSearch } from './components/SidebarSearch';
|
||||
export type { SidebarSearchProps } from './components/SidebarSearch';
|
||||
export type { SidebarSearchModalProps } from './components/SidebarSearchModal';
|
||||
|
||||
export {
|
||||
DefaultResultListItem,
|
||||
HomePageSearchBar,
|
||||
|
||||
@@ -5814,22 +5814,22 @@
|
||||
integrity sha512-64WWqE40U/WwWV8iIQBseTU+b2t+SdJSyQoCLdVPCKM9uf7KOjRivVwXe4KlWoV3y7duNSGuB2UgWhkXzscVmQ==
|
||||
|
||||
"@storybook/addon-a11y@^6.3.4":
|
||||
version "6.3.12"
|
||||
resolved "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-6.3.12.tgz#2f930fc84fc275a4ed43a716fc09cc12caf4e110"
|
||||
integrity sha512-q1NdRHFJV6sLEEJw0hatCc5ZIthELqM/AWdrEWDyhcJNyiq7Tq4nKqQBMTQSYwHiUAmxVgw7i4oa1vM2M51/3g==
|
||||
version "6.4.9"
|
||||
resolved "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-6.4.9.tgz#95cd51ad7a71e4c0a39259df2eb10c382bf324db"
|
||||
integrity sha512-9LwFprh7A3KWmQRTqnyh/wvQ1SX/BewzhBPWqJzq0fGnx9fG35zt0VFtXV13i157wTRDqSO1g92Dgxw9l3S8/A==
|
||||
dependencies:
|
||||
"@storybook/addons" "6.3.12"
|
||||
"@storybook/api" "6.3.12"
|
||||
"@storybook/channels" "6.3.12"
|
||||
"@storybook/client-api" "6.3.12"
|
||||
"@storybook/client-logger" "6.3.12"
|
||||
"@storybook/components" "6.3.12"
|
||||
"@storybook/core-events" "6.3.12"
|
||||
"@storybook/theming" "6.3.12"
|
||||
"@storybook/addons" "6.4.9"
|
||||
"@storybook/api" "6.4.9"
|
||||
"@storybook/channels" "6.4.9"
|
||||
"@storybook/client-logger" "6.4.9"
|
||||
"@storybook/components" "6.4.9"
|
||||
"@storybook/core-events" "6.4.9"
|
||||
"@storybook/csf" "0.0.2--canary.87bc651.0"
|
||||
"@storybook/theming" "6.4.9"
|
||||
axe-core "^4.2.0"
|
||||
core-js "^3.8.2"
|
||||
global "^4.4.0"
|
||||
lodash "^4.17.20"
|
||||
lodash "^4.17.21"
|
||||
react-sizeme "^3.0.1"
|
||||
regenerator-runtime "^0.13.7"
|
||||
ts-dedent "^2.0.0"
|
||||
@@ -5911,7 +5911,7 @@
|
||||
global "^4.4.0"
|
||||
regenerator-runtime "^0.13.7"
|
||||
|
||||
"@storybook/addons@6.3.12", "@storybook/addons@^6.1.11":
|
||||
"@storybook/addons@6.3.12":
|
||||
version "6.3.12"
|
||||
resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.3.12.tgz#8773dcc113c5086dfff722388b7b65580e43b65b"
|
||||
integrity sha512-UgoMyr7Qr0FS3ezt8u6hMEcHgyynQS9ucr5mAwZky3wpXRPFyUTmMto9r4BBUdqyUvTUj/LRKIcmLBfj+/l0Fg==
|
||||
@@ -5941,6 +5941,23 @@
|
||||
global "^4.4.0"
|
||||
regenerator-runtime "^0.13.7"
|
||||
|
||||
"@storybook/addons@6.4.9", "@storybook/addons@^6.1.11":
|
||||
version "6.4.9"
|
||||
resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.4.9.tgz#43b5dabf6781d863fcec0a0b293c236b4d5d4433"
|
||||
integrity sha512-y+oiN2zd+pbRWwkf6aQj4tPDFn+rQkrv7fiVoMxsYub+kKyZ3CNOuTSJH+A1A+eBL6DmzocChUyO6jvZFuh6Dg==
|
||||
dependencies:
|
||||
"@storybook/api" "6.4.9"
|
||||
"@storybook/channels" "6.4.9"
|
||||
"@storybook/client-logger" "6.4.9"
|
||||
"@storybook/core-events" "6.4.9"
|
||||
"@storybook/csf" "0.0.2--canary.87bc651.0"
|
||||
"@storybook/router" "6.4.9"
|
||||
"@storybook/theming" "6.4.9"
|
||||
"@types/webpack-env" "^1.16.0"
|
||||
core-js "^3.8.2"
|
||||
global "^4.4.0"
|
||||
regenerator-runtime "^0.13.7"
|
||||
|
||||
"@storybook/api@6.3.11":
|
||||
version "6.3.11"
|
||||
resolved "https://registry.npmjs.org/@storybook/api/-/api-6.3.11.tgz#ea3806a0570da65bfb5b39e4edb90289b5ba701e"
|
||||
@@ -6019,6 +6036,29 @@
|
||||
ts-dedent "^2.0.0"
|
||||
util-deprecate "^1.0.2"
|
||||
|
||||
"@storybook/api@6.4.9":
|
||||
version "6.4.9"
|
||||
resolved "https://registry.npmjs.org/@storybook/api/-/api-6.4.9.tgz#6187d08658629580f0a583f2069d55b34964b34a"
|
||||
integrity sha512-U+YKcDQg8xal9sE5eSMXB9vcqk8fD1pSyewyAjjbsW5hV0B3L3i4u7z/EAD9Ujbnor+Cvxq+XGvp+Qnc5Gd40A==
|
||||
dependencies:
|
||||
"@storybook/channels" "6.4.9"
|
||||
"@storybook/client-logger" "6.4.9"
|
||||
"@storybook/core-events" "6.4.9"
|
||||
"@storybook/csf" "0.0.2--canary.87bc651.0"
|
||||
"@storybook/router" "6.4.9"
|
||||
"@storybook/semver" "^7.3.2"
|
||||
"@storybook/theming" "6.4.9"
|
||||
core-js "^3.8.2"
|
||||
fast-deep-equal "^3.1.3"
|
||||
global "^4.4.0"
|
||||
lodash "^4.17.21"
|
||||
memoizerific "^1.11.3"
|
||||
regenerator-runtime "^0.13.7"
|
||||
store2 "^2.12.0"
|
||||
telejson "^5.3.2"
|
||||
ts-dedent "^2.0.0"
|
||||
util-deprecate "^1.0.2"
|
||||
|
||||
"@storybook/builder-webpack4@6.3.11":
|
||||
version "6.3.11"
|
||||
resolved "https://registry.npmjs.org/@storybook/builder-webpack4/-/builder-webpack4-6.3.11.tgz#b1b62a41b2fbd951733e86aaa4730dc2541b4221"
|
||||
@@ -6148,6 +6188,15 @@
|
||||
ts-dedent "^2.0.0"
|
||||
util-deprecate "^1.0.2"
|
||||
|
||||
"@storybook/channels@6.4.9":
|
||||
version "6.4.9"
|
||||
resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.4.9.tgz#132c574d3fb2e6aaa9c52312c592794699b9d8ec"
|
||||
integrity sha512-DNW1qDg+1WFS2aMdGh658WJXh8xBXliO5KAn0786DKcWCsKjfsPPQg/QCHczHK0+s5SZyzQT5aOBb4kTRHELQA==
|
||||
dependencies:
|
||||
core-js "^3.8.2"
|
||||
ts-dedent "^2.0.0"
|
||||
util-deprecate "^1.0.2"
|
||||
|
||||
"@storybook/client-api@6.3.11":
|
||||
version "6.3.11"
|
||||
resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.3.11.tgz#3e6548bf6e83a2958db701cf59740a2519eea771"
|
||||
@@ -6220,6 +6269,14 @@
|
||||
core-js "^3.8.2"
|
||||
global "^4.4.0"
|
||||
|
||||
"@storybook/client-logger@6.4.9":
|
||||
version "6.4.9"
|
||||
resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.4.9.tgz#ef6af30fac861fea69c8917120ed06b4c2f0b54e"
|
||||
integrity sha512-BVagmmHcuKDZ/XROADfN3tiolaDW2qG0iLmDhyV1gONnbGE6X5Qm19Jt2VYu3LvjKF1zMPSWm4mz7HtgdwKbuQ==
|
||||
dependencies:
|
||||
core-js "^3.8.2"
|
||||
global "^4.4.0"
|
||||
|
||||
"@storybook/components@6.3.11":
|
||||
version "6.3.11"
|
||||
resolved "https://registry.npmjs.org/@storybook/components/-/components-6.3.11.tgz#a7d015fc9808d0200d033d8f694db79277770030"
|
||||
@@ -6310,6 +6367,36 @@
|
||||
ts-dedent "^2.0.0"
|
||||
util-deprecate "^1.0.2"
|
||||
|
||||
"@storybook/components@6.4.9":
|
||||
version "6.4.9"
|
||||
resolved "https://registry.npmjs.org/@storybook/components/-/components-6.4.9.tgz#caed59eb3f09d1646da748186f718a0e54fb8fd7"
|
||||
integrity sha512-uOUR97S6kjptkMCh15pYNM1vAqFXtpyneuonmBco5vADJb3ds0n2a8NeVd+myIbhIXn55x0OHKiSwBH/u7swCQ==
|
||||
dependencies:
|
||||
"@popperjs/core" "^2.6.0"
|
||||
"@storybook/client-logger" "6.4.9"
|
||||
"@storybook/csf" "0.0.2--canary.87bc651.0"
|
||||
"@storybook/theming" "6.4.9"
|
||||
"@types/color-convert" "^2.0.0"
|
||||
"@types/overlayscrollbars" "^1.12.0"
|
||||
"@types/react-syntax-highlighter" "11.0.5"
|
||||
color-convert "^2.0.1"
|
||||
core-js "^3.8.2"
|
||||
fast-deep-equal "^3.1.3"
|
||||
global "^4.4.0"
|
||||
lodash "^4.17.21"
|
||||
markdown-to-jsx "^7.1.3"
|
||||
memoizerific "^1.11.3"
|
||||
overlayscrollbars "^1.13.1"
|
||||
polished "^4.0.5"
|
||||
prop-types "^15.7.2"
|
||||
react-colorful "^5.1.2"
|
||||
react-popper-tooltip "^3.1.1"
|
||||
react-syntax-highlighter "^13.5.3"
|
||||
react-textarea-autosize "^8.3.0"
|
||||
regenerator-runtime "^0.13.7"
|
||||
ts-dedent "^2.0.0"
|
||||
util-deprecate "^1.0.2"
|
||||
|
||||
"@storybook/core-client@6.3.11":
|
||||
version "6.3.11"
|
||||
resolved "https://registry.npmjs.org/@storybook/core-client/-/core-client-6.3.11.tgz#beede8dfb0b0d86945f0b15ef574c6ed8af37576"
|
||||
@@ -6408,6 +6495,13 @@
|
||||
dependencies:
|
||||
core-js "^3.8.2"
|
||||
|
||||
"@storybook/core-events@6.4.9":
|
||||
version "6.4.9"
|
||||
resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.4.9.tgz#7febedb8d263fbd6e4a69badbfcdce0101e6f782"
|
||||
integrity sha512-YhU2zJr6wzvh5naYYuy/0UKNJ/SaXu73sIr0Tx60ur3bL08XkRg7eZ9vBhNBTlAa35oZqI0iiGCh0ljiX7yEVQ==
|
||||
dependencies:
|
||||
core-js "^3.8.2"
|
||||
|
||||
"@storybook/core-server@6.3.11":
|
||||
version "6.3.11"
|
||||
resolved "https://registry.npmjs.org/@storybook/core-server/-/core-server-6.3.11.tgz#e6cfdab8de72254007de50473186734c26bbcbc7"
|
||||
@@ -6483,6 +6577,13 @@
|
||||
dependencies:
|
||||
lodash "^4.17.15"
|
||||
|
||||
"@storybook/csf@0.0.2--canary.87bc651.0":
|
||||
version "0.0.2--canary.87bc651.0"
|
||||
resolved "https://registry.npmjs.org/@storybook/csf/-/csf-0.0.2--canary.87bc651.0.tgz#c7b99b3a344117ef67b10137b6477a3d2750cf44"
|
||||
integrity sha512-ajk1Uxa+rBpFQHKrCcTmJyQBXZ5slfwHVEaKlkuFaW77it8RgbPJp/ccna3sgoi8oZ7FkkOyvv1Ve4SmwFqRqw==
|
||||
dependencies:
|
||||
lodash "^4.17.15"
|
||||
|
||||
"@storybook/manager-webpack4@6.3.11":
|
||||
version "6.3.11"
|
||||
resolved "https://registry.npmjs.org/@storybook/manager-webpack4/-/manager-webpack4-6.3.11.tgz#477a796da09a771b2d3e1adfe217a79de37057af"
|
||||
@@ -6627,6 +6728,23 @@
|
||||
qs "^6.10.0"
|
||||
ts-dedent "^2.0.0"
|
||||
|
||||
"@storybook/router@6.4.9":
|
||||
version "6.4.9"
|
||||
resolved "https://registry.npmjs.org/@storybook/router/-/router-6.4.9.tgz#7cc3f85494f4e14d38925e2802145df69a071201"
|
||||
integrity sha512-GT2KtVHo/mBjxDBFB5ZtVJVf8vC+3p5kRlQC4jao68caVp7H24ikPOkcY54VnQwwe4A1aXpGbJXUyTisEPFlhQ==
|
||||
dependencies:
|
||||
"@storybook/client-logger" "6.4.9"
|
||||
core-js "^3.8.2"
|
||||
fast-deep-equal "^3.1.3"
|
||||
global "^4.4.0"
|
||||
history "5.0.0"
|
||||
lodash "^4.17.21"
|
||||
memoizerific "^1.11.3"
|
||||
qs "^6.10.0"
|
||||
react-router "^6.0.0"
|
||||
react-router-dom "^6.0.0"
|
||||
ts-dedent "^2.0.0"
|
||||
|
||||
"@storybook/semver@^7.3.2":
|
||||
version "7.3.2"
|
||||
resolved "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz#f3b9c44a1c9a0b933c04e66d0048fcf2fa10dac0"
|
||||
@@ -6705,6 +6823,24 @@
|
||||
resolve-from "^5.0.0"
|
||||
ts-dedent "^2.0.0"
|
||||
|
||||
"@storybook/theming@6.4.9":
|
||||
version "6.4.9"
|
||||
resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.4.9.tgz#8ece44007500b9a592e71eca693fbeac90803b0d"
|
||||
integrity sha512-Do6GH6nKjxfnBg6djcIYAjss5FW9SRKASKxLYxX2RyWJBpz0m/8GfcGcRyORy0yFTk6jByA3Hs+WFH3GnEbWkw==
|
||||
dependencies:
|
||||
"@emotion/core" "^10.1.1"
|
||||
"@emotion/is-prop-valid" "^0.8.6"
|
||||
"@emotion/styled" "^10.0.27"
|
||||
"@storybook/client-logger" "6.4.9"
|
||||
core-js "^3.8.2"
|
||||
deep-object-diff "^1.1.0"
|
||||
emotion-theming "^10.0.27"
|
||||
global "^4.4.0"
|
||||
memoizerific "^1.11.3"
|
||||
polished "^4.0.5"
|
||||
resolve-from "^5.0.0"
|
||||
ts-dedent "^2.0.0"
|
||||
|
||||
"@storybook/ui@6.3.11":
|
||||
version "6.3.11"
|
||||
resolved "https://registry.npmjs.org/@storybook/ui/-/ui-6.3.11.tgz#fc04d6ab50b78dc5f3d8fdc1eade0c78e4c4a4e9"
|
||||
@@ -7886,15 +8022,7 @@
|
||||
resolved "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.0.tgz#f946cefb5c05c64f460090f6be97bd50460c8898"
|
||||
integrity sha512-RNBIyVwa/1v2r8/SqK8tadH2sJlFRAo5Ghac/cOcCv4Kp94m0I03UmAh9WVhCqS9ZdB84dF3x47p9aTw8E4c4A==
|
||||
|
||||
"@types/node-fetch@^2.5.0", "@types/node-fetch@^2.5.7":
|
||||
version "2.5.8"
|
||||
resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.8.tgz#e199c835d234c7eb0846f6618012e558544ee2fb"
|
||||
integrity sha512-fbjI6ja0N5ZA8TV53RUqzsKNkl9fv8Oj3T7zxW7FGv1GSH7gwJaNF8dzCjrqKaxKeUpTz4yT1DaJFq/omNpGfw==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
form-data "^3.0.0"
|
||||
|
||||
"@types/node-fetch@^2.5.12":
|
||||
"@types/node-fetch@^2.5.0", "@types/node-fetch@^2.5.12", "@types/node-fetch@^2.5.7":
|
||||
version "2.5.12"
|
||||
resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.12.tgz#8a6f779b1d4e60b7a57fb6fd48d84fb545b9cc66"
|
||||
integrity sha512-MKgC4dlq4kKNa/mYrwpKfzQMB5X3ee5U6fSprkKpToBqBmX4nFZL9cW5jl6sWn+xpRJ7ypWh2yyqqr8UUCstSw==
|
||||
@@ -10006,12 +10134,7 @@ aws4@^1.11.0, aws4@^1.8.0:
|
||||
resolved "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
|
||||
integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
|
||||
|
||||
axe-core@^4.0.2:
|
||||
version "4.1.3"
|
||||
resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.1.3.tgz#64a4c85509e0991f5168340edc4bedd1ceea6966"
|
||||
integrity sha512-vwPpH4Aj4122EW38mxO/fxhGKtwWTMLDIJfZ1He0Edbtjcfna/R3YB67yVhezUMzqc3Jr3+Ii50KRntlENL4xQ==
|
||||
|
||||
axe-core@^4.2.0:
|
||||
axe-core@^4.0.2, axe-core@^4.2.0:
|
||||
version "4.3.1"
|
||||
resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.3.1.tgz#0c6a076e4a1c3e0544ba6a9479158f9be7a7928e"
|
||||
integrity sha512-3WVgVPs/7OnKU3s+lqMtkv3wQlg3WxK1YifmpJSDO0E1aPBrZWlrrTO6cxRqCXLuX2aYgCljqXIQd0VnRidV0g==
|
||||
@@ -16645,6 +16768,13 @@ highlight.js@^10.7.2, highlight.js@~10.7.0:
|
||||
resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531"
|
||||
integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==
|
||||
|
||||
history@5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.npmjs.org/history/-/history-5.0.0.tgz#0cabbb6c4bbf835addb874f8259f6d25101efd08"
|
||||
integrity sha512-3NyRMKIiFSJmIPdq7FxkNMJkQ7ZEtVblOQ38VtKaA0zZMW1Eo6Q6W8oDKEflr1kNNTItSnk4JMCO1deeSgbLLg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.7.6"
|
||||
|
||||
history@^5.0.0:
|
||||
version "5.1.0"
|
||||
resolved "https://registry.npmjs.org/history/-/history-5.1.0.tgz#2e93c09c064194d38d52ed62afd0afc9d9b01ece"
|
||||
@@ -16652,6 +16782,13 @@ history@^5.0.0:
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.7.6"
|
||||
|
||||
history@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.npmjs.org/history/-/history-5.2.0.tgz#7cdd31cf9bac3c5d31f09c231c9928fad0007b7c"
|
||||
integrity sha512-uPSF6lAJb3nSePJ43hN3eKj1dTWpN9gMod0ZssbFTIsen+WehTmEadgL+kg78xLJFdRfrrC//SavDzmRVdE+Ig==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.7.6"
|
||||
|
||||
hmac-drbg@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
|
||||
@@ -23948,13 +24085,13 @@ promzard@^0.3.0:
|
||||
read "1"
|
||||
|
||||
prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.7, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
|
||||
version "15.7.2"
|
||||
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
|
||||
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
|
||||
version "15.8.0"
|
||||
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.0.tgz#d237e624c45a9846e469f5f31117f970017ff588"
|
||||
integrity sha512-fDGekdaHh65eI3lMi5OnErU6a8Ighg2KjcjQxO7m8VHyWjcPyj5kiOgV1LQDOOOgVy3+5FgjXvdSSX7B8/5/4g==
|
||||
dependencies:
|
||||
loose-envify "^1.4.0"
|
||||
object-assign "^4.1.1"
|
||||
react-is "^16.8.1"
|
||||
react-is "^16.13.1"
|
||||
|
||||
property-expr@^2.0.4:
|
||||
version "2.0.4"
|
||||
@@ -24549,7 +24686,7 @@ react-inspector@^5.1.0, react-inspector@^5.1.1:
|
||||
is-dom "^1.0.0"
|
||||
prop-types "^15.0.0"
|
||||
|
||||
react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0:
|
||||
react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0:
|
||||
version "16.13.1"
|
||||
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||
@@ -24651,6 +24788,14 @@ react-router-dom@6.0.0-beta.0, react-router-dom@^6.0.0-beta.0:
|
||||
prop-types "^15.7.2"
|
||||
react-router "6.0.0-beta.0"
|
||||
|
||||
react-router-dom@^6.0.0:
|
||||
version "6.2.1"
|
||||
resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.2.1.tgz#32ec81829152fbb8a7b045bf593a22eadf019bec"
|
||||
integrity sha512-I6Zax+/TH/cZMDpj3/4Fl2eaNdcvoxxHoH1tYOREsQ22OKDYofGebrNm6CTPUcvLvZm63NL/vzCYdjf9CUhqmA==
|
||||
dependencies:
|
||||
history "^5.2.0"
|
||||
react-router "6.2.1"
|
||||
|
||||
react-router@6.0.0-beta.0, react-router@^6.0.0-beta.0:
|
||||
version "6.0.0-beta.0"
|
||||
resolved "https://registry.npmjs.org/react-router/-/react-router-6.0.0-beta.0.tgz#3e11f39b6ded4412c2fed9e4f989dd4c8156724d"
|
||||
@@ -24658,6 +24803,13 @@ react-router@6.0.0-beta.0, react-router@^6.0.0-beta.0:
|
||||
dependencies:
|
||||
prop-types "^15.7.2"
|
||||
|
||||
react-router@6.2.1, react-router@^6.0.0:
|
||||
version "6.2.1"
|
||||
resolved "https://registry.npmjs.org/react-router/-/react-router-6.2.1.tgz#be2a97a6006ce1d9123c28934e604faef51448a3"
|
||||
integrity sha512-2fG0udBtxou9lXtK97eJeET2ki5//UWfQSl1rlJ7quwe6jrktK9FCCc8dQb5QY6jAv3jua8bBQRhhDOM/kVRsg==
|
||||
dependencies:
|
||||
history "^5.2.0"
|
||||
|
||||
react-side-effect@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.0.tgz#1ce4a8b4445168c487ed24dab886421f74d380d3"
|
||||
@@ -26785,9 +26937,9 @@ stacktrace-js@^2.0.2:
|
||||
stacktrace-gps "^3.0.4"
|
||||
|
||||
start-server-and-test@^1.10.11:
|
||||
version "1.13.1"
|
||||
resolved "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-1.13.1.tgz#c06eb18c3f31d610724722b7eecbdf2550b03582"
|
||||
integrity sha512-wZjksmjG5scEHXmV/3HWzImxNzUgaNQ6W8kkqL2GbiOldM+nqiqh7niimlC9ZGNopTGj16kheWZnZtSWgdBZNQ==
|
||||
version "1.14.0"
|
||||
resolved "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-1.14.0.tgz#c57f04f73eac15dd51733b551d775b40837fdde3"
|
||||
integrity sha512-on5ELuxO2K0t8EmNj9MtVlFqwBMxfWOhu4U7uZD1xccVpFlOQKR93CSe0u98iQzfNxRyaNTb/CdadbNllplTsw==
|
||||
dependencies:
|
||||
bluebird "3.7.2"
|
||||
check-more-types "2.24.0"
|
||||
|
||||
Reference in New Issue
Block a user