Merge branch 'backstage:master' into topic/improve-badges-frontend-readme
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Only serve static assets if there is a public folder during `app:serve` and `plugin:serve`. This fixes a common bug that would break `plugin:serve` with an `EBUSY` error.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
When issuing a `full` update from an entity provider, entities with updates are now properly persisted.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Configuration schema is now also collected from the root `package.json` if it exists.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-app-api': patch
|
||||
---
|
||||
|
||||
Enables late registration of plugins into the application by updating ApiHolder when additional plugins have been added in.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs': patch
|
||||
---
|
||||
|
||||
Fixed the URL for the "Click to copy documentation link to clipboard" action
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/config-loader': patch
|
||||
---
|
||||
|
||||
Add option to collect configuration schemas from explicit package paths in addition to by package name.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-tech-radar': patch
|
||||
---
|
||||
|
||||
Added `SearchBar` to allow filtering and a scroll bar to display hidden tech
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Improved ´plugin:diff´ check for the `package.json` `"files"` field.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
'@backstage/core-components': minor
|
||||
---
|
||||
|
||||
Checkbox tree filters are no longer available in the Table component:
|
||||
|
||||
- Deleted the `CheckboxTree` component
|
||||
- Removed the filter type `'checkbox-tree'` from the `TableFilter` types.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes': patch
|
||||
---
|
||||
|
||||
Add dashboard support for Rancher
|
||||
@@ -23,7 +23,7 @@ function inject_config() {
|
||||
|
||||
# escape ' and " twice, for both sed and json
|
||||
local config_escaped_1
|
||||
config_escaped_1="$(echo "$config" | jq -cM . | sed -e 's/[\\"\x27]/\\&/g')" # \x27 = '
|
||||
config_escaped_1="$(echo "$config" | jq -cM . | sed -e 's/[\\"'\'']/\\&/g')"
|
||||
# escape / and & for sed
|
||||
local config_escaped_2
|
||||
config_escaped_2="$(echo "$config_escaped_1" | sed -e 's/[\/&]/\\&/g')"
|
||||
|
||||
@@ -13,9 +13,10 @@ which during validation is stitched together into a single schema.
|
||||
## Schema Collection and Definition
|
||||
|
||||
Schemas are collected from all packages and dependencies in each repo that are a
|
||||
part of the Backstage ecosystem, including transitive dependencies. The current
|
||||
definition of "part of the ecosystem" is that a package has at least one
|
||||
dependency in the `@backstage` namespace, but this is subject to change.
|
||||
part of the Backstage ecosystem, including the root package and transitive
|
||||
dependencies. The current definition of "part of the ecosystem" is that a
|
||||
package has at least one dependency in the `@backstage` namespace or a
|
||||
`"configSchema"` field in `package.json`, but this is subject to change.
|
||||
|
||||
Each package is searched for a schema at a single point of entry, a top-level
|
||||
`"configSchema"` field in `package.json`. The field can either contain an
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.9.3",
|
||||
"@backstage/config": "^0.1.10",
|
||||
"@backstage/errors": "^0.1.2",
|
||||
"cross-fetch": "^3.0.6"
|
||||
},
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.7.13",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/lodash": "^4.14.151",
|
||||
"yaml": "^1.9.2"
|
||||
|
||||
@@ -50,10 +50,12 @@ export async function serveBundle(options: ServeOptions) {
|
||||
publicPath: config.output?.publicPath as string,
|
||||
stats: 'errors-warnings',
|
||||
},
|
||||
static: {
|
||||
publicPath: config.output?.publicPath as string,
|
||||
directory: paths.targetPublic ?? '/',
|
||||
},
|
||||
static: paths.targetPublic
|
||||
? {
|
||||
publicPath: config.output?.publicPath as string,
|
||||
directory: paths.targetPublic,
|
||||
}
|
||||
: undefined,
|
||||
historyApiFallback: {
|
||||
// Paths with dots should still use the history fallback.
|
||||
// See https://github.com/facebookincubator/create-react-app/issues/387.
|
||||
|
||||
@@ -39,6 +39,8 @@ export async function loadCliConfig(options: Options) {
|
||||
|
||||
const schema = await loadConfigSchema({
|
||||
dependencies: localPackageNames,
|
||||
// Include the package.json in the project root if it exists
|
||||
packagePaths: [paths.resolveTargetRoot('package.json')],
|
||||
});
|
||||
|
||||
const appConfigs = await loadConfig({
|
||||
|
||||
@@ -85,6 +85,7 @@ class PackageJsonHandler {
|
||||
targetObj: any = this.targetPkg,
|
||||
prefix?: string,
|
||||
sort?: boolean,
|
||||
optional?: boolean,
|
||||
) {
|
||||
const fullFieldName = chalk.cyan(
|
||||
prefix ? `${prefix}[${fieldName}]` : fieldName,
|
||||
@@ -107,7 +108,7 @@ class PackageJsonHandler {
|
||||
}
|
||||
await this.write();
|
||||
}
|
||||
} else if (fieldName in obj) {
|
||||
} else if (fieldName in obj && optional !== true) {
|
||||
if (
|
||||
await this.prompt(
|
||||
`package.json is missing field ${fullFieldName}, set to ${coloredNewValue}?`,
|
||||
@@ -123,11 +124,41 @@ class PackageJsonHandler {
|
||||
}
|
||||
|
||||
private async syncFiles() {
|
||||
if (typeof this.targetPkg.configSchema === 'string') {
|
||||
const files = [...this.pkg.files, this.targetPkg.configSchema];
|
||||
await this.syncField('files', { files });
|
||||
const { configSchema } = this.targetPkg;
|
||||
const hasSchemaFile = typeof configSchema === 'string';
|
||||
|
||||
if (!this.targetPkg.files) {
|
||||
const expected = hasSchemaFile ? ['dist', configSchema] : ['dist'];
|
||||
if (
|
||||
await this.prompt(
|
||||
`package.json is missing field "files", set to ${JSON.stringify(
|
||||
expected,
|
||||
)}?`,
|
||||
)
|
||||
) {
|
||||
this.targetPkg.files = expected;
|
||||
await this.write();
|
||||
}
|
||||
} else {
|
||||
await this.syncField('files');
|
||||
const missing = [];
|
||||
if (!this.targetPkg.files.includes('dist')) {
|
||||
missing.push('dist');
|
||||
}
|
||||
if (hasSchemaFile && !this.targetPkg.files.includes(configSchema)) {
|
||||
missing.push(configSchema);
|
||||
}
|
||||
if (missing.length) {
|
||||
if (
|
||||
await this.prompt(
|
||||
`package.json is missing ${JSON.stringify(
|
||||
missing,
|
||||
)} in the "files" field, add?`,
|
||||
)
|
||||
) {
|
||||
this.targetPkg.files.push(...missing);
|
||||
await this.write();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +231,7 @@ class PackageJsonHandler {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.syncField(key, pkgDeps, targetDeps, fieldName, true);
|
||||
await this.syncField(key, pkgDeps, targetDeps, fieldName, true, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ export function loadConfigSchema(
|
||||
export type LoadConfigSchemaOptions =
|
||||
| {
|
||||
dependencies: string[];
|
||||
packagePaths?: string[];
|
||||
}
|
||||
| {
|
||||
serialized: JsonObject;
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('collectConfigSchemas', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(collectConfigSchemas([])).resolves.toEqual([]);
|
||||
await expect(collectConfigSchemas([], [])).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('should find schema in a local package', async () => {
|
||||
@@ -64,7 +64,7 @@ describe('collectConfigSchemas', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(collectConfigSchemas(['a'])).resolves.toEqual([
|
||||
await expect(collectConfigSchemas(['a'], [])).resolves.toEqual([
|
||||
{
|
||||
path: path.join('node_modules', 'a', 'package.json'),
|
||||
value: mockSchema,
|
||||
@@ -72,8 +72,34 @@ describe('collectConfigSchemas', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should find schema in transitive dependencies', async () => {
|
||||
it('should find schema at explicit package path', async () => {
|
||||
mockFs({
|
||||
root: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'root',
|
||||
configSchema: mockSchema,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
collectConfigSchemas([], [path.join('root', 'package.json')]),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
path: path.join('root', 'package.json'),
|
||||
value: mockSchema,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should find schema in transitive dependencies and explicit path', async () => {
|
||||
mockFs({
|
||||
root: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'root',
|
||||
configSchema: { ...mockSchema, title: 'root' },
|
||||
}),
|
||||
},
|
||||
node_modules: {
|
||||
a: {
|
||||
'package.json': JSON.stringify({
|
||||
@@ -124,20 +150,28 @@ describe('collectConfigSchemas', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(collectConfigSchemas(['a'])).resolves.toEqual([
|
||||
{
|
||||
path: path.join('node_modules', 'b', 'package.json'),
|
||||
value: { ...mockSchema, title: 'b' },
|
||||
},
|
||||
{
|
||||
path: path.join('node_modules', 'c1', 'package.json'),
|
||||
value: { ...mockSchema, title: 'c1' },
|
||||
},
|
||||
{
|
||||
path: path.join('node_modules', 'd1', 'package.json'),
|
||||
value: { ...mockSchema, title: 'd1' },
|
||||
},
|
||||
]);
|
||||
await expect(
|
||||
collectConfigSchemas(['a'], [path.join('root', 'package.json')]),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
path: path.join('node_modules', 'b', 'package.json'),
|
||||
value: { ...mockSchema, title: 'b' },
|
||||
},
|
||||
{
|
||||
path: path.join('node_modules', 'c1', 'package.json'),
|
||||
value: { ...mockSchema, title: 'c1' },
|
||||
},
|
||||
{
|
||||
path: path.join('node_modules', 'd1', 'package.json'),
|
||||
value: { ...mockSchema, title: 'd1' },
|
||||
},
|
||||
{
|
||||
path: path.join('root', 'package.json'),
|
||||
value: { ...mockSchema, title: 'root' },
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should schema of different types', async () => {
|
||||
@@ -171,7 +205,7 @@ describe('collectConfigSchemas', () => {
|
||||
[typescriptModuleDir]: (mockFs as any).load(typescriptModuleDir),
|
||||
});
|
||||
|
||||
await expect(collectConfigSchemas(['a', 'b', 'c'])).resolves.toEqual([
|
||||
await expect(collectConfigSchemas(['a', 'b', 'c'], [])).resolves.toEqual([
|
||||
{
|
||||
path: path.join('node_modules', 'a', 'package.json'),
|
||||
value: { ...mockSchema, title: 'inline' },
|
||||
@@ -210,7 +244,7 @@ describe('collectConfigSchemas', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(collectConfigSchemas(['a'])).rejects.toThrow(
|
||||
await expect(collectConfigSchemas(['a'], [])).rejects.toThrow(
|
||||
'Config schema files must be .json or .d.ts, got schema.yaml',
|
||||
);
|
||||
});
|
||||
@@ -230,7 +264,7 @@ describe('collectConfigSchemas', () => {
|
||||
[typescriptModuleDir]: (mockFs as any).load(typescriptModuleDir),
|
||||
});
|
||||
|
||||
await expect(collectConfigSchemas(['a'])).rejects.toThrow(
|
||||
await expect(collectConfigSchemas(['a'], [])).rejects.toThrow(
|
||||
`Invalid schema in ${path.join(
|
||||
'node_modules',
|
||||
'a',
|
||||
|
||||
@@ -26,8 +26,9 @@ import { getProgramFromFiles, generateSchema } from 'typescript-json-schema';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
|
||||
type Item = {
|
||||
name: string;
|
||||
name?: string;
|
||||
parentPath?: string;
|
||||
packagePath?: string;
|
||||
};
|
||||
|
||||
const req =
|
||||
@@ -40,34 +41,47 @@ const req =
|
||||
*/
|
||||
export async function collectConfigSchemas(
|
||||
packageNames: string[],
|
||||
packagePaths: string[],
|
||||
): Promise<ConfigSchemaPackageEntry[]> {
|
||||
const visitedPackages = new Set<string>();
|
||||
const schemas = Array<ConfigSchemaPackageEntry>();
|
||||
const tsSchemaPaths = Array<string>();
|
||||
const currentDir = await fs.realpath(process.cwd());
|
||||
|
||||
async function processItem({ name, parentPath }: Item) {
|
||||
// Ensures that we only process each package once. We don't bother with
|
||||
// loading in schemas from duplicates of different versions, as that's not
|
||||
// supported by Backstage right now anyway. We may want to change that in
|
||||
// the future though, if it for example becomes possible to load in two
|
||||
// different versions of e.g. @backstage/core at once.
|
||||
if (visitedPackages.has(name)) {
|
||||
return;
|
||||
}
|
||||
visitedPackages.add(name);
|
||||
async function processItem(item: Item) {
|
||||
let pkgPath = item.packagePath;
|
||||
|
||||
let pkgPath: string;
|
||||
try {
|
||||
pkgPath = req.resolve(
|
||||
`${name}/package.json`,
|
||||
parentPath && {
|
||||
paths: [parentPath],
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// We can somewhat safely ignore packages that don't export package.json,
|
||||
// as they are likely not part of the Backstage ecosystem anyway.
|
||||
if (pkgPath) {
|
||||
const pkgExists = await fs.pathExists(pkgPath);
|
||||
if (!pkgExists) {
|
||||
return;
|
||||
}
|
||||
} else if (item.name) {
|
||||
const { name, parentPath } = item;
|
||||
|
||||
// Ensures that we only process each package once. We don't bother with
|
||||
// loading in schemas from duplicates of different versions, as that's not
|
||||
// supported by Backstage right now anyway. We may want to change that in
|
||||
// the future though, if it for example becomes possible to load in two
|
||||
// different versions of e.g. @backstage/core at once.
|
||||
if (visitedPackages.has(name)) {
|
||||
return;
|
||||
}
|
||||
visitedPackages.add(name);
|
||||
|
||||
try {
|
||||
pkgPath = req.resolve(
|
||||
`${name}/package.json`,
|
||||
parentPath && {
|
||||
paths: [parentPath],
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// We can somewhat safely ignore packages that don't export package.json,
|
||||
// as they are likely not part of the Backstage ecosystem anyway.
|
||||
}
|
||||
}
|
||||
if (!pkgPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -126,9 +140,10 @@ export async function collectConfigSchemas(
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
packageNames.map(name => processItem({ name, parentPath: currentDir })),
|
||||
);
|
||||
await Promise.all([
|
||||
...packageNames.map(name => processItem({ name, parentPath: currentDir })),
|
||||
...packagePaths.map(path => processItem({ name: path, packagePath: path })),
|
||||
]);
|
||||
|
||||
const tsSchemas = compileTsSchemas(tsSchemaPaths);
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
export type LoadConfigSchemaOptions =
|
||||
| {
|
||||
dependencies: string[];
|
||||
packagePaths?: string[];
|
||||
}
|
||||
| {
|
||||
serialized: JsonObject;
|
||||
@@ -44,7 +45,10 @@ export async function loadConfigSchema(
|
||||
let schemas: ConfigSchemaPackageEntry[];
|
||||
|
||||
if ('dependencies' in options) {
|
||||
schemas = await collectConfigSchemas(options.dependencies);
|
||||
schemas = await collectConfigSchemas(
|
||||
options.dependencies,
|
||||
options.packagePaths ?? [],
|
||||
);
|
||||
} else {
|
||||
const { serialized } = options;
|
||||
if (serialized?.backstageConfigSchemaVersion !== 1) {
|
||||
|
||||
@@ -198,6 +198,7 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
private readonly bindRoutes: AppOptions['bindRoutes'];
|
||||
|
||||
private readonly identityApi = new AppIdentity();
|
||||
private readonly apiFactoryRegistry: ApiFactoryRegistry;
|
||||
|
||||
constructor(options: FullAppOptions) {
|
||||
this.apis = options.apis;
|
||||
@@ -208,6 +209,7 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
this.configLoader = options.configLoader;
|
||||
this.defaultApis = options.defaultApis;
|
||||
this.bindRoutes = options.bindRoutes;
|
||||
this.apiFactoryRegistry = new ApiFactoryRegistry();
|
||||
}
|
||||
|
||||
getPlugins(): BackstagePlugin<any, any>[] {
|
||||
@@ -388,17 +390,27 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
|
||||
private getApiHolder(): ApiHolder {
|
||||
if (this.apiHolder) {
|
||||
// Register additional plugins if they have been added.
|
||||
// Routes paths, objects and others are already updated in the provider when children of it change
|
||||
for (const plugin of this.plugins) {
|
||||
for (const factory of plugin.getApis()) {
|
||||
if (!this.apiFactoryRegistry.get(factory.api)) {
|
||||
this.apiFactoryRegistry.register('default', factory);
|
||||
}
|
||||
}
|
||||
}
|
||||
ApiResolver.validateFactories(
|
||||
this.apiFactoryRegistry,
|
||||
this.apiFactoryRegistry.getAllApis(),
|
||||
);
|
||||
return this.apiHolder;
|
||||
}
|
||||
|
||||
const registry = new ApiFactoryRegistry();
|
||||
|
||||
registry.register('static', {
|
||||
this.apiFactoryRegistry.register('static', {
|
||||
api: appThemeApiRef,
|
||||
deps: {},
|
||||
factory: () => AppThemeSelector.createWithStorage(this.themes),
|
||||
});
|
||||
registry.register('static', {
|
||||
this.apiFactoryRegistry.register('static', {
|
||||
api: configApiRef,
|
||||
deps: {},
|
||||
factory: () => {
|
||||
@@ -410,7 +422,7 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
return this.configApi;
|
||||
},
|
||||
});
|
||||
registry.register('static', {
|
||||
this.apiFactoryRegistry.register('static', {
|
||||
api: identityApiRef,
|
||||
deps: {},
|
||||
factory: () => this.identityApi,
|
||||
@@ -418,18 +430,18 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
|
||||
// It's possible to replace the feature flag API, but since we must have at least
|
||||
// one implementation we add it here directly instead of through the defaultApis.
|
||||
registry.register('default', {
|
||||
this.apiFactoryRegistry.register('default', {
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => new LocalStorageFeatureFlags(),
|
||||
});
|
||||
for (const factory of this.defaultApis) {
|
||||
registry.register('default', factory);
|
||||
this.apiFactoryRegistry.register('default', factory);
|
||||
}
|
||||
|
||||
for (const plugin of this.plugins) {
|
||||
for (const factory of plugin.getApis()) {
|
||||
if (!registry.register('default', factory)) {
|
||||
if (!this.apiFactoryRegistry.register('default', factory)) {
|
||||
throw new Error(
|
||||
`Plugin ${plugin.getId()} tried to register duplicate or forbidden API factory for ${
|
||||
factory.api
|
||||
@@ -440,17 +452,19 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
}
|
||||
|
||||
for (const factory of this.apis) {
|
||||
if (!registry.register('app', factory)) {
|
||||
if (!this.apiFactoryRegistry.register('app', factory)) {
|
||||
throw new Error(
|
||||
`Duplicate or forbidden API factory for ${factory.api} in app`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ApiResolver.validateFactories(registry, registry.getAllApis());
|
||||
|
||||
this.apiHolder = new ApiResolver(registry);
|
||||
ApiResolver.validateFactories(
|
||||
this.apiFactoryRegistry,
|
||||
this.apiFactoryRegistry.getAllApis(),
|
||||
);
|
||||
|
||||
this.apiHolder = new ApiResolver(this.apiFactoryRegistry);
|
||||
return this.apiHolder;
|
||||
}
|
||||
|
||||
|
||||
@@ -1913,7 +1913,7 @@ export interface TableColumn<T extends object = {}> extends Column<T> {
|
||||
// @public (undocumented)
|
||||
export type TableFilter = {
|
||||
column: string;
|
||||
type: 'select' | 'multiple-select' | /** @deprecated */ 'checkbox-tree';
|
||||
type: 'select' | 'multiple-select';
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "TableProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
/*
|
||||
* 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 React, { useState } from 'react';
|
||||
import { CheckboxTree } from './CheckboxTree';
|
||||
|
||||
const CHECKBOX_TREE_ITEMS = [
|
||||
{
|
||||
label: 'Generic subcategory name 1',
|
||||
options: [
|
||||
{
|
||||
label: 'Option 1',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: 'Option 2',
|
||||
value: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Generic subcategory name 2',
|
||||
options: [
|
||||
{
|
||||
label: 'Option 1',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: 'Option 2',
|
||||
value: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Generic subcategory name 3',
|
||||
options: [
|
||||
{
|
||||
label: 'Option 1',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: 'Option 2',
|
||||
value: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default {
|
||||
title: 'Inputs/CheckboxTree',
|
||||
component: CheckboxTree,
|
||||
};
|
||||
|
||||
export const Default = () => (
|
||||
<CheckboxTree
|
||||
onChange={() => {}}
|
||||
label="default"
|
||||
subCategories={CHECKBOX_TREE_ITEMS}
|
||||
/>
|
||||
);
|
||||
|
||||
export const DynamicTree = () => {
|
||||
function generateTree(showMore: boolean = false) {
|
||||
const t = [
|
||||
{
|
||||
label: 'Show more',
|
||||
options: [],
|
||||
},
|
||||
];
|
||||
|
||||
if (showMore) {
|
||||
t.push({
|
||||
label: 'More',
|
||||
options: [],
|
||||
});
|
||||
}
|
||||
|
||||
return t;
|
||||
}
|
||||
|
||||
const [tree, setTree] = useState(generateTree());
|
||||
|
||||
return (
|
||||
<CheckboxTree
|
||||
onChange={state => {
|
||||
setTree(generateTree(state.some(c => c.category === 'Show more')));
|
||||
}}
|
||||
label="default"
|
||||
subCategories={tree}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
/*
|
||||
* 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 { fireEvent, render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { CheckboxTree } from './CheckboxTree';
|
||||
|
||||
const CHECKBOX_TREE_ITEMS = [
|
||||
{
|
||||
label: 'Generic subcategory name 1',
|
||||
options: [
|
||||
{
|
||||
label: 'Option 1',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: 'Option 2',
|
||||
value: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const minProps = {
|
||||
onChange: jest.fn(),
|
||||
label: 'Default',
|
||||
subCategories: CHECKBOX_TREE_ITEMS,
|
||||
};
|
||||
|
||||
describe('<CheckboxTree />', () => {
|
||||
it('renders without exploding', async () => {
|
||||
const { getByText, getByTestId } = render(<CheckboxTree {...minProps} />);
|
||||
|
||||
expect(getByText('Generic subcategory name 1')).toBeInTheDocument();
|
||||
const checkbox = await getByTestId('expandable');
|
||||
|
||||
// Simulate click on expandable arrow
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
// Simulate click on option
|
||||
const option = getByText('Option 1');
|
||||
expect(getByText('Option 1')).toBeInTheDocument();
|
||||
fireEvent.click(option);
|
||||
expect(minProps.onChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,357 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
/* eslint-disable guard-for-in */
|
||||
import {
|
||||
Checkbox,
|
||||
Collapse,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles';
|
||||
import ExpandLess from '@material-ui/icons/ExpandLess';
|
||||
import ExpandMore from '@material-ui/icons/ExpandMore';
|
||||
import produce from 'immer';
|
||||
import { isEqual } from 'lodash';
|
||||
import React, { useEffect, useReducer } from 'react';
|
||||
import { usePrevious } from 'react-use';
|
||||
|
||||
type IndexedObject<T> = {
|
||||
[key: string]: T;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
width: '100%',
|
||||
minWidth: 10,
|
||||
maxWidth: 360,
|
||||
backgroundColor: 'transparent',
|
||||
'&:hover': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'&:active': {
|
||||
animation: 'none',
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
nested: {
|
||||
paddingLeft: theme.spacing(5),
|
||||
height: '32px',
|
||||
'&:hover': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
},
|
||||
listItemIcon: {
|
||||
minWidth: 10,
|
||||
},
|
||||
listItem: {
|
||||
'&:hover': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
},
|
||||
text: {
|
||||
'& span, & svg': {
|
||||
fontWeight: 'normal',
|
||||
fontSize: 14,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/* SUB_CATEGORY */
|
||||
|
||||
type SubCategory = {
|
||||
label: string;
|
||||
isChecked?: boolean;
|
||||
isOpen?: boolean;
|
||||
options?: Option[];
|
||||
};
|
||||
|
||||
type SubCategoryWithIndexedOptions = {
|
||||
label: string;
|
||||
isChecked?: boolean;
|
||||
isOpen?: boolean;
|
||||
options: IndexedObject<Option>;
|
||||
};
|
||||
|
||||
/* OPTION */
|
||||
|
||||
type Option = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
isChecked?: boolean;
|
||||
};
|
||||
|
||||
type Selection = { category?: string; selectedChildren?: string[] }[];
|
||||
|
||||
export type CheckboxTreeProps = {
|
||||
subCategories: SubCategory[];
|
||||
label: string;
|
||||
triggerReset?: boolean;
|
||||
selected?: Selection;
|
||||
onChange: (arg: Selection) => any;
|
||||
};
|
||||
|
||||
/* REDUCER */
|
||||
|
||||
type checkOptionPayload = {
|
||||
subCategoryLabel: string;
|
||||
optionLabel: string;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: 'checkOption'; payload: checkOptionPayload }
|
||||
| { type: 'checkCategory'; payload: string }
|
||||
| { type: 'toggleCategory'; payload: string }
|
||||
| {
|
||||
type: 'updateCategories';
|
||||
payload: IndexedObject<SubCategoryWithIndexedOptions>;
|
||||
}
|
||||
| { type: 'updateSelected'; payload: Selection }
|
||||
| { type: 'triggerReset' };
|
||||
|
||||
const reducer = (
|
||||
state: IndexedObject<SubCategoryWithIndexedOptions>,
|
||||
action: Action,
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case 'checkOption': {
|
||||
return produce(state, newState => {
|
||||
const category = newState[action.payload.subCategoryLabel];
|
||||
const option = category.options[action.payload.optionLabel];
|
||||
option.isChecked = !option.isChecked;
|
||||
category.isChecked = Object.values(category.options).every(
|
||||
o => o.isChecked,
|
||||
);
|
||||
});
|
||||
}
|
||||
case 'checkCategory': {
|
||||
return produce(state, newState => {
|
||||
const category = newState[action.payload];
|
||||
const options = category.options;
|
||||
category.isChecked = !category.isChecked;
|
||||
for (const option in options) {
|
||||
options[option].isChecked = category.isChecked;
|
||||
}
|
||||
});
|
||||
}
|
||||
case 'toggleCategory':
|
||||
return produce(state, newState => {
|
||||
const category = newState[action.payload];
|
||||
category.isOpen = !category.isOpen;
|
||||
});
|
||||
case 'triggerReset': {
|
||||
return produce(state, newState => {
|
||||
for (const category in newState) {
|
||||
newState[category].isChecked = false;
|
||||
for (const option in newState[category].options) {
|
||||
newState[category].options[option].isChecked =
|
||||
newState[category].isChecked;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
case 'updateCategories': {
|
||||
return produce(state, newState => {
|
||||
for (const category in newState) {
|
||||
delete newState[category];
|
||||
}
|
||||
|
||||
for (const category in action.payload) {
|
||||
newState[category] = action.payload[category];
|
||||
|
||||
if (state[category]) {
|
||||
newState[category].isChecked = state[category].isChecked;
|
||||
newState[category].isOpen = state[category].isOpen;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
case 'updateSelected': {
|
||||
return produce(state, newState => {
|
||||
for (const category in newState) {
|
||||
const selection = action.payload.find(s => s.category === category);
|
||||
|
||||
if (selection) {
|
||||
newState[category].isChecked = true;
|
||||
|
||||
for (const option in newState[category].options) {
|
||||
newState[category].options[option].isChecked =
|
||||
selection.selectedChildren?.includes(option) || false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const indexer = (
|
||||
arr: SubCategory[],
|
||||
): IndexedObject<SubCategoryWithIndexedOptions> =>
|
||||
arr.reduce((accumulator, el) => {
|
||||
if (el.options) {
|
||||
return {
|
||||
...accumulator,
|
||||
[el.label]: {
|
||||
label: el.label,
|
||||
isChecked: el.isChecked || false,
|
||||
isOpen: false,
|
||||
options: indexer(el.options),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...accumulator,
|
||||
[el.label]: { ...el, isChecked: el.isChecked || false },
|
||||
};
|
||||
}, {});
|
||||
|
||||
/**
|
||||
*
|
||||
* @deprecated CheckboxTree is no longer used in Table filters
|
||||
*/
|
||||
export function CheckboxTree(props: CheckboxTreeProps) {
|
||||
const { subCategories, label, selected, onChange, triggerReset } = props;
|
||||
const classes = useStyles();
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, indexer(subCategories));
|
||||
|
||||
const handleOpen = (event: any, value: any) => {
|
||||
event.stopPropagation();
|
||||
dispatch({ type: 'toggleCategory', payload: value });
|
||||
};
|
||||
|
||||
const previousSubCategories = usePrevious(subCategories);
|
||||
|
||||
useEffect(() => {
|
||||
const values = Object.values(state).map(category => ({
|
||||
category: category.isChecked ? category.label : undefined,
|
||||
selectedChildren: Object.values(category.options)
|
||||
.filter(option => option.isChecked)
|
||||
.map(option => option.label),
|
||||
}));
|
||||
onChange(values);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'triggerReset' });
|
||||
}, [triggerReset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selected) {
|
||||
dispatch({ type: 'updateSelected', payload: selected });
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEqual(subCategories, previousSubCategories)) {
|
||||
dispatch({
|
||||
type: 'updateCategories',
|
||||
payload: indexer(subCategories),
|
||||
});
|
||||
}
|
||||
}, [subCategories, previousSubCategories]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography variant="button">{label}</Typography>
|
||||
<List className={classes.root}>
|
||||
{Object.values(state).map(item => (
|
||||
<div key={item.label}>
|
||||
<ListItem
|
||||
className={classes.listItem}
|
||||
dense
|
||||
button
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: 'checkCategory',
|
||||
payload: item.label,
|
||||
})
|
||||
}
|
||||
>
|
||||
<ListItemIcon className={classes.listItemIcon}>
|
||||
<Checkbox
|
||||
color="primary"
|
||||
edge="start"
|
||||
checked={item.isChecked}
|
||||
tabIndex={-1}
|
||||
disableRipple
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText className={classes.text} primary={item.label} />
|
||||
{Object.values(item.options).length ? (
|
||||
<>
|
||||
{item.isOpen ? (
|
||||
<ExpandLess
|
||||
data-testid="expandable"
|
||||
onClick={event => handleOpen(event, item.label)}
|
||||
/>
|
||||
) : (
|
||||
<ExpandMore
|
||||
data-testid="expandable"
|
||||
onClick={event => handleOpen(event, item.label)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</ListItem>
|
||||
<Collapse in={item.isOpen} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{Object.values(item.options).map(option => (
|
||||
<ListItem
|
||||
button
|
||||
key={option.label}
|
||||
className={classes.nested}
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: 'checkOption',
|
||||
payload: {
|
||||
subCategoryLabel: item.label,
|
||||
optionLabel: option.label,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<ListItemIcon className={classes.listItemIcon}>
|
||||
<Checkbox
|
||||
color="primary"
|
||||
edge="start"
|
||||
checked={option.isChecked}
|
||||
tabIndex={-1}
|
||||
disableRipple
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
className={classes.text}
|
||||
primary={option.label}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
</div>
|
||||
))}
|
||||
</List>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,8 +18,6 @@ import React, { useEffect, useState } from 'react';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { Button, makeStyles } from '@material-ui/core';
|
||||
import { Select } from '../Select';
|
||||
import { CheckboxTree } from '../CheckboxTree';
|
||||
import { CheckboxTreeProps } from '../CheckboxTree/CheckboxTree';
|
||||
import { SelectProps } from '../Select/Select';
|
||||
|
||||
const useSubvalueCellStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
@@ -53,10 +51,8 @@ const useSubvalueCellStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
export type Without<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||
|
||||
export type Filter = {
|
||||
type: 'select' | /** @deprecated */ 'checkbox-tree' | 'multiple-select';
|
||||
element:
|
||||
| Without<CheckboxTreeProps, 'onChange'>
|
||||
| Without<SelectProps, 'onChange'>;
|
||||
type: 'select' | 'multiple-select';
|
||||
element: Without<SelectProps, 'onChange'>;
|
||||
};
|
||||
|
||||
export type SelectedFilters = {
|
||||
@@ -100,57 +96,20 @@ export const Filters = (props: Props) => {
|
||||
</div>
|
||||
<div className={classes.filters}>
|
||||
{props.filters?.length &&
|
||||
props.filters.map(filter =>
|
||||
filter.type === 'checkbox-tree' ? (
|
||||
<CheckboxTree
|
||||
triggerReset={reset}
|
||||
key={filter.element.label}
|
||||
{...(filter.element as CheckboxTreeProps)}
|
||||
selected={
|
||||
selectedFilters[filter.element.label]
|
||||
? (selectedFilters[filter.element.label] as string[]).map(
|
||||
s => ({
|
||||
category: s,
|
||||
}),
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
onChange={el =>
|
||||
setSelectedFilters({
|
||||
...selectedFilters,
|
||||
[filter.element.label]: el
|
||||
.filter(
|
||||
(checkboxFilter: any) =>
|
||||
checkboxFilter.category ||
|
||||
checkboxFilter.selectedChildren.length,
|
||||
)
|
||||
.map((checkboxFilter: any) =>
|
||||
checkboxFilter.category
|
||||
? [
|
||||
...checkboxFilter.selectedChildren,
|
||||
checkboxFilter.category,
|
||||
]
|
||||
: checkboxFilter.selectedChildren,
|
||||
)
|
||||
.flat(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
triggerReset={reset}
|
||||
key={filter.element.label}
|
||||
{...(filter.element as SelectProps)}
|
||||
selected={selectedFilters[filter.element.label]}
|
||||
onChange={el =>
|
||||
setSelectedFilters({
|
||||
...selectedFilters,
|
||||
[filter.element.label]: el as any,
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
props.filters.map(filter => (
|
||||
<Select
|
||||
triggerReset={reset}
|
||||
key={filter.element.label}
|
||||
{...(filter.element as SelectProps)}
|
||||
selected={selectedFilters[filter.element.label]}
|
||||
onChange={el =>
|
||||
setSelectedFilters({
|
||||
...selectedFilters,
|
||||
[filter.element.label]: el as any,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -313,10 +313,6 @@ export const FilterTable = () => {
|
||||
column: 'Column 2',
|
||||
type: 'multiple-select',
|
||||
},
|
||||
{
|
||||
column: 'Numeric value',
|
||||
type: 'checkbox-tree',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -56,7 +56,6 @@ import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { CheckboxTreeProps } from '../CheckboxTree/CheckboxTree';
|
||||
import { SelectProps } from '../Select/Select';
|
||||
import { Filter, Filters, SelectedFilters, Without } from './Filters';
|
||||
|
||||
@@ -191,7 +190,7 @@ export interface TableColumn<T extends object = {}> extends Column<T> {
|
||||
|
||||
export type TableFilter = {
|
||||
column: string;
|
||||
type: 'select' | 'multiple-select' | /** @deprecated */ 'checkbox-tree';
|
||||
type: 'select' | 'multiple-select';
|
||||
};
|
||||
|
||||
export type TableState = {
|
||||
@@ -365,14 +364,6 @@ export function Table<T extends object = {}>(props: TableProps<T>) {
|
||||
setSelectedFiltersLength(selectedFiltersArray.flat().length);
|
||||
}, [data, selectedFilters, getFieldByTitle]);
|
||||
|
||||
// Check for deprecated checkbox-tree filter
|
||||
useEffect(() => {
|
||||
if (filters?.some(filter => filter.type === 'checkbox-tree')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('"checkbox-tree" filter type is deprecated');
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
const constructFilters = (
|
||||
filterConfig: TableFilter[],
|
||||
dataValue: any[] | undefined,
|
||||
@@ -403,16 +394,6 @@ export function Table<T extends object = {}>(props: TableProps<T>) {
|
||||
return distinctValues;
|
||||
};
|
||||
|
||||
const constructCheckboxTree = (
|
||||
filter: TableFilter,
|
||||
): Without<CheckboxTreeProps, 'onChange'> => ({
|
||||
label: filter.column,
|
||||
subCategories: [...extractDistinctValues(filter.column)].map(v => ({
|
||||
label: v,
|
||||
options: [],
|
||||
})),
|
||||
});
|
||||
|
||||
const constructSelect = (
|
||||
filter: TableFilter,
|
||||
): Without<SelectProps, 'onChange'> => {
|
||||
@@ -429,10 +410,7 @@ export function Table<T extends object = {}>(props: TableProps<T>) {
|
||||
|
||||
return filterConfig.map(filter => ({
|
||||
type: filter.type,
|
||||
element:
|
||||
filter.type === 'checkbox-tree'
|
||||
? constructCheckboxTree(filter)
|
||||
: constructSelect(filter),
|
||||
element: constructSelect(filter),
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -39,9 +39,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.7.13",
|
||||
"@backstage/test-utils": "^0.1.16",
|
||||
"@types/lodash": "^4.14.151",
|
||||
"msw": "^0.29.0"
|
||||
"@types/lodash": "^4.14.151"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @param {import('knex').Knex} knex
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
await knex.schema.alterTable('refresh_state', table => {
|
||||
table
|
||||
.text('unprocessed_hash')
|
||||
.nullable()
|
||||
.comment('A hash of the unprocessed contents, used to detect changes');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import('knex').Knex} knex
|
||||
*/
|
||||
exports.down = async function down(knex) {
|
||||
await knex.schema.alterTable('refresh_state', table => {
|
||||
table.dropColumn('unprocessed_hash');
|
||||
});
|
||||
};
|
||||
@@ -36,7 +36,6 @@
|
||||
"@backstage/config": "^0.1.10",
|
||||
"@backstage/errors": "^0.1.2",
|
||||
"@backstage/integration": "^0.6.5",
|
||||
"@backstage/plugin-search-backend-node": "^0.4.2",
|
||||
"@backstage/search-common": "^0.2.0",
|
||||
"@octokit/graphql": "^4.5.8",
|
||||
"@types/express": "^4.17.6",
|
||||
@@ -53,10 +52,8 @@
|
||||
"knex": "^0.95.1",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^2.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"p-limit": "^3.0.2",
|
||||
"prom-client": "^13.2.0",
|
||||
"qs": "^6.9.4",
|
||||
"uuid": "^8.0.0",
|
||||
"winston": "^3.2.1",
|
||||
"yaml": "^1.9.2",
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from './tables';
|
||||
import { createRandomRefreshInterval } from '../refresh';
|
||||
import { timestampToDateTime } from './conversion';
|
||||
import { generateStableHash } from './util';
|
||||
|
||||
describe('Default Processing Database', () => {
|
||||
const defaultLogger = getVoidLogger();
|
||||
@@ -309,6 +310,7 @@ describe('Default Processing Database', () => {
|
||||
entity_id: id,
|
||||
entity_ref: 'location:default/fakelocation',
|
||||
unprocessed_entity: '{}',
|
||||
unprocessed_hash: generateStableHash({} as any),
|
||||
processed_entity: '{}',
|
||||
errors: '[]',
|
||||
next_update_at: '2021-04-01 13:37:00',
|
||||
@@ -996,6 +998,100 @@ describe('Default Processing Database', () => {
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should support replacing modified entities during a full update, %p',
|
||||
async databaseId => {
|
||||
const { knex, db } = await createDatabase(databaseId);
|
||||
|
||||
await db.transaction(async tx => {
|
||||
await db.replaceUnprocessedEntities(tx, {
|
||||
type: 'full',
|
||||
sourceKey: 'lols',
|
||||
items: [
|
||||
{
|
||||
entity: {
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'a' },
|
||||
spec: { marker: 'WILL_CHANGE' },
|
||||
} as Entity,
|
||||
locationKey: 'file:///tmp/a',
|
||||
},
|
||||
{
|
||||
entity: {
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'b' },
|
||||
spec: { marker: 'NEVER_CHANGES' },
|
||||
} as Entity,
|
||||
locationKey: 'file:///tmp/b',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
let state = await knex<DbRefreshStateRow>('refresh_state').select();
|
||||
expect(state).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
entity_ref: 'component:default/a',
|
||||
location_key: 'file:///tmp/a',
|
||||
unprocessed_entity: expect.stringContaining('WILL_CHANGE'),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
entity_ref: 'component:default/b',
|
||||
location_key: 'file:///tmp/b',
|
||||
unprocessed_entity: expect.stringContaining('NEVER_CHANGES'),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
await db.transaction(async tx => {
|
||||
await db.replaceUnprocessedEntities(tx, {
|
||||
type: 'full',
|
||||
sourceKey: 'lols',
|
||||
items: [
|
||||
{
|
||||
entity: {
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'a' },
|
||||
spec: { marker: 'HAS_CHANGED' },
|
||||
} as Entity,
|
||||
locationKey: 'file:///tmp/a',
|
||||
},
|
||||
{
|
||||
entity: {
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'b' },
|
||||
spec: { marker: 'NEVER_CHANGES' },
|
||||
} as Entity,
|
||||
locationKey: 'file:///tmp/b',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
state = await knex<DbRefreshStateRow>('refresh_state').select();
|
||||
expect(state).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
entity_ref: 'component:default/a',
|
||||
location_key: 'file:///tmp/a',
|
||||
unprocessed_entity: expect.stringContaining('HAS_CHANGED'),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
entity_ref: 'component:default/b',
|
||||
location_key: 'file:///tmp/b',
|
||||
unprocessed_entity: expect.stringContaining('NEVER_CHANGES'),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
|
||||
describe('getProcessableEntities', () => {
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
ListAncestorsResult,
|
||||
UpdateEntityCacheOptions,
|
||||
} from './types';
|
||||
import { generateStableHash } from './util';
|
||||
|
||||
// The number of items that are sent per batch to the database layer, when
|
||||
// doing .batchInsert calls to knex. This needs to be low enough to not cause
|
||||
@@ -156,7 +157,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
|
||||
): Promise<void> {
|
||||
const tx = txOpaque as Knex.Transaction;
|
||||
|
||||
const { toAdd, toRemove } = await this.createDelta(tx, options);
|
||||
const { toUpsert, toRemove } = await this.createDelta(tx, options);
|
||||
|
||||
if (toRemove.length) {
|
||||
// TODO(freben): Batch split, to not hit variable limits?
|
||||
@@ -273,14 +274,27 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
if (toAdd.length) {
|
||||
for (const { entity, locationKey } of toAdd) {
|
||||
if (toUpsert.length) {
|
||||
for (const {
|
||||
deferred: { entity, locationKey },
|
||||
hash,
|
||||
} of toUpsert) {
|
||||
const entityRef = stringifyEntityRef(entity);
|
||||
|
||||
try {
|
||||
let ok = await this.insertUnprocessedEntity(tx, entity, locationKey);
|
||||
let ok = await this.updateUnprocessedEntity(
|
||||
tx,
|
||||
entity,
|
||||
hash,
|
||||
locationKey,
|
||||
);
|
||||
if (!ok) {
|
||||
ok = await this.updateUnprocessedEntity(tx, entity, locationKey);
|
||||
ok = await this.insertUnprocessedEntity(
|
||||
tx,
|
||||
entity,
|
||||
hash,
|
||||
locationKey,
|
||||
);
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
@@ -448,6 +462,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
|
||||
private async updateUnprocessedEntity(
|
||||
tx: Knex.Transaction,
|
||||
entity: Entity,
|
||||
hash: string,
|
||||
locationKey?: string,
|
||||
): Promise<boolean> {
|
||||
const entityRef = stringifyEntityRef(entity);
|
||||
@@ -456,6 +471,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
|
||||
const refreshResult = await tx<DbRefreshStateRow>('refresh_state')
|
||||
.update({
|
||||
unprocessed_entity: serializedEntity,
|
||||
unprocessed_hash: hash,
|
||||
location_key: locationKey,
|
||||
last_discovery_at: tx.fn.now(),
|
||||
// We only get to this point if a processed entity actually had any changes, or
|
||||
@@ -483,6 +499,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
|
||||
private async insertUnprocessedEntity(
|
||||
tx: Knex.Transaction,
|
||||
entity: Entity,
|
||||
hash: string,
|
||||
locationKey?: string,
|
||||
): Promise<boolean> {
|
||||
const entityRef = stringifyEntityRef(entity);
|
||||
@@ -493,6 +510,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
|
||||
entity_id: uuid(),
|
||||
entity_ref: entityRef,
|
||||
unprocessed_entity: serializedEntity,
|
||||
unprocessed_hash: hash,
|
||||
errors: '',
|
||||
location_key: locationKey,
|
||||
next_update_at: tx.fn.now(),
|
||||
@@ -558,10 +576,16 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
|
||||
private async createDelta(
|
||||
tx: Knex.Transaction,
|
||||
options: ReplaceUnprocessedEntitiesOptions,
|
||||
): Promise<{ toAdd: DeferredEntity[]; toRemove: string[] }> {
|
||||
): Promise<{
|
||||
toUpsert: { deferred: DeferredEntity; hash: string }[];
|
||||
toRemove: string[];
|
||||
}> {
|
||||
if (options.type === 'delta') {
|
||||
return {
|
||||
toAdd: options.added,
|
||||
toUpsert: options.added.map(e => ({
|
||||
deferred: e,
|
||||
hash: generateStableHash(e.entity),
|
||||
})),
|
||||
toRemove: options.removed.map(e => stringifyEntityRef(e.entity)),
|
||||
};
|
||||
}
|
||||
@@ -570,39 +594,55 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
|
||||
const oldRefs = await tx<DbRefreshStateReferencesRow>(
|
||||
'refresh_state_references',
|
||||
)
|
||||
.where({ source_key: options.sourceKey })
|
||||
.leftJoin<DbRefreshStateRow>('refresh_state', {
|
||||
target_entity_ref: 'entity_ref',
|
||||
})
|
||||
.select(['target_entity_ref', 'location_key']);
|
||||
.where({ source_key: options.sourceKey })
|
||||
.select({
|
||||
target_entity_ref: 'refresh_state_references.target_entity_ref',
|
||||
location_key: 'refresh_state.location_key',
|
||||
unprocessed_hash: 'refresh_state.unprocessed_hash',
|
||||
});
|
||||
|
||||
const items = options.items.map(deferred => ({
|
||||
deferred,
|
||||
ref: stringifyEntityRef(deferred.entity),
|
||||
hash: generateStableHash(deferred.entity),
|
||||
}));
|
||||
|
||||
const oldRefsSet = new Map(
|
||||
oldRefs.map(r => [r.target_entity_ref, r.location_key]),
|
||||
oldRefs.map(r => [
|
||||
r.target_entity_ref,
|
||||
{
|
||||
locationKey: r.location_key,
|
||||
oldEntityHash: r.unprocessed_hash,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const newRefsSet = new Set(items.map(item => item.ref));
|
||||
|
||||
const toAdd = new Array<DeferredEntity>();
|
||||
const toUpsert = new Array<{ deferred: DeferredEntity; hash: string }>();
|
||||
const toRemove = oldRefs
|
||||
.map(row => row.target_entity_ref)
|
||||
.filter(ref => !newRefsSet.has(ref));
|
||||
|
||||
for (const item of items) {
|
||||
if (!oldRefsSet.has(item.ref)) {
|
||||
const oldRef = oldRefsSet.get(item.ref);
|
||||
const upsertItem = { deferred: item.deferred, hash: item.hash };
|
||||
if (!oldRef) {
|
||||
// Add any entity that does not exist in the database
|
||||
toAdd.push(item.deferred);
|
||||
} else if (oldRefsSet.get(item.ref) !== item.deferred.locationKey) {
|
||||
toUpsert.push(upsertItem);
|
||||
} else if (oldRef.locationKey !== item.deferred.locationKey) {
|
||||
// Remove and then re-add any entity that exists, but with a different location key
|
||||
toRemove.push(item.ref);
|
||||
toAdd.push(item.deferred);
|
||||
toUpsert.push(upsertItem);
|
||||
} else if (oldRef.oldEntityHash !== item.hash) {
|
||||
// Entities with modifications should be pushed through too
|
||||
toUpsert.push(upsertItem);
|
||||
}
|
||||
}
|
||||
|
||||
return { toAdd, toRemove };
|
||||
return { toUpsert, toRemove };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -626,10 +666,12 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
|
||||
// their entity ref.
|
||||
for (const { entity, locationKey } of options.entities) {
|
||||
const entityRef = stringifyEntityRef(entity);
|
||||
const hash = generateStableHash(entity);
|
||||
|
||||
const updated = await this.updateUnprocessedEntity(
|
||||
tx,
|
||||
entity,
|
||||
hash,
|
||||
locationKey,
|
||||
);
|
||||
if (updated) {
|
||||
@@ -640,6 +682,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
|
||||
const inserted = await this.insertUnprocessedEntity(
|
||||
tx,
|
||||
entity,
|
||||
hash,
|
||||
locationKey,
|
||||
);
|
||||
if (inserted) {
|
||||
|
||||
@@ -24,6 +24,7 @@ export type DbRefreshStateRow = {
|
||||
entity_id: string;
|
||||
entity_ref: string;
|
||||
unprocessed_entity: string;
|
||||
unprocessed_hash?: string;
|
||||
processed_entity?: string;
|
||||
result_hash?: string;
|
||||
cache?: string;
|
||||
|
||||
+10
-2
@@ -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.
|
||||
@@ -14,4 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { CheckboxTree } from './CheckboxTree';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { createHash } from 'crypto';
|
||||
import stableStringify from 'fast-json-stable-stringify';
|
||||
|
||||
export function generateStableHash(entity: Entity) {
|
||||
return createHash('sha1')
|
||||
.update(stableStringify({ ...entity }))
|
||||
.digest('hex');
|
||||
}
|
||||
@@ -49,10 +49,7 @@
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@testing-library/user-event": "^13.1.8",
|
||||
"@testing-library/react-hooks": "^3.4.2",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^14.14.32",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"msw": "^0.29.0"
|
||||
"@types/jest": "^26.0.7"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.9.0",
|
||||
"@backstage/catalog-model": "^0.9.0",
|
||||
"@backstage/config": "^0.1.8",
|
||||
"@graphql-modules/core": "^0.7.17",
|
||||
@@ -48,12 +47,8 @@
|
||||
"@graphql-codegen/cli": "^1.21.3",
|
||||
"@graphql-codegen/typescript": "^1.17.7",
|
||||
"@graphql-codegen/typescript-resolvers": "^1.17.7",
|
||||
"@types/express": "^4.17.7",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"eslint-plugin-graphql": "^4.0.0",
|
||||
"msw": "^0.29.0",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-node": "^10.0.0"
|
||||
"msw": "^0.29.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
"@backstage/integration": "^0.6.5",
|
||||
"@backstage/integration-react": "^0.1.10",
|
||||
"@backstage/plugin-catalog-react": "^0.5.0",
|
||||
"@backstage/theme": "^0.2.10",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
@@ -50,7 +49,6 @@
|
||||
"react-dom": "^16.13.1",
|
||||
"react-hook-form": "^7.12.2",
|
||||
"react-router": "6.0.0-beta.0",
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
"react-use": "^17.2.4",
|
||||
"yaml": "^1.10.0",
|
||||
"lodash": "^4.17.21"
|
||||
@@ -65,7 +63,6 @@
|
||||
"@testing-library/react-hooks": "^3.3.0",
|
||||
"@testing-library/user-event": "^13.1.8",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^14.14.32",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"msw": "^0.29.0"
|
||||
},
|
||||
|
||||
@@ -45,12 +45,10 @@
|
||||
"qs": "^6.9.4",
|
||||
"react": "^16.13.1",
|
||||
"react-router": "6.0.0-beta.0",
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
"react-use": "^17.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.7.13",
|
||||
"@backstage/dev-utils": "^0.2.10",
|
||||
"@backstage/test-utils": "^0.1.17",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^11.2.5",
|
||||
@@ -58,9 +56,7 @@
|
||||
"@testing-library/user-event": "^13.1.8",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/jwt-decode": "^3.1.0",
|
||||
"@types/node": "^14.14.32",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"msw": "^0.29.0",
|
||||
"react-test-renderer": "^16.13.1"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
"@backstage/catalog-model": "^0.9.3",
|
||||
"@backstage/core-components": "^0.5.0",
|
||||
"@backstage/core-plugin-api": "^0.1.8",
|
||||
"@backstage/errors": "^0.1.1",
|
||||
"@backstage/integration": "^0.6.5",
|
||||
"@backstage/integration-react": "^0.1.10",
|
||||
"@backstage/plugin-catalog-react": "^0.5.0",
|
||||
"@backstage/theme": "^0.2.10",
|
||||
@@ -44,14 +42,11 @@
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"@types/react": "*",
|
||||
"classnames": "^2.2.6",
|
||||
"git-url-parse": "^11.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-router": "6.0.0-beta.0",
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
"react-use": "^17.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -61,13 +56,9 @@
|
||||
"@backstage/test-utils": "^0.1.17",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@testing-library/react-hooks": "^3.3.0",
|
||||
"@testing-library/user-event": "^13.1.8",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^14.14.32",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"msw": "^0.29.0",
|
||||
"react-test-renderer": "^16.13.1"
|
||||
"cross-fetch": "^3.0.6"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -15,21 +15,88 @@
|
||||
*/
|
||||
import { rancherFormatter } from './rancher';
|
||||
|
||||
describe('clusterLinks - Rancher formatter', () => {
|
||||
it('should return an url on the workloads when there is a namespace only', () => {
|
||||
expect(() =>
|
||||
rancherFormatter({
|
||||
dashboardUrl: new URL('https://k8s.foo.com'),
|
||||
object: {
|
||||
metadata: {
|
||||
name: 'foobar',
|
||||
namespace: 'bar',
|
||||
},
|
||||
describe('clusterLinks - rancher formatter', () => {
|
||||
it('should return a url on the workloads when there is a namespace only', () => {
|
||||
const url = rancherFormatter({
|
||||
dashboardUrl: new URL('https://k8s.foo.com'),
|
||||
object: {
|
||||
metadata: {
|
||||
namespace: 'bar',
|
||||
},
|
||||
kind: 'Deployment',
|
||||
}),
|
||||
).toThrowError(
|
||||
'Rancher formatter is not yet implemented. Please, contribute!',
|
||||
},
|
||||
kind: 'foo',
|
||||
});
|
||||
expect(url.href).toBe('https://k8s.foo.com/explorer/workload');
|
||||
});
|
||||
it('should return a url on the workloads when the kind is not recognized', () => {
|
||||
const url = rancherFormatter({
|
||||
dashboardUrl: new URL('https://k8s.foo.com'),
|
||||
object: {
|
||||
metadata: {
|
||||
name: 'foobar',
|
||||
namespace: 'bar',
|
||||
},
|
||||
},
|
||||
kind: 'UnknownKind',
|
||||
});
|
||||
expect(url.href).toBe('https://k8s.foo.com/explorer/workload');
|
||||
});
|
||||
it('should return a url on the deployment', () => {
|
||||
const url = rancherFormatter({
|
||||
dashboardUrl: new URL('https://k8s.foo.com/'),
|
||||
object: {
|
||||
metadata: {
|
||||
name: 'foobar',
|
||||
namespace: 'bar',
|
||||
},
|
||||
},
|
||||
kind: 'Deployment',
|
||||
});
|
||||
expect(url.href).toBe(
|
||||
'https://k8s.foo.com/explorer/apps.deployment/bar/foobar',
|
||||
);
|
||||
});
|
||||
it('should return a url on the service', () => {
|
||||
const url = rancherFormatter({
|
||||
dashboardUrl: new URL('https://k8s.foo.com/'),
|
||||
object: {
|
||||
metadata: {
|
||||
name: 'foobar',
|
||||
namespace: 'bar',
|
||||
},
|
||||
},
|
||||
kind: 'Service',
|
||||
});
|
||||
expect(url.href).toBe('https://k8s.foo.com/explorer/service/bar/foobar');
|
||||
});
|
||||
it('should return a url on the ingress', () => {
|
||||
const url = rancherFormatter({
|
||||
dashboardUrl: new URL('https://k8s.foo.com/'),
|
||||
object: {
|
||||
metadata: {
|
||||
name: 'foobar',
|
||||
namespace: 'bar',
|
||||
},
|
||||
},
|
||||
kind: 'Ingress',
|
||||
});
|
||||
expect(url.href).toBe(
|
||||
'https://k8s.foo.com/explorer/networking.k8s.io.ingress/bar/foobar',
|
||||
);
|
||||
});
|
||||
it('should return a url on the deployment for a hpa', () => {
|
||||
const url = rancherFormatter({
|
||||
dashboardUrl: new URL('https://k8s.foo.com/'),
|
||||
object: {
|
||||
metadata: {
|
||||
name: 'foobar',
|
||||
namespace: 'bar',
|
||||
},
|
||||
},
|
||||
kind: 'HorizontalPodAutoscaler',
|
||||
});
|
||||
expect(url.href).toBe(
|
||||
'https://k8s.foo.com/explorer/autoscaling.horizontalpodautoscaler/bar/foobar',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,8 +15,22 @@
|
||||
*/
|
||||
import { ClusterLinksFormatterOptions } from '../../../types/types';
|
||||
|
||||
export function rancherFormatter(_options: ClusterLinksFormatterOptions): URL {
|
||||
throw new Error(
|
||||
'Rancher formatter is not yet implemented. Please, contribute!',
|
||||
);
|
||||
const kindMappings: Record<string, string> = {
|
||||
deployment: 'apps.deployment',
|
||||
ingress: 'networking.k8s.io.ingress',
|
||||
service: 'service',
|
||||
horizontalpodautoscaler: 'autoscaling.horizontalpodautoscaler',
|
||||
};
|
||||
|
||||
export function rancherFormatter(options: ClusterLinksFormatterOptions): URL {
|
||||
const result = new URL(options.dashboardUrl.href);
|
||||
const name = options.object.metadata?.name;
|
||||
const namespace = options.object.metadata?.namespace;
|
||||
const validKind = kindMappings[options.kind.toLocaleLowerCase('en-US')];
|
||||
if (validKind && name && namespace) {
|
||||
result.pathname = `explorer/${validKind}/${namespace}/${name}`;
|
||||
} else if (namespace) {
|
||||
result.pathname = 'explorer/workload';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,8 @@ export interface TechRadarComponentProps {
|
||||
// (undocumented)
|
||||
id?: string;
|
||||
// (undocumented)
|
||||
searchText?: string;
|
||||
// (undocumented)
|
||||
svgProps?: object;
|
||||
// (undocumented)
|
||||
width: number;
|
||||
|
||||
@@ -81,4 +81,5 @@ export interface TechRadarComponentProps {
|
||||
width: number;
|
||||
height: number;
|
||||
svgProps?: object;
|
||||
searchText?: string;
|
||||
}
|
||||
|
||||
@@ -14,19 +14,19 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Progress } from '@backstage/core-components';
|
||||
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
import Radar from '../components/Radar';
|
||||
import {
|
||||
RadarEntry,
|
||||
techRadarApiRef,
|
||||
TechRadarComponentProps,
|
||||
TechRadarLoaderResponse,
|
||||
} from '../api';
|
||||
import Radar from '../components/Radar';
|
||||
import { Entry } from '../utils/types';
|
||||
|
||||
import { Progress } from '@backstage/core-components';
|
||||
import { useApi, errorApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
const useTechRadarLoader = (id: string | undefined) => {
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const techRadarApi = useApi(techRadarApiRef);
|
||||
@@ -45,24 +45,44 @@ const useTechRadarLoader = (id: string | undefined) => {
|
||||
return { loading, value, error };
|
||||
};
|
||||
|
||||
function matchFilter(filter?: string): (entry: RadarEntry) => boolean {
|
||||
const terms = filter
|
||||
?.toLocaleLowerCase('en-US')
|
||||
.split(/\s/)
|
||||
.map(e => e.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!terms?.length) {
|
||||
return () => true;
|
||||
}
|
||||
|
||||
return entry => {
|
||||
const text = `${entry.title} ${
|
||||
entry.timeline[0]?.description || ''
|
||||
}`.toLocaleLowerCase('en-US');
|
||||
return terms.every(term => text.includes(term));
|
||||
};
|
||||
}
|
||||
|
||||
const RadarComponent = (props: TechRadarComponentProps): JSX.Element => {
|
||||
const { loading, error, value: data } = useTechRadarLoader(props.id);
|
||||
|
||||
const mapToEntries = (
|
||||
loaderResponse: TechRadarLoaderResponse | undefined,
|
||||
loaderResponse: TechRadarLoaderResponse,
|
||||
): Array<Entry> => {
|
||||
return loaderResponse!.entries.map(entry => {
|
||||
return {
|
||||
return loaderResponse.entries
|
||||
.filter(matchFilter(props.searchText))
|
||||
.map(entry => ({
|
||||
id: entry.key,
|
||||
quadrant: loaderResponse!.quadrants.find(q => q.id === entry.quadrant)!,
|
||||
quadrant: loaderResponse.quadrants.find(q => q.id === entry.quadrant)!,
|
||||
title: entry.title,
|
||||
ring: loaderResponse!.rings.find(
|
||||
ring: loaderResponse.rings.find(
|
||||
r => r.id === entry.timeline[0].ringId,
|
||||
)!,
|
||||
timeline: entry.timeline.map(e => {
|
||||
return {
|
||||
date: e.date,
|
||||
ring: loaderResponse!.rings.find(a => a.id === e.ringId)!,
|
||||
ring: loaderResponse.rings.find(a => a.id === e.ringId)!,
|
||||
description: e.description,
|
||||
moved: e.moved,
|
||||
};
|
||||
@@ -70,18 +90,17 @@ const RadarComponent = (props: TechRadarComponentProps): JSX.Element => {
|
||||
moved: entry.timeline[0].moved,
|
||||
description: entry.description || entry.timeline[0].description,
|
||||
url: entry.url,
|
||||
};
|
||||
});
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && <Progress />}
|
||||
{!loading && !error && (
|
||||
{!loading && !error && data && (
|
||||
<Radar
|
||||
{...props}
|
||||
rings={data!.rings}
|
||||
quadrants={data!.quadrants}
|
||||
rings={data.rings}
|
||||
quadrants={data.quadrants}
|
||||
entries={mapToEntries(data)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { makeStyles, Theme } from '@material-ui/core';
|
||||
import type { Quadrant, Ring, Entry } from '../../utils/types';
|
||||
import React from 'react';
|
||||
import { WithLink } from '../../utils/components';
|
||||
import type { Entry, Quadrant, Ring } from '../../utils/types';
|
||||
import { RadarDescription } from '../RadarDescription';
|
||||
|
||||
type Segments = {
|
||||
@@ -32,17 +32,21 @@ export type Props = {
|
||||
};
|
||||
|
||||
const useStyles = makeStyles<Theme>(theme => ({
|
||||
quadrantLegend: {
|
||||
overflowY: 'auto',
|
||||
scrollbarWidth: 'thin',
|
||||
},
|
||||
quadrant: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
scrollbarWidth: 'thin',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
quadrantHeading: {
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
marginTop: 0,
|
||||
marginBottom: theme.spacing(8 / (18 * 0.375)),
|
||||
marginBottom: theme.spacing(2),
|
||||
fontSize: '18px',
|
||||
},
|
||||
rings: {
|
||||
@@ -53,12 +57,16 @@ const useStyles = makeStyles<Theme>(theme => ({
|
||||
pageBreakInside: 'avoid',
|
||||
'-webkit-column-break-inside': 'avoid',
|
||||
fontSize: '12px',
|
||||
marginBottom: theme.spacing(2),
|
||||
},
|
||||
ringEmpty: {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
ringHeading: {
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
marginTop: 0,
|
||||
marginBottom: theme.spacing(8 / (12 * 0.375)),
|
||||
marginBottom: theme.spacing(1),
|
||||
fontSize: '12px',
|
||||
fontWeight: 800,
|
||||
},
|
||||
@@ -172,7 +180,7 @@ const RadarLegend = (props: Props): JSX.Element => {
|
||||
<div data-testid="radar-ring" key={ring.id} className={classes.ring}>
|
||||
<h3 className={classes.ringHeading}>{ring.name}</h3>
|
||||
{entries.length === 0 ? (
|
||||
<p>(empty)</p>
|
||||
<p className={classes.ringEmpty}>(empty)</p>
|
||||
) : (
|
||||
<ol className={classes.ringList}>
|
||||
{entries.map(entry => (
|
||||
@@ -221,6 +229,7 @@ const RadarLegend = (props: Props): JSX.Element => {
|
||||
y={quadrant.legendY}
|
||||
width={quadrant.legendWidth}
|
||||
height={quadrant.legendHeight}
|
||||
className={classes.quadrantLegend}
|
||||
data-testid="radar-quadrant"
|
||||
>
|
||||
<div className={classes.quadrant}>
|
||||
|
||||
@@ -14,17 +14,17 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Grid, makeStyles } from '@material-ui/core';
|
||||
import RadarComponent from '../components/RadarComponent';
|
||||
import { TechRadarComponentProps } from '../api';
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
Page,
|
||||
Header,
|
||||
Page,
|
||||
SupportButton,
|
||||
} from '@backstage/core-components';
|
||||
import { Grid, Input, makeStyles } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { TechRadarComponentProps } from '../api';
|
||||
import RadarComponent from '../components/RadarComponent';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
overflowXScroll: {
|
||||
@@ -45,11 +45,19 @@ export const RadarPage = ({
|
||||
...props
|
||||
}: TechRadarPageProps): JSX.Element => {
|
||||
const classes = useStyles();
|
||||
const [searchText, setSearchText] = React.useState('');
|
||||
|
||||
return (
|
||||
<Page themeId="tool">
|
||||
<Header title={title} subtitle={subtitle} />
|
||||
<Content className={classes.overflowXScroll}>
|
||||
<ContentHeader title={pageTitle}>
|
||||
<Input
|
||||
id="tech-radar-filter"
|
||||
type="search"
|
||||
placeholder="Filter"
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
/>
|
||||
<SupportButton>
|
||||
This is used for visualizing the official guidelines of different
|
||||
areas of software development such as languages, frameworks,
|
||||
@@ -58,7 +66,7 @@ export const RadarPage = ({
|
||||
</ContentHeader>
|
||||
<Grid container spacing={3} direction="row">
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<RadarComponent {...props} />
|
||||
<RadarComponent searchText={searchText} {...props} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
## Getting started
|
||||
|
||||
Set up Backstage and TechDocs by follow our guide on [Getting Started](../../docs/features/techdocs/getting-started.md).
|
||||
Set up Backstage and TechDocs by following our guide on [Getting Started](../../docs/features/techdocs/getting-started.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
http://backstage.io/docs/features/techdocs/configuration
|
||||
Refer to our [configuration reference](../../docs/features/techdocs/configuration.md) for a complete listing of configuration options.
|
||||
|
||||
### TechDocs Storage Api
|
||||
### TechDocs Storage API
|
||||
|
||||
The default setup of TechDocs assumes your documentation is accessed by reading a page with the format of `<storageUrl>/<entity kind>/<entity namespace>/<entity name>`. If for some reason you want to change this it can be configured by implementing a new techdocs storage API. Do this by implementing TechDocsStorage found in `plugins/techdocs/src/api`. Add your new API to the application in `app/src/apis.ts` (or replace if it's already registered as an API).
|
||||
The default setup of TechDocs assumes that your documentation is accessed by reading a page with the format of `<storageUrl>/<entity kind>/<entity namespace>/<entity name>`. This can be configured by [implementing a new techdocs storage API](https://backstage.io/docs/features/techdocs/how-to-guides#how-to-implement-your-own-techdocs-apis).
|
||||
|
||||
@@ -28,12 +28,7 @@ export function createCopyDocsUrlAction(copyToClipboard: Function) {
|
||||
icon: () => <ShareIcon fontSize="small" />,
|
||||
tooltip: 'Click to copy documentation link to clipboard',
|
||||
onClick: () =>
|
||||
copyToClipboard(
|
||||
`${window.location.origin}${window.location.pathname.replace(
|
||||
/\/?$/,
|
||||
'/',
|
||||
)}${row.resolved.docsUrl}`,
|
||||
),
|
||||
copyToClipboard(`${window.location.origin}${row.resolved.docsUrl}`),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5287,15 +5287,15 @@
|
||||
react-use "^17.2.4"
|
||||
|
||||
"@roadiehq/backstage-plugin-github-pull-requests@^1.0.13":
|
||||
version "1.0.13"
|
||||
resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-pull-requests/-/backstage-plugin-github-pull-requests-1.0.13.tgz#18673d5b906f03fe9b34e388f85e699f51ce89f0"
|
||||
integrity sha512-k9z2uas1SiCLC93A6kUu8smeKnBA6GbSdY+VhhMgojHmGkhS669HxIbCyt9xyf1S6/G7o6/4MibxHBvHfszI4Q==
|
||||
version "1.0.15"
|
||||
resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-pull-requests/-/backstage-plugin-github-pull-requests-1.0.15.tgz#a60a59d825dc4cb5373263228ca546ebff6b6313"
|
||||
integrity sha512-cNlSJgbRAE8HWsx/uHOhmQiQjLHynUB9Nq6IzovCbJmXU13JYkXit+jlrw+tMbPaTB826VEKXkOO+u2pNvkV9A==
|
||||
dependencies:
|
||||
"@backstage/catalog-model" "^0.9.0"
|
||||
"@backstage/core-app-api" "^0.1.3"
|
||||
"@backstage/core-components" "^0.3.0"
|
||||
"@backstage/core-components" "^0.5.0"
|
||||
"@backstage/core-plugin-api" "^0.1.3"
|
||||
"@backstage/plugin-catalog-react" "^0.4.0"
|
||||
"@backstage/plugin-catalog-react" "^0.5.0"
|
||||
"@material-ui/core" "^4.11.0"
|
||||
"@material-ui/icons" "^4.9.1"
|
||||
"@octokit/rest" "^18.5.3"
|
||||
@@ -6600,9 +6600,9 @@
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/core-js@^2.5.4":
|
||||
version "2.5.4"
|
||||
resolved "https://registry.npmjs.org/@types/core-js/-/core-js-2.5.4.tgz#fc42ebde7d9cfa7c5f2668f117449b02348e41fd"
|
||||
integrity sha512-Xwy8o12ak+iYgFr/KCVaVK5Sy+jFMiiPAID3+ObvMlBzy26XQJw5xu+a6rlHsrJENXj/AwJOGsJpVohUjAzSKQ==
|
||||
version "2.5.5"
|
||||
resolved "https://registry.npmjs.org/@types/core-js/-/core-js-2.5.5.tgz#dc5a013ee0b23bd5ac126403854fadabb4f24f75"
|
||||
integrity sha512-C4vwOHrhsvxn7UFyk4NDQNUpgNKdWsT/bL39UWyD75KSEOObZSKa9mYDOCM5FGeJG2qtbG0XiEbUKND2+j0WOg==
|
||||
|
||||
"@types/cors@2.8.10":
|
||||
version "2.8.10"
|
||||
@@ -6777,7 +6777,7 @@
|
||||
"@types/express" "*"
|
||||
"@types/xml2js" "*"
|
||||
|
||||
"@types/express@*", "@types/express@^4.17.12", "@types/express@^4.17.6", "@types/express@^4.17.7":
|
||||
"@types/express@*", "@types/express@^4.17.12", "@types/express@^4.17.6":
|
||||
version "4.17.12"
|
||||
resolved "https://registry.npmjs.org/@types/express/-/express-4.17.12.tgz#4bc1bf3cd0cfe6d3f6f2853648b40db7d54de350"
|
||||
integrity sha512-pTYas6FrP15B1Oa0bkN5tQMNqOcVXa9j4FTFtO8DWI9kppKib+6NJtfTOOLcwxuuYvcX2+dVG6et1SxW/Kc17Q==
|
||||
@@ -23246,9 +23246,9 @@ react-syntax-highlighter@^13.5.3:
|
||||
refractor "^3.1.0"
|
||||
|
||||
react-syntax-highlighter@^15.4.3:
|
||||
version "15.4.3"
|
||||
resolved "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.4.3.tgz#fffe3286677ac470b963b364916d16374996f3a6"
|
||||
integrity sha512-TnhGgZKXr5o8a63uYdRTzeb8ijJOgRGe0qjrE0eK/gajtdyqnSO6LqB3vW16hHB0cFierYSoy/AOJw8z1Dui8g==
|
||||
version "15.4.4"
|
||||
resolved "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.4.4.tgz#dc9043f19e7bd063ff3ea78986d22a6eaa943b2a"
|
||||
integrity sha512-PsOFHNTzkb3OroXdoR897eKN5EZ6grht1iM+f1lJSq7/L0YVnkJaNVwC3wEUYPOAmeyl5xyer1DjL6MrumO6Zw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.3.1"
|
||||
highlight.js "^10.4.1"
|
||||
|
||||
Reference in New Issue
Block a user