Merge branch 'backstage:master' into master
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
The `create-plugin` and `create` commands have both been deprecated in favor of a new `new` command. The `new` command is functionally identical to `create`, but the new naming makes it possible to use as yarn script, since `yarn create` is reserved.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-app-api': patch
|
||||
---
|
||||
|
||||
When using React Router v6 stable, it is now possible for components within the `Route` element tree to have `path` props, although they will be ignored.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
The `versions:bump` command will now update dependency ranges in `package.json`, even if the new version is within the current range.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Added support for custom certificate for webpack dev server.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
'@backstage/plugin-search-react': minor
|
||||
---
|
||||
|
||||
Provides search autocomplete functionality through a `SearchAutocomplete` component.
|
||||
A `SearchAutocompleteDefaultOption` can also be used to render options with icons, primary texts, and secondary texts.
|
||||
Example:
|
||||
|
||||
```jsx
|
||||
import React, { ChangeEvent, useState, useCallback } from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import { Grid, Paper } from '@material-ui/core';
|
||||
|
||||
import { Page, Content } from '@backstage/core-components';
|
||||
import { SearchAutocomplete, SearchAutocompleteDefaultOption} from '@backstage/plugin-search-react';
|
||||
|
||||
const OptionsIcon = () => <svg />
|
||||
|
||||
const SearchPage = () => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const options = useAsync(async () => {
|
||||
// Gets and returns autocomplete options
|
||||
}, [inputValue])
|
||||
|
||||
const useCallback((_event: ChangeEvent<{}>, newInputValue: string) => {
|
||||
setInputValue(newInputValue);
|
||||
}, [setInputValue])
|
||||
|
||||
return (
|
||||
<Page themeId="home">
|
||||
<Content>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={12}>
|
||||
<Paper>
|
||||
<SearchAutocomplete
|
||||
options={options}
|
||||
inputValue={inputValue}
|
||||
inputDebounceTime={100}
|
||||
onInputChange={handleInputChange}
|
||||
getOptionLabel={option => option.title}
|
||||
renderOption={option => (
|
||||
<SearchAutocompleteDefaultOption
|
||||
icon={<OptionIcon />}
|
||||
primaryText={option.title}
|
||||
secondaryText={option.text}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{'/* Filters and results are omitted */'}
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search': patch
|
||||
---
|
||||
|
||||
Use the new `inheritParentContextIfAvailable` search context property in `SearchModal` instead of manually checking if a parent context exists, this conditional statement was previously duplicated in more than one component like in `SearchBar` as well and is now only done in ` SearchContextProvider`.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-search-react': minor
|
||||
---
|
||||
|
||||
We noticed a repeated check for the existence of a parent context before creating a child search context in more the one component such as Search Modal and Search Bar and to remove code duplication we extract the conditional to the context provider, now you can use it passing an `inheritParentContextIfAvailable` prop to the `SearchContextProvider`.
|
||||
|
||||
Note: This added property does not create a local context if there is a parent context and in this case, you cannot use it together with `initialState`, it will result in a type error because the parent context is already initialized.
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Added `yarn new` as one of the scripts installed by default, which calls `backstage-cli new`. This script replaces `create-plugin`, which you can now remove if you want to. It is kept in the `create-app` template for backwards compatibility.
|
||||
|
||||
The `remove-plugin` command has been removed, as it has been removed from the Backstage CLI.
|
||||
|
||||
To apply these changes to an existing app, make the following change to the root `package.json`:
|
||||
|
||||
```diff
|
||||
- "remove-plugin": "backstage-cli remove-plugin"
|
||||
+ "new": "backstage-cli new --scope internal"
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Added a new `backstage-cli repo clean` command that cleans the repo root and runs the clean script in all packages.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs': patch
|
||||
---
|
||||
|
||||
Use the new `SearchAutocomplete` component in the `TechDocsSearch` component to maintain consistency across search experiences and avoid code duplication.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Updated the root `package.json` to use the new `backstage-cli repo clean` command.
|
||||
|
||||
To apply this change to an existing project, make the following change to the root `package.json`:
|
||||
|
||||
```diff
|
||||
- "clean": "backstage-cli clean && lerna run clean",
|
||||
+ "clean": "backstage-cli repo clean",
|
||||
```
|
||||
@@ -7,7 +7,6 @@ app:
|
||||
# applicationId: qwerty
|
||||
# site: # datadoghq.eu default = datadoghq.com
|
||||
# env: # optional
|
||||
|
||||
support:
|
||||
url: https://github.com/backstage/backstage/issues # Used by common ErrorPage
|
||||
items: # Used by common SupportButton component
|
||||
|
||||
@@ -154,3 +154,39 @@ schema migrations as well, but you can do so in any manner that you see fit.
|
||||
See the [Knex library documentation](http://knexjs.org/) for examples and
|
||||
details on how to write schema migrations and perform SQL queries against your
|
||||
database..
|
||||
|
||||
## Making Use of the User's Identity
|
||||
|
||||
The Backstage backend comes with a facility for retrieving the identity of the
|
||||
logged in user.
|
||||
|
||||
As part of the environment object that is passed to your `createPlugin`
|
||||
function, there is a `identity` field. You can use that to get an identity
|
||||
from the request.
|
||||
|
||||
```ts
|
||||
// in packages/backend/src/plugins/carmen.ts
|
||||
export default async function createPlugin(
|
||||
env: PluginEnvironment,
|
||||
): Promise<Router> {
|
||||
return await createRouter({
|
||||
model: model,
|
||||
logger: env.logger,
|
||||
identity: env.identity,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The plugin can then extract the identity from the request.
|
||||
|
||||
```ts
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const router = Router();
|
||||
|
||||
router.post('/example', async (req, res) => {
|
||||
const identity = await identity.getIdentity({ request: req });
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
+4
-5
@@ -13,8 +13,8 @@
|
||||
"build:api-reports:only": "ts-node -T -P scripts/tsconfig.json scripts/api-extractor.ts",
|
||||
"build:api-docs": "yarn build:api-reports --docs",
|
||||
"tsc": "tsc",
|
||||
"tsc:full": "backstage-cli clean && tsc --skipLibCheck false --incremental false",
|
||||
"clean": "backstage-cli clean && lerna run clean",
|
||||
"tsc:full": "backstage-cli repo clean && tsc --skipLibCheck false --incremental false",
|
||||
"clean": "backstage-cli repo clean",
|
||||
"diff": "lerna run diff --",
|
||||
"test": "backstage-cli test",
|
||||
"test:all": "lerna run test -- --coverage",
|
||||
@@ -23,9 +23,8 @@
|
||||
"lint:all": "backstage-cli repo lint",
|
||||
"lint:type-deps": "node scripts/check-type-dependencies.js",
|
||||
"docker-build": "yarn tsc && yarn workspace example-backend build --build-dependencies && yarn workspace example-backend build-image",
|
||||
"backstage-create": "backstage-cli create --scope backstage --no-private",
|
||||
"create-plugin": "yarn backstage-create --select plugin",
|
||||
"remove-plugin": "backstage-cli remove-plugin",
|
||||
"new": "backstage-cli new --scope backstage --no-private",
|
||||
"create-plugin": "echo \"use 'yarn new' instead\"",
|
||||
"release": "node scripts/prepare-release.js && changeset version && yarn diff --yes && yarn prettier --write '{packages,plugins}/*/{package.json,CHANGELOG.md}' '.changeset/*.json' && yarn install --no-immutable",
|
||||
"prettier:check": "prettier --check .",
|
||||
"lerna": "lerna",
|
||||
|
||||
+25
-29
@@ -12,8 +12,7 @@ Options:
|
||||
-h, --help
|
||||
|
||||
Commands:
|
||||
create [options]
|
||||
create-plugin [options]
|
||||
new [options]
|
||||
plugin:diff [options]
|
||||
test
|
||||
config:docs [options]
|
||||
@@ -100,20 +99,6 @@ Options:
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli create`
|
||||
|
||||
```
|
||||
Usage: backstage-cli create [options]
|
||||
|
||||
Options:
|
||||
--select <name>
|
||||
--option <name>=<value>
|
||||
--scope <scope>
|
||||
--npm-registry <URL>
|
||||
--no-private
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli create-github-app`
|
||||
|
||||
```
|
||||
@@ -123,19 +108,6 @@ Options:
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli create-plugin`
|
||||
|
||||
```
|
||||
Usage: backstage-cli create-plugin [options]
|
||||
|
||||
Options:
|
||||
--backend
|
||||
--scope <scope>
|
||||
--npm-registry <URL>
|
||||
--no-private
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli info`
|
||||
|
||||
```
|
||||
@@ -197,6 +169,20 @@ Options:
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli new`
|
||||
|
||||
```
|
||||
Usage: backstage-cli new [options]
|
||||
|
||||
Options:
|
||||
--select <name>
|
||||
--option <name>=<value>
|
||||
--scope <scope>
|
||||
--npm-registry <URL>
|
||||
--no-private
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli package`
|
||||
|
||||
```
|
||||
@@ -415,6 +401,7 @@ Options:
|
||||
Commands:
|
||||
build [options]
|
||||
lint [options]
|
||||
clean
|
||||
help [command]
|
||||
```
|
||||
|
||||
@@ -429,6 +416,15 @@ Options:
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli repo clean`
|
||||
|
||||
```
|
||||
Usage: backstage-cli repo clean [options]
|
||||
|
||||
Options:
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli repo lint`
|
||||
|
||||
```
|
||||
|
||||
@@ -248,6 +248,32 @@
|
||||
"description": "The port that the frontend should be bound to. Only used for local development."
|
||||
}
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"type": "object",
|
||||
"description": "Only used for local development. The https object is passed to webpack in order to enable using https on localhost.",
|
||||
"properties": {
|
||||
"certificate": {
|
||||
"type": "object",
|
||||
"description": "Parent object containing certificate and the private key",
|
||||
"required": [
|
||||
"key",
|
||||
"cert"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"visibility": "secret",
|
||||
"description": "Https Certificate private key. Use $file to load in a file"
|
||||
},
|
||||
"cert": {
|
||||
"type": "string",
|
||||
"visibility": "secret",
|
||||
"description": "Https Certificate. Use $file to load in a file"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,11 @@ export function registerRepoCommand(program: Command) {
|
||||
.option('--fix', 'Attempt to automatically fix violations')
|
||||
.action(lazy(() => import('./repo/lint').then(m => m.command)));
|
||||
|
||||
command
|
||||
.command('clean')
|
||||
.description('Delete cache and output directories')
|
||||
.action(lazy(() => import('./repo/clean').then(m => m.command)));
|
||||
|
||||
command
|
||||
.command('list-deprecations', { hidden: true })
|
||||
.description('List deprecations. [EXPERIMENTAL]')
|
||||
@@ -193,7 +198,7 @@ export function registerMigrateCommand(program: Command) {
|
||||
|
||||
export function registerCommands(program: Command) {
|
||||
program
|
||||
.command('create')
|
||||
.command('new')
|
||||
.storeOptionsAsProperties(false)
|
||||
.description(
|
||||
'Open up an interactive guide to creating new things in your app',
|
||||
@@ -214,15 +219,39 @@ export function registerCommands(program: Command) {
|
||||
'The package registry to use for new packages',
|
||||
)
|
||||
.option('--no-private', 'Do not mark new packages as private')
|
||||
.action(lazy(() => import('./create/create').then(m => m.default)));
|
||||
.action(lazy(() => import('./new/new').then(m => m.default)));
|
||||
|
||||
program
|
||||
.command('create-plugin')
|
||||
.command('create', { hidden: true })
|
||||
.storeOptionsAsProperties(false)
|
||||
.description(
|
||||
'Open up an interactive guide to creating new things in your app [DEPRECATED]',
|
||||
)
|
||||
.option(
|
||||
'--select <name>',
|
||||
'Select the thing you want to be creating upfront',
|
||||
)
|
||||
.option(
|
||||
'--option <name>=<value>',
|
||||
'Pre-fill options for the creation process',
|
||||
(opt, arr: string[]) => [...arr, opt],
|
||||
[],
|
||||
)
|
||||
.option('--scope <scope>', 'The scope to use for new packages')
|
||||
.option(
|
||||
'--npm-registry <URL>',
|
||||
'The package registry to use for new packages',
|
||||
)
|
||||
.option('--no-private', 'Do not mark new packages as private')
|
||||
.action(lazy(() => import('./new/new').then(m => m.default)));
|
||||
|
||||
program
|
||||
.command('create-plugin', { hidden: true })
|
||||
.option(
|
||||
'--backend',
|
||||
'Create plugin with the backend dependencies as default',
|
||||
)
|
||||
.description('Creates a new plugin in the current repository')
|
||||
.description('Creates a new plugin in the current repository [DEPRECATED]')
|
||||
.option('--scope <scope>', 'npm scope')
|
||||
.option('--npm-registry <URL>', 'npm registry URL')
|
||||
.option('--no-private', 'Public npm package')
|
||||
|
||||
@@ -18,7 +18,7 @@ import os from 'os';
|
||||
import fs from 'fs-extra';
|
||||
import { join as joinPath } from 'path';
|
||||
import { OptionValues } from 'commander';
|
||||
import { FactoryRegistry } from '../../lib/create/FactoryRegistry';
|
||||
import { FactoryRegistry } from '../../lib/new/FactoryRegistry';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { assertError } from '@backstage/errors';
|
||||
import { Task } from '../../lib/tasks';
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { execFile as execFileCb } from 'child_process';
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { promisify } from 'util';
|
||||
import { PackageGraph } from '../../lib/monorepo';
|
||||
import { paths } from '../../lib/paths';
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
export async function command(): Promise<void> {
|
||||
const packages = await PackageGraph.listTargetPackages();
|
||||
|
||||
await fs.remove(paths.resolveTargetRoot('dist'));
|
||||
await fs.remove(paths.resolveTargetRoot('dist-types'));
|
||||
await fs.remove(paths.resolveTargetRoot('coverage'));
|
||||
|
||||
await Promise.all(
|
||||
Array.from(Array(10), async () => {
|
||||
while (packages.length > 0) {
|
||||
const pkg = packages.pop()!;
|
||||
const cleanScript = pkg.packageJson.scripts?.clean;
|
||||
|
||||
if (
|
||||
cleanScript === 'backstage-cli clean' ||
|
||||
cleanScript === 'backstage-cli package clean'
|
||||
) {
|
||||
await fs.remove(resolvePath(pkg.dir, 'dist'));
|
||||
await fs.remove(resolvePath(pkg.dir, 'dist-types'));
|
||||
await fs.remove(resolvePath(pkg.dir, 'coverage'));
|
||||
} else if (cleanScript) {
|
||||
const result = await execFile('yarn', ['run', 'clean'], {
|
||||
cwd: pkg.dir,
|
||||
shell: true,
|
||||
});
|
||||
process.stdout.write(result.stdout);
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -174,6 +174,8 @@ describe('bump', () => {
|
||||
'unlocking @backstage/core@^1.0.3 ~> 1.0.6',
|
||||
'unlocking @backstage/core-api@^1.0.6 ~> 1.0.7',
|
||||
'unlocking @backstage/core-api@^1.0.3 ~> 1.0.7',
|
||||
'bumping @backstage/core in a to ^1.0.6',
|
||||
'bumping @backstage/core in b to ^1.0.6',
|
||||
'bumping @backstage/theme in b to ^2.0.0',
|
||||
'Running yarn install to install new versions',
|
||||
'⚠️ The following packages may have breaking changes:',
|
||||
@@ -210,15 +212,15 @@ describe('bump', () => {
|
||||
expect(packageA).toEqual({
|
||||
name: 'a',
|
||||
dependencies: {
|
||||
'@backstage/core': '^1.0.5', // not bumped since new version is within range
|
||||
'@backstage/core': '^1.0.6',
|
||||
},
|
||||
});
|
||||
const packageB = await fs.readJson('/packages/b/package.json');
|
||||
expect(packageB).toEqual({
|
||||
name: 'b',
|
||||
dependencies: {
|
||||
'@backstage/core': '^1.0.3', // not bumped
|
||||
'@backstage/theme': '^2.0.0', // bumped since newer
|
||||
'@backstage/core': '^1.0.6',
|
||||
'@backstage/theme': '^2.0.0',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -297,6 +299,8 @@ describe('bump', () => {
|
||||
'unlocking @backstage/core-api@^1.0.6 ~> 1.0.7',
|
||||
'unlocking @backstage/core-api@^1.0.3 ~> 1.0.7',
|
||||
'bumping @backstage/theme in b to ^5.0.0',
|
||||
'bumping @backstage/core in b to ^1.0.6',
|
||||
'bumping @backstage/core in a to ^1.0.6',
|
||||
'Your project is now at version 0.0.1, which has been written to backstage.json',
|
||||
'Running yarn install to install new versions',
|
||||
'⚠️ The following packages may have breaking changes:',
|
||||
@@ -333,15 +337,15 @@ describe('bump', () => {
|
||||
expect(packageA).toEqual({
|
||||
name: 'a',
|
||||
dependencies: {
|
||||
'@backstage/core': '^1.0.5', // not bumped since new version is within range
|
||||
'@backstage/core': '^1.0.6',
|
||||
},
|
||||
});
|
||||
const packageB = await fs.readJson('/packages/b/package.json');
|
||||
expect(packageB).toEqual({
|
||||
name: 'b',
|
||||
dependencies: {
|
||||
'@backstage/core': '^1.0.3', // not bumped
|
||||
'@backstage/theme': '^5.0.0', // bumped since newer
|
||||
'@backstage/core': '^1.0.6',
|
||||
'@backstage/theme': '^5.0.0',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -544,6 +548,8 @@ describe('bump', () => {
|
||||
'unlocking @backstage/core-api@^1.0.6 ~> 1.0.7',
|
||||
'unlocking @backstage/core-api@^1.0.3 ~> 1.0.7',
|
||||
'bumping @backstage/theme in b to ^5.0.0',
|
||||
'bumping @backstage/core in b to ^1.0.6',
|
||||
'bumping @backstage/core in a to ^1.0.6',
|
||||
'Your project is now at version 1.0.0, which has been written to backstage.json',
|
||||
'Running yarn install to install new versions',
|
||||
'⚠️ The following packages may have breaking changes:',
|
||||
@@ -650,7 +656,11 @@ describe('bump', () => {
|
||||
'unlocking @backstage-extra/custom@^1.0.1 ~> 1.1.0',
|
||||
'unlocking @backstage/core-api@^1.0.6 ~> 1.0.7',
|
||||
'unlocking @backstage/core-api@^1.0.3 ~> 1.0.7',
|
||||
'bumping @backstage/core in a to ^1.0.6',
|
||||
'bumping @backstage-extra/custom in a to ^1.1.0',
|
||||
'bumping @backstage-extra/custom-two in a to ^2.0.0',
|
||||
'bumping @backstage/core in b to ^1.0.6',
|
||||
'bumping @backstage-extra/custom in b to ^1.1.0',
|
||||
'bumping @backstage-extra/custom-two in b to ^2.0.0',
|
||||
'bumping @backstage/theme in b to ^2.0.0',
|
||||
'Skipping backstage.json update as custom pattern is used',
|
||||
@@ -690,9 +700,9 @@ describe('bump', () => {
|
||||
expect(packageA).toEqual({
|
||||
name: 'a',
|
||||
dependencies: {
|
||||
'@backstage-extra/custom': '^1.0.1',
|
||||
'@backstage-extra/custom': '^1.1.0',
|
||||
'@backstage-extra/custom-two': '^2.0.0',
|
||||
'@backstage/core': '^1.0.5', // not bumped since new version is within range
|
||||
'@backstage/core': '^1.0.6',
|
||||
},
|
||||
});
|
||||
const packageB = await fs.readJson('/packages/b/package.json');
|
||||
@@ -701,8 +711,8 @@ describe('bump', () => {
|
||||
dependencies: {
|
||||
'@backstage-extra/custom': '^1.1.0',
|
||||
'@backstage-extra/custom-two': '^2.0.0',
|
||||
'@backstage/core': '^1.0.3', // not bumped
|
||||
'@backstage/theme': '^2.0.0', // bumped since newer
|
||||
'@backstage/core': '^1.0.6',
|
||||
'@backstage/theme': '^2.0.0',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,13 +124,6 @@ export default async (opts: OptionValues) => {
|
||||
}
|
||||
|
||||
for (const pkg of pkgs) {
|
||||
if (semver.satisfies(target, pkg.range)) {
|
||||
if (semver.minVersion(pkg.range)?.version !== target) {
|
||||
unlocked.push({ name, range: pkg.range, target });
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
versionBumps.set(
|
||||
pkg.name,
|
||||
(versionBumps.get(pkg.name) ?? []).concat({
|
||||
|
||||
@@ -40,6 +40,7 @@ export async function serveBundle(options: ServeOptions) {
|
||||
isDev: true,
|
||||
baseUrl: url,
|
||||
});
|
||||
|
||||
const compiler = webpack(config);
|
||||
|
||||
const server = new WebpackDevServer(
|
||||
@@ -60,7 +61,15 @@ export async function serveBundle(options: ServeOptions) {
|
||||
// See https://github.com/facebookincubator/create-react-app/issues/387.
|
||||
disableDotRule: true,
|
||||
},
|
||||
https: url.protocol === 'https:',
|
||||
https:
|
||||
url.protocol === 'https:'
|
||||
? {
|
||||
cert: options.backendConfig.getString(
|
||||
'app.https.certificate.cert',
|
||||
),
|
||||
key: options.backendConfig.getString('app.https.certificate.key'),
|
||||
}
|
||||
: false,
|
||||
host,
|
||||
port,
|
||||
proxy: pkg.proxy,
|
||||
|
||||
@@ -31,6 +31,7 @@ export type ServeOptions = BundlingPathsOptions & {
|
||||
checksEnabled: boolean;
|
||||
frontendConfig: Config;
|
||||
frontendAppConfigs: AppConfig[];
|
||||
backendConfig: Config;
|
||||
};
|
||||
|
||||
export type BuildOptions = BundlingPathsOptions & {
|
||||
|
||||
@@ -96,11 +96,15 @@ export async function loadCliConfig(options: Options) {
|
||||
});
|
||||
const frontendConfig = ConfigReader.fromConfigs(frontendAppConfigs);
|
||||
|
||||
const backendAppConfigs = schema.process(appConfigs);
|
||||
const backendConfig = ConfigReader.fromConfigs(backendAppConfigs);
|
||||
|
||||
return {
|
||||
schema,
|
||||
appConfigs,
|
||||
frontendConfig,
|
||||
frontendAppConfigs,
|
||||
backendConfig,
|
||||
};
|
||||
} catch (error) {
|
||||
const maybeSchemaError = error as Error & { messages?: string[] };
|
||||
|
||||
@@ -453,18 +453,19 @@ describe('discovery', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw elements within element prop contains a path', () => {
|
||||
expect(() => {
|
||||
traverseElementTree({
|
||||
root: <Route path="foo" element={<Extension3 path="bar" />} />,
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
collectors: {
|
||||
routing: routingV2Collector,
|
||||
},
|
||||
});
|
||||
}).toThrow(
|
||||
'Elements within the element prop tree may not have paths, found "bar"',
|
||||
);
|
||||
it('should ignore path props within route elements', () => {
|
||||
const { routing } = traverseElementTree({
|
||||
root: <Route path="foo" element={<Extension1 path="bar" />} />,
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
collectors: {
|
||||
routing: routingV2Collector,
|
||||
},
|
||||
});
|
||||
expect(sortedEntries(routing.paths)).toEqual([[ref1, 'foo']]);
|
||||
expect(sortedEntries(routing.parents)).toEqual([[ref1, undefined]]);
|
||||
expect(routing.objects).toEqual([
|
||||
routeObj('foo', [ref1], [], undefined, plugin),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw when a routable extension does not have a path set', () => {
|
||||
|
||||
@@ -53,6 +53,8 @@ interface RoutingV2CollectorContext {
|
||||
isElementAncestor?: boolean;
|
||||
}
|
||||
|
||||
// This collects all the mount points and their plugins within an element tree.
|
||||
// Unlike regular traversal this ignores all other things, like path props and mount point gatherers.
|
||||
function collectSubTree(
|
||||
node: ReactNode,
|
||||
entries = new Array<{ routeRef: RouteRef; plugin?: BackstagePlugin }>(),
|
||||
@@ -62,12 +64,6 @@ function collectSubTree(
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.props.path) {
|
||||
throw new Error(
|
||||
`Elements within the element prop tree may not have paths, found "${element.props.path}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const routeRef = getComponentData<RouteRef>(element, 'core.mountPoint');
|
||||
if (routeRef) {
|
||||
const plugin = getComponentData<BackstagePlugin>(element, 'core.plugin');
|
||||
@@ -87,6 +83,16 @@ export const routingV2Collector = createCollector(
|
||||
objects: new Array<BackstageRouteObject>(),
|
||||
}),
|
||||
(acc, node, parent, ctx?: RoutingV2CollectorContext) => {
|
||||
// If we're in an element prop, ignore everything
|
||||
if (ctx?.isElementAncestor) {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Start ignoring everything if we enter an element prop
|
||||
if (parent?.props.element === node) {
|
||||
return { ...ctx, isElementAncestor: true };
|
||||
}
|
||||
|
||||
const pathProp: unknown = node.props?.path;
|
||||
|
||||
const mountPoint = getComponentData<RouteRef>(node, 'core.mountPoint');
|
||||
@@ -98,15 +104,6 @@ export const routingV2Collector = createCollector(
|
||||
);
|
||||
}
|
||||
|
||||
// If we're in an element prop, ignore everything
|
||||
if (ctx?.isElementAncestor) {
|
||||
return ctx;
|
||||
}
|
||||
// Start ignoring everything if we enter an element prop
|
||||
if (parent?.props.element === node) {
|
||||
return { ...ctx, isElementAncestor: true };
|
||||
}
|
||||
|
||||
const parentChildren = ctx?.obj?.children ?? acc.objects;
|
||||
|
||||
if (pathProp !== undefined) {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"build-image": "yarn workspace backend build-image",
|
||||
"tsc": "tsc",
|
||||
"tsc:full": "tsc --skipLibCheck false --incremental false",
|
||||
"clean": "backstage-cli clean && lerna run clean",
|
||||
"clean": "backstage-cli repo clean",
|
||||
"diff": "lerna run diff --",
|
||||
"test": "backstage-cli test",
|
||||
"test:all": "lerna run test -- --coverage",
|
||||
@@ -21,7 +21,7 @@
|
||||
"lint:all": "backstage-cli repo lint",
|
||||
"prettier:check": "prettier --check .",
|
||||
"create-plugin": "backstage-cli create-plugin --scope internal",
|
||||
"remove-plugin": "backstage-cli remove-plugin"
|
||||
"new": "backstage-cli new --scope internal"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
|
||||
import { ApiRef } from '@backstage/core-plugin-api';
|
||||
import { AsyncState } from 'react-use/lib/useAsync';
|
||||
import { AutocompleteProps } from '@material-ui/lab';
|
||||
import { ForwardRefExoticComponent } from 'react';
|
||||
import { InputBaseProps } from '@material-ui/core';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { ListItemTextProps } from '@material-ui/core';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { default as React_2 } from 'react';
|
||||
import { ReactElement } from 'react';
|
||||
@@ -74,6 +77,34 @@ export interface SearchApi {
|
||||
// @public (undocumented)
|
||||
export const searchApiRef: ApiRef<SearchApi>;
|
||||
|
||||
// @public
|
||||
export const SearchAutocomplete: SearchAutocompleteComponent;
|
||||
|
||||
// @public
|
||||
export type SearchAutocompleteComponent = <Option>(
|
||||
props: SearchAutocompleteProps<Option>,
|
||||
) => JSX.Element;
|
||||
|
||||
// @public
|
||||
export const SearchAutocompleteDefaultOption: ({
|
||||
icon,
|
||||
primaryText,
|
||||
primaryTextTypographyProps,
|
||||
secondaryText,
|
||||
secondaryTextTypographyProps,
|
||||
disableTextTypography,
|
||||
}: SearchAutocompleteDefaultOptionProps) => JSX.Element;
|
||||
|
||||
// @public
|
||||
export type SearchAutocompleteDefaultOptionProps = {
|
||||
icon?: ReactNode;
|
||||
primaryText: ListItemTextProps['primary'];
|
||||
primaryTextTypographyProps?: ListItemTextProps['primaryTypographyProps'];
|
||||
secondaryText?: ListItemTextProps['secondary'];
|
||||
secondaryTextTypographyProps?: ListItemTextProps['secondaryTypographyProps'];
|
||||
disableTextTypography?: ListItemTextProps['disableTypography'];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type SearchAutocompleteFilterProps = SearchFilterComponentProps & {
|
||||
filterSelectedOptions?: boolean;
|
||||
@@ -82,21 +113,20 @@ export type SearchAutocompleteFilterProps = SearchFilterComponentProps & {
|
||||
};
|
||||
|
||||
// @public
|
||||
export const SearchBar: ({ onChange, ...props }: SearchBarProps) => JSX.Element;
|
||||
export type SearchAutocompleteProps<Option> = Omit<
|
||||
AutocompleteProps<Option, undefined, undefined, boolean>,
|
||||
'renderInput' | 'disableClearable' | 'multiple'
|
||||
> & {
|
||||
'data-testid'?: string;
|
||||
inputPlaceholder?: SearchBarProps['placeholder'];
|
||||
inputDebounceTime?: SearchBarProps['debounceTime'];
|
||||
};
|
||||
|
||||
// @public
|
||||
export const SearchBarBase: ({
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onSubmit,
|
||||
debounceTime,
|
||||
clearButton,
|
||||
fullWidth,
|
||||
value: defaultValue,
|
||||
inputProps: defaultInputProps,
|
||||
endAdornment: defaultEndAdornment,
|
||||
...props
|
||||
}: SearchBarBaseProps) => JSX.Element;
|
||||
export const SearchBar: ForwardRefExoticComponent<SearchBarProps>;
|
||||
|
||||
// @public
|
||||
export const SearchBarBase: ForwardRefExoticComponent<SearchBarBaseProps>;
|
||||
|
||||
// @public
|
||||
export type SearchBarBaseProps = Omit<InputBaseProps, 'onChange'> & {
|
||||
@@ -116,9 +146,15 @@ export const SearchContextProvider: (
|
||||
) => JSX.Element;
|
||||
|
||||
// @public
|
||||
export type SearchContextProviderProps = PropsWithChildren<{
|
||||
initialState?: SearchContextState;
|
||||
}>;
|
||||
export type SearchContextProviderProps =
|
||||
| PropsWithChildren<{
|
||||
initialState?: SearchContextState;
|
||||
inheritParentContextIfAvailable?: never;
|
||||
}>
|
||||
| PropsWithChildren<{
|
||||
initialState?: never;
|
||||
inheritParentContextIfAvailable?: boolean;
|
||||
}>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type SearchContextState = {
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ComponentType } from 'react';
|
||||
|
||||
import { Grid, makeStyles, Paper } from '@material-ui/core';
|
||||
import LabelIcon from '@material-ui/icons/Label';
|
||||
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
|
||||
import { searchApiRef, MockSearchApi } from '../../api';
|
||||
import { SearchContextProvider } from '../../context';
|
||||
|
||||
import { SearchAutocomplete } from './SearchAutocomplete';
|
||||
import { SearchAutocompleteDefaultOption } from './SearchAutocompleteDefaultOption';
|
||||
|
||||
export default {
|
||||
title: 'Plugins/Search/SearchAutocomplete',
|
||||
component: SearchAutocomplete,
|
||||
decorators: [
|
||||
(Story: ComponentType<{}>) => (
|
||||
<TestApiProvider apis={[[searchApiRef, new MockSearchApi()]]}>
|
||||
<SearchContextProvider>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={12}>
|
||||
<Story />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SearchContextProvider>
|
||||
</TestApiProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
padding: theme.spacing(1),
|
||||
},
|
||||
}));
|
||||
|
||||
export const Default = () => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Paper className={classes.root}>
|
||||
<SearchAutocomplete options={['hello-word', 'petstore', 'spotify']} />
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export const Outlined = () => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Paper className={classes.root} variant="outlined">
|
||||
<SearchAutocomplete options={['hello-word', 'petstore', 'spotify']} />
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export const Initialized = () => {
|
||||
const classes = useStyles();
|
||||
const options = ['hello-word', 'petstore', 'spotify'];
|
||||
return (
|
||||
<Paper className={classes.root}>
|
||||
<SearchAutocomplete options={options} value={options[0]} />
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export const LoadingOptions = () => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Paper className={classes.root}>
|
||||
<SearchAutocomplete options={[]} loading />
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export const RenderingCustomOptions = () => {
|
||||
const classes = useStyles();
|
||||
const options = [
|
||||
{
|
||||
title: 'hello-world',
|
||||
text: 'Hello World example for gRPC',
|
||||
},
|
||||
{
|
||||
title: 'petstore',
|
||||
text: 'The petstore API',
|
||||
},
|
||||
{
|
||||
title: 'spotify',
|
||||
text: 'The Spotify web API',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Paper className={classes.root}>
|
||||
<SearchAutocomplete
|
||||
options={options}
|
||||
renderOption={option => (
|
||||
<SearchAutocompleteDefaultOption
|
||||
icon={<LabelIcon titleAccess="Option icon" />}
|
||||
primaryText={option.title}
|
||||
secondaryText={option.text}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import LabelIcon from '@material-ui/icons/Label';
|
||||
|
||||
import { configApiRef } from '@backstage/core-plugin-api';
|
||||
import { ConfigReader } from '@backstage/core-app-api';
|
||||
import { TestApiProvider, renderWithEffects } from '@backstage/test-utils';
|
||||
|
||||
import { searchApiRef } from '../../api';
|
||||
import { SearchAutocomplete } from './SearchAutocomplete';
|
||||
import { SearchAutocompleteDefaultOption } from './SearchAutocompleteDefaultOption';
|
||||
|
||||
const title = 'Backstage Test App';
|
||||
const configApiMock = new ConfigReader({
|
||||
app: { title },
|
||||
});
|
||||
|
||||
const query = jest.fn().mockResolvedValue({ results: [] });
|
||||
const searchApiMock = { query };
|
||||
|
||||
describe('SearchAutocomplete', () => {
|
||||
const options = ['hello-world', 'petstore', 'spotify'];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Renders without exploding', async () => {
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchAutocomplete options={options} />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('search-autocomplete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Show all options by default when focused', async () => {
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchAutocomplete options={options} />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(options[0])).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(options[1])).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(options[2])).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByPlaceholderText(`Search in ${title}`));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(options[0])).toBeInTheDocument();
|
||||
expect(screen.getByText(options[1])).toBeInTheDocument();
|
||||
expect(screen.getByText(options[2])).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Updates context with the initial value', async () => {
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchAutocomplete value={options[0]} options={options} />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(query).toHaveBeenCalledWith({
|
||||
filters: {},
|
||||
pageCursor: undefined,
|
||||
term: options[0],
|
||||
types: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Updates context when value is cleared', async () => {
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchAutocomplete value={options[0]} options={options} />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(query).toHaveBeenCalledWith({
|
||||
filters: {},
|
||||
pageCursor: undefined,
|
||||
term: options[0],
|
||||
types: [],
|
||||
});
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Clear'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(query).toHaveBeenCalledWith({
|
||||
filters: {},
|
||||
pageCursor: undefined,
|
||||
term: '',
|
||||
types: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Updates context when an option is select', async () => {
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchAutocomplete options={options} />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByPlaceholderText(`Search in ${title}`));
|
||||
|
||||
await userEvent.click(screen.getByText(options[0]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(query).toHaveBeenCalledWith({
|
||||
filters: {},
|
||||
pageCursor: undefined,
|
||||
term: options[0],
|
||||
types: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Shows a circular progress when loading options', async () => {
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchAutocomplete options={options} loading />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('search-autocomplete-progressbar'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Uses the default search autocomplete option component', async () => {
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchAutocomplete
|
||||
options={[
|
||||
{
|
||||
title: 'hello-world',
|
||||
text: 'Hello World example for gRPC',
|
||||
},
|
||||
{
|
||||
title: 'petstore',
|
||||
text: 'The petstore API',
|
||||
},
|
||||
{
|
||||
title: 'spotify',
|
||||
text: 'The Spotify web API',
|
||||
},
|
||||
]}
|
||||
getOptionLabel={option => option.title}
|
||||
renderOption={option => (
|
||||
<SearchAutocompleteDefaultOption
|
||||
icon={<LabelIcon titleAccess="Option icon" />}
|
||||
primaryText={option.title}
|
||||
secondaryText={option.text}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByPlaceholderText(`Search in ${title}`));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTitle('Option icon')).toHaveLength(3);
|
||||
expect(screen.getByText('hello-world')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Hello World example for gRPC'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ChangeEvent, useCallback, useMemo } from 'react';
|
||||
|
||||
import { CircularProgress } from '@material-ui/core';
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteProps,
|
||||
AutocompleteChangeDetails,
|
||||
AutocompleteChangeReason,
|
||||
AutocompleteRenderInputParams,
|
||||
} from '@material-ui/lab';
|
||||
|
||||
import { SearchContextProvider, useSearch } from '../../context';
|
||||
import { SearchBar, SearchBarProps } from '../SearchBar';
|
||||
|
||||
/**
|
||||
* Props for {@link SearchAutocomplete}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SearchAutocompleteProps<Option> = Omit<
|
||||
AutocompleteProps<Option, undefined, undefined, boolean>,
|
||||
'renderInput' | 'disableClearable' | 'multiple'
|
||||
> & {
|
||||
'data-testid'?: string;
|
||||
inputPlaceholder?: SearchBarProps['placeholder'];
|
||||
inputDebounceTime?: SearchBarProps['debounceTime'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Type for {@link SearchAutocomplete}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SearchAutocompleteComponent = <Option>(
|
||||
props: SearchAutocompleteProps<Option>,
|
||||
) => JSX.Element;
|
||||
|
||||
const withContext = (
|
||||
Component: SearchAutocompleteComponent,
|
||||
): SearchAutocompleteComponent => {
|
||||
return props => (
|
||||
<SearchContextProvider inheritParentContextIfAvailable>
|
||||
<Component {...props} />
|
||||
</SearchContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Recommended search autocomplete when you use the Search Provider or Search Context.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const SearchAutocomplete = withContext(
|
||||
function SearchAutocompleteComponent<Option>(
|
||||
props: SearchAutocompleteProps<Option>,
|
||||
) {
|
||||
const {
|
||||
loading,
|
||||
value,
|
||||
onChange = () => {},
|
||||
options = [],
|
||||
getOptionLabel = (option: Option) => String(option),
|
||||
inputPlaceholder,
|
||||
inputDebounceTime,
|
||||
freeSolo = true,
|
||||
fullWidth = true,
|
||||
clearOnBlur = false,
|
||||
'data-testid': dataTestId = 'search-autocomplete',
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const { setTerm } = useSearch();
|
||||
|
||||
const getInputValue = useCallback(
|
||||
(option?: null | string | Option) => {
|
||||
if (!option) return '';
|
||||
if (typeof option === 'string') return option;
|
||||
return getOptionLabel(option);
|
||||
},
|
||||
[getOptionLabel],
|
||||
);
|
||||
|
||||
const inputValue = useMemo(
|
||||
() => getInputValue(value),
|
||||
[value, getInputValue],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(
|
||||
event: ChangeEvent<{}>,
|
||||
option: null | string | Option,
|
||||
reason: AutocompleteChangeReason,
|
||||
details?: AutocompleteChangeDetails<Option>,
|
||||
) => {
|
||||
setTerm(getInputValue(option));
|
||||
onChange(event, option, reason, details);
|
||||
},
|
||||
[getInputValue, setTerm, onChange],
|
||||
);
|
||||
|
||||
const renderInput = useCallback(
|
||||
({
|
||||
InputProps: { ref, endAdornment },
|
||||
InputLabelProps,
|
||||
...params
|
||||
}: AutocompleteRenderInputParams) => (
|
||||
<SearchBar
|
||||
{...params}
|
||||
ref={ref}
|
||||
clearButton={false}
|
||||
value={inputValue}
|
||||
placeholder={inputPlaceholder}
|
||||
debounceTime={inputDebounceTime}
|
||||
endAdornment={
|
||||
loading ? (
|
||||
<CircularProgress
|
||||
data-testid="search-autocomplete-progressbar"
|
||||
color="inherit"
|
||||
size={20}
|
||||
/>
|
||||
) : (
|
||||
endAdornment
|
||||
)
|
||||
}
|
||||
/>
|
||||
),
|
||||
[loading, inputValue, inputPlaceholder, inputDebounceTime],
|
||||
);
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
{...rest}
|
||||
data-testid={dataTestId}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
options={options}
|
||||
getOptionLabel={getOptionLabel}
|
||||
renderInput={renderInput}
|
||||
freeSolo={freeSolo}
|
||||
fullWidth={fullWidth}
|
||||
clearOnBlur={clearOnBlur}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ComponentType, PropsWithChildren } from 'react';
|
||||
|
||||
import { Grid, ListItem } from '@material-ui/core';
|
||||
import LabelIcon from '@material-ui/icons/Label';
|
||||
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
|
||||
import { searchApiRef, MockSearchApi } from '../../api';
|
||||
import { SearchContextProvider } from '../../context';
|
||||
|
||||
import { SearchAutocompleteDefaultOption } from './SearchAutocompleteDefaultOption';
|
||||
|
||||
export default {
|
||||
title: 'Plugins/Search/SearchAutocompleteDefaultOption',
|
||||
component: SearchAutocompleteDefaultOption,
|
||||
decorators: [
|
||||
(Story: ComponentType<{}>) => (
|
||||
<TestApiProvider apis={[[searchApiRef, new MockSearchApi()]]}>
|
||||
<SearchContextProvider>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={12}>
|
||||
<ListItem>
|
||||
<Story />
|
||||
</ListItem>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SearchContextProvider>
|
||||
</TestApiProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const Default = () => (
|
||||
<SearchAutocompleteDefaultOption primaryText="hello-world" />
|
||||
);
|
||||
|
||||
export const Icon = () => (
|
||||
<SearchAutocompleteDefaultOption
|
||||
icon={<LabelIcon />}
|
||||
primaryText="hello-world"
|
||||
/>
|
||||
);
|
||||
|
||||
export const SecondaryText = () => (
|
||||
<SearchAutocompleteDefaultOption
|
||||
primaryText="hello-world"
|
||||
secondaryText="Hello World example for gRPC"
|
||||
/>
|
||||
);
|
||||
|
||||
export const AllCombined = () => (
|
||||
<SearchAutocompleteDefaultOption
|
||||
icon={<LabelIcon />}
|
||||
primaryText="hello-world"
|
||||
secondaryText="Hello World example for gRPC"
|
||||
/>
|
||||
);
|
||||
|
||||
export const CustomTextTypographies = () => (
|
||||
<SearchAutocompleteDefaultOption
|
||||
icon={<LabelIcon />}
|
||||
primaryText="hello-world"
|
||||
primaryTextTypographyProps={{ color: 'primary' }}
|
||||
secondaryText="Hello World example for gRPC"
|
||||
secondaryTextTypographyProps={{ color: 'secondary' }}
|
||||
/>
|
||||
);
|
||||
|
||||
const CustomPrimaryText = ({ children }: PropsWithChildren<{}>) => (
|
||||
<dt>{children}</dt>
|
||||
);
|
||||
|
||||
const CustomSecondaryText = ({ children }: PropsWithChildren<{}>) => (
|
||||
<dd>{children}</dd>
|
||||
);
|
||||
|
||||
export const CustomTextComponents = () => (
|
||||
<dl>
|
||||
<SearchAutocompleteDefaultOption
|
||||
icon={<LabelIcon />}
|
||||
primaryText={<CustomPrimaryText>hello-world</CustomPrimaryText>}
|
||||
secondaryText={
|
||||
<CustomSecondaryText>Hello World example for gRPC</CustomSecondaryText>
|
||||
}
|
||||
disableTextTypography
|
||||
/>
|
||||
</dl>
|
||||
);
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import {
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
ListItemTextProps,
|
||||
} from '@material-ui/core';
|
||||
|
||||
/**
|
||||
* Props for {@link SearchAutocompleteDefaultOption}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SearchAutocompleteDefaultOptionProps = {
|
||||
icon?: ReactNode;
|
||||
primaryText: ListItemTextProps['primary'];
|
||||
primaryTextTypographyProps?: ListItemTextProps['primaryTypographyProps'];
|
||||
secondaryText?: ListItemTextProps['secondary'];
|
||||
secondaryTextTypographyProps?: ListItemTextProps['secondaryTypographyProps'];
|
||||
disableTextTypography?: ListItemTextProps['disableTypography'];
|
||||
};
|
||||
|
||||
/**
|
||||
* A default search autocomplete option component.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const SearchAutocompleteDefaultOption = ({
|
||||
icon,
|
||||
primaryText,
|
||||
primaryTextTypographyProps,
|
||||
secondaryText,
|
||||
secondaryTextTypographyProps,
|
||||
disableTextTypography,
|
||||
}: SearchAutocompleteDefaultOptionProps) => (
|
||||
<>
|
||||
{icon ? <ListItemIcon>{icon}</ListItemIcon> : null}
|
||||
<ListItemText
|
||||
primary={primaryText}
|
||||
primaryTypographyProps={primaryTextTypographyProps}
|
||||
secondary={secondaryText}
|
||||
secondaryTypographyProps={secondaryTextTypographyProps}
|
||||
disableTypography={disableTextTypography}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { SearchAutocomplete } from './SearchAutocomplete';
|
||||
|
||||
export { SearchAutocompleteDefaultOption } from './SearchAutocompleteDefaultOption';
|
||||
|
||||
export type {
|
||||
SearchAutocompleteProps,
|
||||
SearchAutocompleteComponent,
|
||||
} from './SearchAutocomplete';
|
||||
|
||||
export type { SearchAutocompleteDefaultOptionProps } from './SearchAutocompleteDefaultOption';
|
||||
@@ -20,8 +20,12 @@ import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
ComponentType,
|
||||
ForwardRefExoticComponent,
|
||||
} from 'react';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
|
||||
import {
|
||||
InputBase,
|
||||
InputBaseProps,
|
||||
@@ -37,13 +41,17 @@ import {
|
||||
useApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
import {
|
||||
SearchContextProvider,
|
||||
useSearch,
|
||||
useSearchContextCheck,
|
||||
} from '../../context';
|
||||
import { SearchContextProvider, useSearch } from '../../context';
|
||||
import { TrackSearch } from '../SearchTracker';
|
||||
|
||||
function withContext<T>(Component: ComponentType<T>) {
|
||||
return forwardRef<unknown, T>((props, ref) => (
|
||||
<SearchContextProvider inheritParentContextIfAvailable>
|
||||
<Component {...props} ref={ref} />
|
||||
</SearchContextProvider>
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for {@link SearchBarBase}.
|
||||
*
|
||||
@@ -64,95 +72,99 @@ export type SearchBarBaseProps = Omit<InputBaseProps, 'onChange'> & {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const SearchBarBase = ({
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onSubmit,
|
||||
debounceTime = 200,
|
||||
clearButton = true,
|
||||
fullWidth = true,
|
||||
value: defaultValue,
|
||||
inputProps: defaultInputProps = {},
|
||||
endAdornment: defaultEndAdornment,
|
||||
...props
|
||||
}: SearchBarBaseProps) => {
|
||||
const configApi = useApi(configApiRef);
|
||||
const [value, setValue] = useState<string>(defaultValue as string);
|
||||
const hasSearchContext = useSearchContextCheck();
|
||||
export const SearchBarBase: ForwardRefExoticComponent<SearchBarBaseProps> =
|
||||
withContext(
|
||||
forwardRef((props, ref) => {
|
||||
const {
|
||||
onChange,
|
||||
onKeyDown = () => {},
|
||||
onClear = () => {},
|
||||
onSubmit = () => {},
|
||||
debounceTime = 200,
|
||||
clearButton = true,
|
||||
fullWidth = true,
|
||||
value: defaultValue,
|
||||
placeholder: defaultPlaceholder,
|
||||
inputProps: defaultInputProps = {},
|
||||
endAdornment: defaultEndAdornment,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
useEffect(() => {
|
||||
setValue(prevValue =>
|
||||
prevValue !== defaultValue ? (defaultValue as string) : prevValue,
|
||||
);
|
||||
}, [defaultValue]);
|
||||
const configApi = useApi(configApiRef);
|
||||
const [value, setValue] = useState<string>('');
|
||||
|
||||
useDebounce(() => onChange(value), debounceTime, [value]);
|
||||
useEffect(() => {
|
||||
setValue(prevValue =>
|
||||
prevValue !== defaultValue ? String(defaultValue) : prevValue,
|
||||
);
|
||||
}, [defaultValue]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.target.value);
|
||||
},
|
||||
[setValue],
|
||||
useDebounce(() => onChange(value), debounceTime, [value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.target.value);
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (onKeyDown) onKeyDown(e);
|
||||
if (onSubmit && e.key === 'Enter') {
|
||||
onSubmit();
|
||||
}
|
||||
},
|
||||
[onKeyDown, onSubmit],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
onChange('');
|
||||
if (onClear) {
|
||||
onClear();
|
||||
}
|
||||
}, [onChange, onClear]);
|
||||
|
||||
const placeholder =
|
||||
defaultPlaceholder ??
|
||||
`Search in ${configApi.getOptionalString('app.title') || 'Backstage'}`;
|
||||
|
||||
const startAdornment = (
|
||||
<InputAdornment position="start">
|
||||
<IconButton aria-label="Query" size="small" disabled>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
);
|
||||
|
||||
const endAdornment = (
|
||||
<InputAdornment position="end">
|
||||
<IconButton aria-label="Clear" size="small" onClick={handleClear}>
|
||||
<ClearButton />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
);
|
||||
|
||||
return (
|
||||
<TrackSearch>
|
||||
<InputBase
|
||||
data-testid="search-bar-next"
|
||||
ref={ref}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
startAdornment={startAdornment}
|
||||
endAdornment={clearButton ? endAdornment : defaultEndAdornment}
|
||||
inputProps={{ 'aria-label': 'Search', ...defaultInputProps }}
|
||||
fullWidth={fullWidth}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...rest}
|
||||
/>
|
||||
</TrackSearch>
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (onKeyDown) onKeyDown(e);
|
||||
if (onSubmit && e.key === 'Enter') {
|
||||
onSubmit();
|
||||
}
|
||||
},
|
||||
[onKeyDown, onSubmit],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
onChange('');
|
||||
}, [onChange]);
|
||||
|
||||
const placeholder = `Search in ${
|
||||
configApi.getOptionalString('app.title') || 'Backstage'
|
||||
}`;
|
||||
|
||||
const startAdornment = (
|
||||
<InputAdornment position="start">
|
||||
<IconButton aria-label="Query" disabled>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
);
|
||||
|
||||
const endAdornment = (
|
||||
<InputAdornment position="end">
|
||||
<IconButton aria-label="Clear" onClick={handleClear}>
|
||||
<ClearButton />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
);
|
||||
|
||||
const searchBar = (
|
||||
<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>
|
||||
);
|
||||
|
||||
return hasSearchContext ? (
|
||||
searchBar
|
||||
) : (
|
||||
<SearchContextProvider>{searchBar}</SearchContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for {@link SearchBar}.
|
||||
*
|
||||
@@ -165,25 +177,40 @@ export type SearchBarProps = Partial<SearchBarBaseProps>;
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const SearchBar = ({ onChange, ...props }: SearchBarProps) => {
|
||||
const { term, setTerm } = useSearch();
|
||||
export const SearchBar: ForwardRefExoticComponent<SearchBarProps> = withContext(
|
||||
forwardRef((props, ref) => {
|
||||
const { value: initialValue = '', onChange, ...rest } = props;
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValue: string) => {
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
} else {
|
||||
setTerm(newValue);
|
||||
const { term, setTerm } = useSearch();
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValue) {
|
||||
setTerm(String(initialValue));
|
||||
}
|
||||
},
|
||||
[onChange, setTerm],
|
||||
);
|
||||
}, [initialValue, setTerm]);
|
||||
|
||||
return (
|
||||
<AnalyticsContext
|
||||
attributes={{ pluginId: 'search', extension: 'SearchBar' }}
|
||||
>
|
||||
<SearchBarBase value={term} onChange={handleChange} {...props} />
|
||||
</AnalyticsContext>
|
||||
);
|
||||
};
|
||||
const handleChange = useCallback(
|
||||
(newValue: string) => {
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
} else {
|
||||
setTerm(newValue);
|
||||
}
|
||||
},
|
||||
[onChange, setTerm],
|
||||
);
|
||||
|
||||
return (
|
||||
<AnalyticsContext
|
||||
attributes={{ pluginId: 'search', extension: 'SearchBar' }}
|
||||
>
|
||||
<SearchBarBase
|
||||
{...rest}
|
||||
ref={ref}
|
||||
value={term}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</AnalyticsContext>
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
export { SearchBar, SearchBarBase } from './SearchBar';
|
||||
|
||||
export type { SearchBarProps, SearchBarBaseProps } from './SearchBar';
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
*/
|
||||
|
||||
export * from './HighlightedSearchResultText';
|
||||
export * from './SearchBar';
|
||||
export * from './SearchAutocomplete';
|
||||
export * from './SearchFilter';
|
||||
export * from './SearchResult';
|
||||
export * from './SearchResultPager';
|
||||
export * from './SearchBar';
|
||||
export * from './DefaultResultListItem';
|
||||
|
||||
@@ -100,29 +100,16 @@ const searchInitialState: SearchContextState = {
|
||||
types: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for {@link SearchContextProvider}
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SearchContextProviderProps = PropsWithChildren<{
|
||||
initialState?: SearchContextState;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* @public
|
||||
*
|
||||
* Search context provider which gives you access to shared state between search components
|
||||
*/
|
||||
export const SearchContextProvider = (props: SearchContextProviderProps) => {
|
||||
const { initialState = searchInitialState, children } = props;
|
||||
const useSearchContextValue = (
|
||||
initialValue: SearchContextState = searchInitialState,
|
||||
) => {
|
||||
const searchApi = useApi(searchApiRef);
|
||||
const [pageCursor, setPageCursor] = useState<string | undefined>(
|
||||
initialState.pageCursor,
|
||||
initialValue.pageCursor,
|
||||
);
|
||||
const [filters, setFilters] = useState<JsonObject>(initialState.filters);
|
||||
const [term, setTerm] = useState<string>(initialState.term);
|
||||
const [types, setTypes] = useState<string[]>(initialState.types);
|
||||
const [filters, setFilters] = useState<JsonObject>(initialValue.filters);
|
||||
const [term, setTerm] = useState<string>(initialValue.term);
|
||||
const [types, setTypes] = useState<string[]>(initialValue.types);
|
||||
|
||||
const prevTerm = usePrevious(term);
|
||||
|
||||
@@ -170,11 +157,69 @@ export const SearchContextProvider = (props: SearchContextProviderProps) => {
|
||||
fetchPreviousPage: hasPreviousPage ? fetchPreviousPage : undefined,
|
||||
};
|
||||
|
||||
const versionedValue = createVersionedValueMap({ 1: value });
|
||||
return value;
|
||||
};
|
||||
|
||||
export type LocalSearchContextProps = PropsWithChildren<{
|
||||
initialState?: SearchContextState;
|
||||
}>;
|
||||
|
||||
const LocalSearchContext = (props: SearchContextProviderProps) => {
|
||||
const { initialState, children } = props;
|
||||
const value = useSearchContextValue(initialState);
|
||||
|
||||
return (
|
||||
<AnalyticsContext attributes={{ searchTypes: types.sort().join(',') }}>
|
||||
<SearchContext.Provider value={versionedValue} children={children} />
|
||||
<AnalyticsContext
|
||||
attributes={{ searchTypes: value.types.sort().join(',') }}
|
||||
>
|
||||
<SearchContext.Provider value={createVersionedValueMap({ 1: value })}>
|
||||
{children}
|
||||
</SearchContext.Provider>
|
||||
</AnalyticsContext>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for {@link SearchContextProvider}
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SearchContextProviderProps =
|
||||
| PropsWithChildren<{
|
||||
/**
|
||||
* State initialized by a local context.
|
||||
*/
|
||||
initialState?: SearchContextState;
|
||||
/**
|
||||
* Do not create an inheritance from the parent, as a new initial state must be defined in a local context.
|
||||
*/
|
||||
inheritParentContextIfAvailable?: never;
|
||||
}>
|
||||
| PropsWithChildren<{
|
||||
/**
|
||||
* Does not accept initial state since it is already initialized by parent context.
|
||||
*/
|
||||
initialState?: never;
|
||||
/**
|
||||
* If true, don't create a child context if there is a parent one already defined.
|
||||
* @remarks Defaults to false.
|
||||
*/
|
||||
inheritParentContextIfAvailable?: boolean;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* Search context provider which gives you access to shared state between search components
|
||||
*/
|
||||
export const SearchContextProvider = (props: SearchContextProviderProps) => {
|
||||
const { initialState, inheritParentContextIfAvailable, children } = props;
|
||||
const hasParentContext = useSearchContextCheck();
|
||||
|
||||
return hasParentContext && inheritParentContextIfAvailable ? (
|
||||
<>{children}</>
|
||||
) : (
|
||||
<LocalSearchContext initialState={initialState}>
|
||||
{children}
|
||||
</LocalSearchContext>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogActions,
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
SearchResult,
|
||||
SearchResultPager,
|
||||
useSearch,
|
||||
useSearchContextCheck,
|
||||
} from '@backstage/plugin-search-react';
|
||||
import { useRouteRef } from '@backstage/core-plugin-api';
|
||||
import { Link, useContent } from '@backstage/core-components';
|
||||
@@ -171,15 +170,6 @@ export const Modal = ({ toggleModal }: SearchModalProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Context = ({ children }: PropsWithChildren<{}>) => {
|
||||
// Checks if there is a parent context already defined and, if not, creates a new local context.
|
||||
const hasParentContext = useSearchContextCheck();
|
||||
if (hasParentContext) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return <SearchContextProvider>{children}</SearchContextProvider>;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@@ -204,11 +194,11 @@ export const SearchModal = ({
|
||||
hidden={hidden}
|
||||
>
|
||||
{open && (
|
||||
<Context>
|
||||
<SearchContextProvider inheritParentContextIfAvailable>
|
||||
{(children && children({ toggleModal })) ?? (
|
||||
<Modal toggleModal={toggleModal} />
|
||||
)}
|
||||
</Context>
|
||||
</SearchContextProvider>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -15,29 +15,25 @@
|
||||
*/
|
||||
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import { ResultHighlight } from '@backstage/plugin-search-common';
|
||||
import {
|
||||
SearchAutocomplete,
|
||||
SearchContextProvider,
|
||||
useSearch,
|
||||
} from '@backstage/plugin-search-react';
|
||||
import {
|
||||
makeStyles,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
TextField,
|
||||
} from '@material-ui/core';
|
||||
import SearchIcon from '@material-ui/icons/Search';
|
||||
import Autocomplete from '@material-ui/lab/Autocomplete';
|
||||
import React, { ChangeEvent, useEffect, useState } from 'react';
|
||||
import { makeStyles, Paper } from '@material-ui/core';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
import { TechDocsSearchResultListItem } from './TechDocsSearchResultListItem';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
bar: {
|
||||
padding: theme.spacing(1),
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Props for {@link TechDocsSearch}
|
||||
@@ -62,6 +58,13 @@ type TechDocsDoc = {
|
||||
type TechDocsSearchResult = {
|
||||
type: string;
|
||||
document: TechDocsDoc;
|
||||
highlight?: ResultHighlight;
|
||||
};
|
||||
|
||||
const isTechDocsSearchResult = (
|
||||
option: any,
|
||||
): option is TechDocsSearchResult => {
|
||||
return option?.document;
|
||||
};
|
||||
|
||||
const TechDocsSearchBar = (props: TechDocsSearchProps) => {
|
||||
@@ -69,8 +72,6 @@ const TechDocsSearchBar = (props: TechDocsSearchProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
term,
|
||||
setTerm,
|
||||
setFilters,
|
||||
result: { loading, value: searchVal },
|
||||
} = useSearch();
|
||||
@@ -91,10 +92,6 @@ const TechDocsSearchBar = (props: TechDocsSearchProps) => {
|
||||
};
|
||||
}, [loading, searchVal]);
|
||||
|
||||
const [value, setValue] = useState<string>(term);
|
||||
|
||||
useDebounce(() => setTerm(value), debounceTime, [value]);
|
||||
|
||||
// Update the filter context when the entityId changes, e.g. when the search
|
||||
// bar continues to be rendered, navigating between different TechDocs sites.
|
||||
const { kind, name, namespace } = entityId;
|
||||
@@ -109,82 +106,54 @@ const TechDocsSearchBar = (props: TechDocsSearchProps) => {
|
||||
});
|
||||
}, [kind, namespace, name, setFilters]);
|
||||
|
||||
const handleQuery = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!open) {
|
||||
setOpen(true);
|
||||
}
|
||||
setValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleSelection = (_: any, selection: TechDocsSearchResult | null) => {
|
||||
if (selection?.document) {
|
||||
const handleSelection = (
|
||||
_: any,
|
||||
selection: TechDocsSearchResult | string | null,
|
||||
) => {
|
||||
if (isTechDocsSearchResult(selection)) {
|
||||
const { location } = selection.document;
|
||||
navigate(location);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
classes={{ root: classes.root }}
|
||||
data-testid="techdocs-search-bar"
|
||||
size="small"
|
||||
open={open}
|
||||
getOptionLabel={() => ''}
|
||||
filterOptions={x => {
|
||||
return x; // This is needed to get renderOption to be called after options change. Bug in material-ui?
|
||||
}}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
onChange={handleSelection}
|
||||
blurOnSelect
|
||||
noOptionsText="No results found"
|
||||
value={null}
|
||||
options={options}
|
||||
renderOption={({ document, highlight }) => (
|
||||
<TechDocsSearchResultListItem
|
||||
result={document}
|
||||
lineClamp={3}
|
||||
asListItem={false}
|
||||
asLink={false}
|
||||
title={document.title}
|
||||
highlight={highlight}
|
||||
/>
|
||||
)}
|
||||
loading={loading}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
{...params}
|
||||
data-testid="techdocs-search-bar-input"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
placeholder={`Search ${entityTitle || entityId.name} docs`}
|
||||
value={value}
|
||||
onChange={handleQuery}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<IconButton aria-label="Query" disabled>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<React.Fragment>
|
||||
{loading ? (
|
||||
<CircularProgress color="inherit" size={20} />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</React.Fragment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Paper className={classes.bar} variant="outlined">
|
||||
<SearchAutocomplete
|
||||
classes={{ root: classes.root }}
|
||||
data-testid="techdocs-search-bar"
|
||||
size="small"
|
||||
open={open}
|
||||
getOptionLabel={() => ''}
|
||||
filterOptions={x => {
|
||||
return x; // This is needed to get renderOption to be called after options change. Bug in material-ui?
|
||||
}}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
onChange={handleSelection}
|
||||
blurOnSelect
|
||||
noOptionsText="No results found"
|
||||
value={null}
|
||||
options={options}
|
||||
renderOption={({ document, highlight }) => (
|
||||
<TechDocsSearchResultListItem
|
||||
result={document}
|
||||
lineClamp={3}
|
||||
asListItem={false}
|
||||
asLink={false}
|
||||
title={document.title}
|
||||
highlight={highlight}
|
||||
/>
|
||||
)}
|
||||
loading={loading}
|
||||
inputDebounceTime={debounceTime}
|
||||
inputPlaceholder={`Search ${entityTitle || entityId.name} docs`}
|
||||
freeSolo={false}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8203,8 +8203,8 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"@google-cloud/storage@npm:^6.0.0":
|
||||
version: 6.4.1
|
||||
resolution: "@google-cloud/storage@npm:6.4.1"
|
||||
version: 6.4.2
|
||||
resolution: "@google-cloud/storage@npm:6.4.2"
|
||||
dependencies:
|
||||
"@google-cloud/paginator": ^3.0.7
|
||||
"@google-cloud/projectify": ^3.0.0
|
||||
@@ -8224,7 +8224,7 @@ __metadata:
|
||||
retry-request: ^5.0.0
|
||||
teeny-request: ^8.0.0
|
||||
uuid: ^8.0.0
|
||||
checksum: 14e7b79b5ed896f1598bb16a9d5f8ca0432c1d5a5225e563b6ab6dbb397f20196eb4a5c45bdfd866dd1f74f486a9155570cb0d41dc1b170b16555e3e50c96094
|
||||
checksum: ebd8bf46675f21b429915d9889c56574acc395745126e40f96803334c1e2d8b35a60d22b1f0ecf9db4bb67c373f53e4eeef9406af334cdf9c0ca8461da777ab3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -24660,7 +24660,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"graphql@npm:^16.0.0, graphql@npm:^16.3.0":
|
||||
"graphql@npm:^15.0.0 || ^16.0.0, graphql@npm:^16.0.0, graphql@npm:^16.3.0":
|
||||
version: 16.6.0
|
||||
resolution: "graphql@npm:16.6.0"
|
||||
checksum: bf1d9e3c1938ce3c1a81e909bd3ead1ae4707c577f91cff1ca2eca474bfbc7873d5d7b942e1e9777ff5a8304421dba57a4b76d7a29eb19de8711cb70e3c2415e
|
||||
@@ -27527,9 +27527,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"jose@npm:^4.6.0":
|
||||
version: 4.9.1
|
||||
resolution: "jose@npm:4.9.1"
|
||||
checksum: ebd9a4c9610d7fb93e9385f042554f0f3a83b16f3932a0aa42a7b78499c2ffae0607bce6b50a256521b3bf700d65140fdfb8de73988f1f29100491f3312a3707
|
||||
version: 4.9.2
|
||||
resolution: "jose@npm:4.9.2"
|
||||
checksum: d3950385a6417d988c50bd8ba5407f5960624060aa8e4662c2109f1ebcc40c418e64b721a87065d8197b4aa0ddd7fb4dad5064618956dba8e74dc630916bf40f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -30868,8 +30868,8 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"msw@npm:^0.46.0":
|
||||
version: 0.46.0
|
||||
resolution: "msw@npm:0.46.0"
|
||||
version: 0.46.1
|
||||
resolution: "msw@npm:0.46.1"
|
||||
dependencies:
|
||||
"@mswjs/cookies": ^0.2.2
|
||||
"@mswjs/interceptors": ^0.17.2
|
||||
@@ -30879,6 +30879,7 @@ __metadata:
|
||||
chalk: 4.1.1
|
||||
chokidar: ^3.4.2
|
||||
cookie: ^0.4.2
|
||||
graphql: ^15.0.0 || ^16.0.0
|
||||
headers-polyfill: ^3.0.4
|
||||
inquirer: ^8.2.0
|
||||
is-node-process: ^1.0.1
|
||||
@@ -30891,16 +30892,13 @@ __metadata:
|
||||
type-fest: ^2.19.0
|
||||
yargs: ^17.3.1
|
||||
peerDependencies:
|
||||
graphql: ^15.0.0 || ^16.0.0
|
||||
typescript: ">= 4.2.x <= 4.8.x"
|
||||
peerDependenciesMeta:
|
||||
graphql:
|
||||
optional: true
|
||||
typescript:
|
||||
optional: true
|
||||
bin:
|
||||
msw: cli/index.js
|
||||
checksum: 722ed4b149ab1b80715803fd7bba24e51a3741f43b223e3ab4421b31a2df258e777175137e63f14853cf5b6f6444c8e709c581c8d9486b30ed03bb5a17dea017
|
||||
checksum: 50c941cb43acf64e888d7bc3642fdc269a553a3bdcddf8cf12e412acf4c732fa16834b0dc1ad54d1e3e04dc9815cb9f25fb79f45a1109fea5dca049c1495eabd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user