Merge branch 'backstage:master' into master

This commit is contained in:
Bogdan Nechyporenko
2022-09-13 11:51:15 +02:00
committed by GitHub
58 changed files with 1118 additions and 521 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-msgraph': patch
---
Added $select attribute to user query
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
humanizeEntityRef function can now be forced to include default namespace
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/config-loader': patch
---
No longer log when reloading remote config.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-plugin-api': patch
---
Service are now scoped to either `'plugin'` or `'root'` scope. Service factories have been updated to provide dependency instances directly rather than factory functions.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Support displaying and ordering by counts in `EntityTagPicker` field. Add the `showCounts` option to enable this. Also support configuring `helperText`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-node': patch
---
Updated usage of experimental backend service APIs.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': patch
---
Updated service implementations and backend wiring to support scoped service.
+3 -1
View File
@@ -49,6 +49,8 @@ The api for providing custom rules may differ between plugins, but there should
// packages/backend/src/plugins/catalog.ts
import { isInSystemRule } from './permission';
// The CatalogBuilder with the addPermissionRules function is in the alpha path
import { CatalogBuilder } from '@backstage/plugin-catalog-backend/alpha';
...
@@ -56,7 +58,7 @@ export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const builder = await CatalogBuilder.create(env);
builder.addPermissionRules(isInSystem);
builder.addPermissionRules(isInSystemRule);
...
return router;
}
+1 -4
View File
@@ -77,10 +77,7 @@ export default async function createPlugin(
logger: env.logger,
discovery: env.discovery,
policy: new TestPermissionPolicy(),
identity: IdentityClient.create({
discovery: env.discovery,
issuer: await env.discovery.getExternalBaseUrl('auth'),
}),
identity: env.identity,
});
}
```
+64
View File
@@ -0,0 +1,64 @@
---
id: feature-flags
title: Feature Flags
description: Details the process of defining setting and reading a plugin feature flag.
---
Backstage offers the ability to define feature flags inside a plugin. This allows you to restrict parts of your plugin to those individual users who have toggled the feature flag to on.
This page describes the process of defining setting and reading a plugin feature flag. If you are looking for using feature flags with software templates that can be found under [Writing Templates](https://backstage.io/docs/features/software-templates/writing-templates#remove-sections-or-fields-based-on-feature-flags).
## Defining a Feature Flag
Before using a feature flag we must first define it. This is done when we create the plugin by passing the name of the feature flag into the `featureFlags` array.
```ts
/* src/plugin.ts */
import { createPlugin, createRouteRef } from '@backstage/core-plugin-api';
import ExampleComponent from './components/ExampleComponent';
export const examplePlugin = createPlugin({
id: 'example',
routes: {
root: rootRouteRef,
},
featureFlags: [{ name: 'show-example-feature' }],
});
```
## Enabling Feature Flags
Feature flags are defaulted to off and can be updated by individual users in the backstage interface.
These are set by navigating to the page under `Settings` > `Feature Flags`.
The users selection is saved in the users browsers local storage. Once toggled it may be required for a user to refresh the page to see any new changes.
## FeatureFlagged Component
The easiest way to control content based on the state of a feature flag is to use the [FeatureFlagged](https://backstage.io/docs/reference/core-app-api.featureflagged) component.
```ts
import { FeatureFlagged } from '@backstage/core-app-api'
...
<FeatureFlagged with="show-example-feature">
<NewFeatureComponent />
</FeatureFlagged>
<FeatureFlagged without="show-example-feature">
<PreviousFeatureComponent />
</FeatureFlagged>
```
## Evaluating Feature Flag State
It is also possible to test the feature flag state using the [FeatureFlags Api](https://backstage.io/docs/reference/core-plugin-api.featureflagsapi).
```ts
import { useApi, featureFlagsApiRef } from '@backstage/core-plugin-api';
const featureFlagsApi = useApi(featureFlagsApiRef);
const isOn = featureFlagsApi.isActive('show-example-feature');
```
+1 -1
View File
@@ -127,10 +127,10 @@ nav:
- Create a Backstage Plugin: 'plugins/create-a-plugin.md'
- Plugin Development: 'plugins/plugin-development.md'
- Structure of a plugin: 'plugins/structure-of-a-plugin.md'
- Plugin Development: 'plugins/plugin-development.md'
- Integrate into the Software Catalog: 'plugins/integrating-plugin-into-software-catalog.md'
- Composability System: 'plugins/composability.md'
- Plugin Analytics: 'plugins/analytics.md'
- Feature Flags: 'plugins/feature-flags.md'
- Backends and APIs:
- Proxying: 'plugins/proxying.md'
- Backend plugin: 'plugins/backend-plugin.md'
@@ -18,6 +18,7 @@ import { CacheManager } from '@backstage/backend-common';
import {
configServiceRef,
createServiceFactory,
pluginMetadataServiceRef,
cacheServiceRef,
} from '@backstage/backend-plugin-api';
@@ -25,13 +26,13 @@ import {
export const cacheFactory = createServiceFactory({
service: cacheServiceRef,
deps: {
configFactory: configServiceRef,
config: configServiceRef,
plugin: pluginMetadataServiceRef,
},
factory: async ({ configFactory }) => {
const config = await configFactory('root');
async factory({ config }) {
const cacheManager = CacheManager.fromConfig(config);
return async (pluginId: string) => {
return cacheManager.forPlugin(pluginId);
return async ({ plugin }) => {
return cacheManager.forPlugin(plugin.getId());
};
},
});
@@ -19,23 +19,20 @@ import {
configServiceRef,
createServiceFactory,
loggerToWinstonLogger,
loggerServiceRef,
rootLoggerServiceRef,
} from '@backstage/backend-plugin-api';
/** @public */
export const configFactory = createServiceFactory({
service: configServiceRef,
deps: {
loggerFactory: loggerServiceRef,
logger: rootLoggerServiceRef,
},
factory: async ({ loggerFactory }) => {
const logger = await loggerFactory('root');
async factory({ logger }) {
const config = await loadBackendConfig({
argv: process.argv,
logger: loggerToWinstonLogger(logger),
});
return async () => {
return config;
};
return config;
},
});
@@ -19,19 +19,20 @@ import {
configServiceRef,
createServiceFactory,
databaseServiceRef,
pluginMetadataServiceRef,
} from '@backstage/backend-plugin-api';
/** @public */
export const databaseFactory = createServiceFactory({
service: databaseServiceRef,
deps: {
configFactory: configServiceRef,
config: configServiceRef,
plugin: pluginMetadataServiceRef,
},
factory: async ({ configFactory }) => {
const config = await configFactory('root');
async factory({ config }) {
const databaseManager = DatabaseManager.fromConfig(config);
return async (pluginId: string) => {
return databaseManager.forPlugin(pluginId);
return async ({ plugin }) => {
return databaseManager.forPlugin(plugin.getId());
};
},
});
@@ -25,10 +25,9 @@ import {
export const discoveryFactory = createServiceFactory({
service: discoveryServiceRef,
deps: {
configFactory: configServiceRef,
config: configServiceRef,
},
factory: async ({ configFactory }) => {
const config = await configFactory('root');
async factory({ config }) {
const discovery = SingleHostDiscovery.fromConfig(config);
return async () => {
return discovery;
@@ -18,6 +18,7 @@ import {
createServiceFactory,
httpRouterServiceRef,
configServiceRef,
pluginMetadataServiceRef,
} from '@backstage/backend-plugin-api';
import Router from 'express-promise-router';
import { Handler } from 'express';
@@ -27,18 +28,20 @@ import { createServiceBuilder } from '@backstage/backend-common';
export const httpRouterFactory = createServiceFactory({
service: httpRouterServiceRef,
deps: {
configFactory: configServiceRef,
config: configServiceRef,
plugin: pluginMetadataServiceRef,
},
factory: async ({ configFactory }) => {
async factory({ config }) {
const rootRouter = Router();
const service = createServiceBuilder(module)
.loadConfig(await configFactory('root'))
.loadConfig(config)
.addRouter('', rootRouter);
await service.start();
return async (pluginId?: string) => {
return async ({ plugin }) => {
const pluginId = plugin.getId();
const path = pluginId ? `/api/${pluginId}` : '';
return {
use(handler: Handler) {
@@ -14,38 +14,23 @@
* limitations under the License.
*/
import { createRootLogger } from '@backstage/backend-common';
import {
createServiceFactory,
Logger,
loggerServiceRef,
pluginMetadataServiceRef,
rootLoggerServiceRef,
} from '@backstage/backend-plugin-api';
import { Logger as WinstonLogger } from 'winston';
class BackstageLogger implements Logger {
static fromWinston(logger: WinstonLogger): BackstageLogger {
return new BackstageLogger(logger);
}
private constructor(private readonly winston: WinstonLogger) {}
info(message: string, ...meta: any[]): void {
this.winston.info(message, ...meta);
}
child(fields: { [name: string]: string }): Logger {
return new BackstageLogger(this.winston.child(fields));
}
}
/** @public */
export const loggerFactory = createServiceFactory({
service: loggerServiceRef,
deps: {},
factory: async () => {
const root = BackstageLogger.fromWinston(createRootLogger());
return async (pluginId: string) => {
return root.child({ pluginId });
deps: {
rootLogger: rootLoggerServiceRef,
plugin: pluginMetadataServiceRef,
},
async factory({ rootLogger }) {
return async ({ plugin }) => {
return rootLogger.child({ pluginId: plugin.getId() });
};
},
});
@@ -27,20 +27,16 @@ import { ServerPermissionClient } from '@backstage/plugin-permission-node';
export const permissionsFactory = createServiceFactory({
service: permissionsServiceRef,
deps: {
configFactory: configServiceRef,
discoveryFactory: discoveryServiceRef,
tokenManagerFactory: tokenManagerServiceRef,
config: configServiceRef,
discovery: discoveryServiceRef,
tokenManager: tokenManagerServiceRef,
},
factory: async ({ configFactory, discoveryFactory, tokenManagerFactory }) => {
const config = await configFactory('root');
const discovery = await discoveryFactory('root');
const tokenManager = await tokenManagerFactory('root');
const permissions = ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
});
return async (_pluginId: string) => {
return permissions;
async factory({ config }) {
return async ({ discovery, tokenManager }) => {
return ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
});
};
},
});
@@ -0,0 +1,48 @@
/*
* 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 { createRootLogger } from '@backstage/backend-common';
import {
createServiceFactory,
Logger,
rootLoggerServiceRef,
} from '@backstage/backend-plugin-api';
import { Logger as WinstonLogger } from 'winston';
class BackstageLogger implements Logger {
static fromWinston(logger: WinstonLogger): BackstageLogger {
return new BackstageLogger(logger);
}
private constructor(private readonly winston: WinstonLogger) {}
info(message: string, ...meta: any[]): void {
this.winston.info(message, ...meta);
}
child(fields: { [name: string]: string }): Logger {
return new BackstageLogger(this.winston.child(fields));
}
}
/** @public */
export const loggerFactory = createServiceFactory({
service: rootLoggerServiceRef,
deps: {},
async factory() {
return BackstageLogger.fromWinston(createRootLogger());
},
});
@@ -17,6 +17,7 @@
import {
configServiceRef,
createServiceFactory,
pluginMetadataServiceRef,
schedulerServiceRef,
} from '@backstage/backend-plugin-api';
import { TaskScheduler } from '@backstage/backend-tasks';
@@ -25,13 +26,13 @@ import { TaskScheduler } from '@backstage/backend-tasks';
export const schedulerFactory = createServiceFactory({
service: schedulerServiceRef,
deps: {
configFactory: configServiceRef,
config: configServiceRef,
plugin: pluginMetadataServiceRef,
},
factory: async ({ configFactory }) => {
const config = await configFactory('root');
async factory({ config }) {
const taskScheduler = TaskScheduler.fromConfig(config);
return async (pluginId: string) => {
return taskScheduler.forPlugin(pluginId);
return async ({ plugin }) => {
return taskScheduler.forPlugin(plugin.getId());
};
},
});
@@ -27,32 +27,11 @@ import { ServerTokenManager } from '@backstage/backend-common';
export const tokenManagerFactory = createServiceFactory({
service: tokenManagerServiceRef,
deps: {
configFactory: configServiceRef,
loggerFactory: loggerServiceRef,
config: configServiceRef,
logger: loggerServiceRef,
},
factory: async ({ configFactory, loggerFactory }) => {
const logger = await loggerFactory('root');
const config = await configFactory('root');
return async (_pluginId: string) => {
// doesn't the logger want to be inferred from the plugin tho here?
// maybe ... also why do we recreate it every time otherwise
// we should memoize on a per plugin right? so I think it's should be fine to re-use the plugin one
// we shouldn't recreate on a per plugin basis.
// hm - on the other hand, is this really ever called more than once?
// not this function right. should only be called when the plugin requests this serviceRef
// yeah so no need to worry about memo probably
// but we still want to scope the logger to the ServrTokenmanagfer>?
// mm sure maybe
// maybe in this case it doesn't provide so much value b
// oh hang on - isn't it up to THE MANAGER to make a child internally if it wants to do that
// so that it becomes a property intrinsic to that class, no matter how it's constructed
// or is that too much responsibility for it - making the constructor complex so to speak, making it harder to tweak that behavior
// this is not ultra efficient :)
// I think the naming here is wrong to be gonest
// this isn't like the cache manager or the database manager
// the manager name is confusuion i think
// aye perhaps
async factory() {
return async ({ config, logger }) => {
return ServerTokenManager.fromConfig(config, {
logger: loggerToWinstonLogger(logger),
});
@@ -27,15 +27,14 @@ import {
export const urlReaderFactory = createServiceFactory({
service: urlReaderServiceRef,
deps: {
configFactory: configServiceRef,
loggerFactory: loggerServiceRef,
config: configServiceRef,
logger: loggerServiceRef,
},
factory: async ({ configFactory, loggerFactory }) => {
return async (pluginId: string) => {
const logger = await loggerFactory(pluginId);
async factory() {
return async ({ config, logger }) => {
return UrlReaders.default({
config,
logger: loggerToWinstonLogger(logger),
config: await configFactory(pluginId),
});
};
},
@@ -50,11 +50,12 @@ export class BackendInitializer {
if (extensionPoint) {
result.set(name, extensionPoint);
} else {
const factory = await this.#serviceHolder.get(
const impl = await this.#serviceHolder.get(
ref as ServiceRef<unknown>,
pluginId,
);
if (factory) {
result.set(name, await factory(pluginId));
if (impl) {
result.set(name, impl);
} else {
missingRefs.add(ref);
}
@@ -18,158 +18,209 @@ import {
createServiceRef,
createServiceFactory,
ServiceRef,
pluginMetadataServiceRef,
} from '@backstage/backend-plugin-api';
import { ServiceRegistry } from './ServiceRegistry';
const ref1 = createServiceRef<{ x: number; pluginId: string }>({
const ref1 = createServiceRef<{ x: number }>({
id: '1',
});
const sf1 = createServiceFactory({
service: ref1,
deps: {},
factory: async () => {
return async pluginId => {
return { x: 1, pluginId };
async factory() {
return async () => {
return { x: 1 };
};
},
});
const ref2 = createServiceRef<{ x: number; pluginId: string }>({
const ref2 = createServiceRef<{ x: number }>({
scope: 'root',
id: '2',
});
const sf2 = createServiceFactory({
service: ref2,
deps: {},
factory: async () => {
return async pluginId => {
return { x: 2, pluginId };
};
async factory() {
return { x: 2 };
},
});
const sf2b = createServiceFactory({
service: ref2,
deps: {},
factory: async () => {
return async pluginId => {
return { x: 22, pluginId };
};
async factory() {
return { x: 22 };
},
});
})();
const refDefault1 = createServiceRef<{ x: number; pluginId: string }>({
const refDefault1 = createServiceRef<{ x: number }>({
id: '1',
defaultFactory: async service =>
createServiceFactory({
service,
deps: {},
factory: async () => async pluginId => ({ x: 10, pluginId }),
}),
async factory() {
return async () => ({ x: 10 });
},
})(),
});
const refDefault2a = createServiceRef<{ x: number; pluginId: string }>({
const refDefault2a = createServiceRef<{ x: number }>({
id: '2a',
defaultFactory: async service =>
createServiceFactory({
service,
deps: {},
factory: async () => async pluginId => ({ x: 20, pluginId }),
async factory() {
return async () => ({ x: 20 });
},
}),
});
const refDefault2b = createServiceRef<{ x: number; pluginId: string }>({
const refDefault2b = createServiceRef<{ x: number }>({
id: '2b',
defaultFactory: async service =>
createServiceFactory({
service,
deps: {},
factory: async () => async pluginId => ({ x: 220, pluginId }),
async factory() {
return async () => ({ x: 220 });
},
}),
});
describe('ServiceRegistry', () => {
it('should return undefined if there is no factory defined', async () => {
const registry = new ServiceRegistry([]);
expect(registry.get(ref1)).toBe(undefined);
expect(registry.get(ref1, 'catalog')).toBe(undefined);
});
it('should return a factory for a registered ref', async () => {
it('should return an implementation for a registered ref', async () => {
const registry = new ServiceRegistry([sf1]);
const factory = registry.get(ref1)!;
expect(factory).toEqual(expect.any(Function));
await expect(factory('catalog')).resolves.toEqual({
x: 1,
pluginId: 'catalog',
});
await expect(factory('scaffolder')).resolves.toEqual({
x: 1,
pluginId: 'scaffolder',
});
expect(await factory('catalog')).toBe(await factory('catalog'));
await expect(registry.get(ref1, 'catalog')).resolves.toEqual({ x: 1 });
await expect(registry.get(ref1, 'scaffolder')).resolves.toEqual({ x: 1 });
expect(await registry.get(ref1, 'catalog')).toBe(
await registry.get(ref1, 'catalog'),
);
expect(await registry.get(ref1, 'scaffolder')).toBe(
await registry.get(ref1, 'scaffolder'),
);
expect(await registry.get(ref1, 'catalog')).not.toBe(
await registry.get(ref1, 'scaffolder'),
);
});
it('should handle multiple factories with different serviceRefs', async () => {
const registry = new ServiceRegistry([sf1, sf2]);
const factory1 = registry.get(ref1)!;
const factory2 = registry.get(ref2)!;
expect(factory1).toEqual(expect.any(Function));
expect(factory2).toEqual(expect.any(Function));
await expect(factory1('catalog')).resolves.toEqual({
await expect(registry.get(ref1, 'catalog')).resolves.toEqual({
x: 1,
pluginId: 'catalog',
});
await expect(factory2('catalog')).resolves.toEqual({
await expect(registry.get(ref2, 'catalog')).resolves.toEqual({
x: 2,
});
expect(await registry.get(ref1, 'catalog')).not.toBe(
await registry.get(ref2, 'catalog'),
);
});
it('should not be possible for root scoped services to depend on plugin scoped services', async () => {
const factory = createServiceFactory({
service: ref2,
deps: { pluginDep: ref1 },
async factory() {
return { x: 2 };
},
});
const registry = new ServiceRegistry([factory, sf1]);
await expect(registry.get(ref2, 'catalog')).rejects.toThrow(
"Failed to instantiate 'root' scoped service '2' because it depends on 'plugin' scoped service '1'.",
);
});
it('should be possible for plugin scoped services to depend on root scoped services', async () => {
const factory = createServiceFactory({
service: ref1,
deps: { rootDep: ref2 },
async factory({ rootDep }) {
return async () => ({ x: rootDep.x });
},
});
const registry = new ServiceRegistry([factory, sf2]);
await expect(registry.get(ref1, 'catalog')).resolves.toEqual({
x: 2,
});
});
it('should be possible for root scoped services to depend on root scoped services', async () => {
const ref = createServiceRef<{ x: number }>({ id: 'x', scope: 'root' });
const factory = createServiceFactory({
service: ref,
deps: { rootDep: ref2 },
async factory({ rootDep }) {
return { x: rootDep.x };
},
});
const registry = new ServiceRegistry([factory, sf2]);
await expect(registry.get(ref, 'catalog')).resolves.toEqual({
x: 2,
});
});
it('should return the pluginId from the pluginMetadata service', async () => {
const ref = createServiceRef<{ pluginId: string }>({ id: 'x' });
const factory = createServiceFactory({
service: ref,
deps: { meta: pluginMetadataServiceRef },
async factory() {
return async ({ meta }) => ({ pluginId: meta.getId() });
},
});
const registry = new ServiceRegistry([factory]);
await expect(registry.get(ref, 'catalog')).resolves.toEqual({
pluginId: 'catalog',
});
expect(await factory1('catalog')).not.toBe(await factory2('catalog'));
});
it('should use the last factory for each ref', async () => {
const registry = new ServiceRegistry([sf2, sf2b]);
const factory2 = registry.get(ref2)!;
await expect(factory2('catalog')).resolves.toEqual({
await expect(registry.get(ref2, 'catalog')).resolves.toEqual({
x: 22,
pluginId: 'catalog',
});
});
it('should return the defaultFactory from the ref if not provided to the registry', async () => {
it('should use the defaultFactory from the ref if not provided to the registry', async () => {
const registry = new ServiceRegistry([]);
const factory = registry.get(refDefault1)!;
expect(factory).toEqual(expect.any(Function));
await expect(factory('catalog')).resolves.toEqual({
await expect(registry.get(refDefault1, 'catalog')).resolves.toEqual({
x: 10,
pluginId: 'catalog',
});
});
it('should not return the defaultFactory from the ref if provided to the registry', async () => {
it('should not use the defaultFactory from the ref if provided to the registry', async () => {
const registry = new ServiceRegistry([sf1]);
const factory = registry.get(refDefault1)!;
expect(factory).toEqual(expect.any(Function));
await expect(factory('catalog')).resolves.toEqual({
await expect(registry.get(refDefault1, 'catalog')).resolves.toEqual({
x: 1,
pluginId: 'catalog',
});
});
it('should handle duplicate defaultFactories by duplicating the implementations', async () => {
const registry = new ServiceRegistry([]);
const factoryA = registry.get(refDefault2a)!;
const factoryB = registry.get(refDefault2b)!;
expect(factoryA).toEqual(expect.any(Function));
expect(factoryB).toEqual(expect.any(Function));
await expect(factoryA('catalog')).resolves.toEqual({
await expect(registry.get(refDefault2a, 'catalog')).resolves.toEqual({
x: 20,
pluginId: 'catalog',
});
await expect(factoryB('catalog')).resolves.toEqual({
await expect(registry.get(refDefault2b, 'catalog')).resolves.toEqual({
x: 220,
pluginId: 'catalog',
});
expect(await factoryA('catalog')).toBe(await factoryA('catalog'));
expect(await factoryB('catalog')).toBe(await factoryB('catalog'));
expect(await factoryA('catalog')).not.toBe(await factoryB('catalog'));
expect(await registry.get(refDefault2a, 'catalog')).toBe(
await registry.get(refDefault2a, 'catalog'),
);
expect(await registry.get(refDefault2b, 'catalog')).toBe(
await registry.get(refDefault2b, 'catalog'),
);
expect(await registry.get(refDefault2a, 'catalog')).not.toBe(
await registry.get(refDefault2b, 'catalog'),
);
});
it('should only call each default factory loader once', async () => {
@@ -177,7 +228,9 @@ describe('ServiceRegistry', () => {
createServiceFactory({
service,
deps: {},
factory: async () => async () => {},
async factory() {
return async () => {};
},
}),
);
const ref = createServiceRef<void>({
@@ -186,17 +239,16 @@ describe('ServiceRegistry', () => {
});
const registry = new ServiceRegistry([]);
const factory = registry.get(ref)!;
await Promise.all([
expect(factory('catalog')).resolves.toBeUndefined(),
expect(factory('catalog')).resolves.toBeUndefined(),
expect(registry.get(ref, 'catalog')).resolves.toBeUndefined(),
expect(registry.get(ref, 'catalog')).resolves.toBeUndefined(),
]);
expect(factoryLoader).toHaveBeenCalledTimes(1);
});
it('should not call factory functions more than once', async () => {
const innerFactory = jest.fn(async (pluginId: string) => {
return { x: 1, pluginId };
const innerFactory = jest.fn(async () => {
return { x: 1 };
});
const factory = jest.fn(async () => innerFactory);
const myFactory = createServiceFactory({
@@ -208,17 +260,15 @@ describe('ServiceRegistry', () => {
const registry = new ServiceRegistry([myFactory]);
await Promise.all([
registry.get(ref1)!('catalog')!,
registry.get(ref1)!('catalog')!,
registry.get(ref1)!('catalog')!,
registry.get(ref1)!('scaffolder')!,
registry.get(ref1)!('scaffolder')!,
registry.get(ref1, 'catalog')!,
registry.get(ref1, 'catalog')!,
registry.get(ref1, 'catalog')!,
registry.get(ref1, 'scaffolder')!,
registry.get(ref1, 'scaffolder')!,
]);
expect(factory).toHaveBeenCalledTimes(1);
expect(innerFactory).toHaveBeenCalledTimes(2);
expect(innerFactory).toHaveBeenCalledWith('catalog');
expect(innerFactory).toHaveBeenCalledWith('scaffolder');
});
it('should throw if dependencies are not available', async () => {
@@ -231,9 +281,8 @@ describe('ServiceRegistry', () => {
});
const registry = new ServiceRegistry([myFactory]);
const factory = registry.get(ref1)!;
await expect(factory('catalog')).rejects.toThrow(
await expect(registry.get(ref1, 'catalog')).rejects.toThrow(
"Failed to instantiate service '1' for 'catalog' because the following dependent services are missing: '2'",
);
});
@@ -247,8 +296,8 @@ describe('ServiceRegistry', () => {
const factoryA = createServiceFactory({
service: refA,
deps: { b: refB },
async factory({ b }) {
return async pluginId => b(pluginId);
async factory() {
return async ({ b }) => b;
},
});
@@ -261,9 +310,8 @@ describe('ServiceRegistry', () => {
});
const registry = new ServiceRegistry([factoryA, factoryB]);
const factory = registry.get(refA)!;
await expect(factory('catalog')).rejects.toThrow(
await expect(registry.get(refA, 'catalog')).rejects.toThrow(
"Failed to instantiate service 'a' for 'catalog' because the factory function threw an error, Error: Failed to instantiate service 'b' for 'catalog' because the following dependent services are missing: 'c', 'd'",
);
});
@@ -278,9 +326,8 @@ describe('ServiceRegistry', () => {
});
const registry = new ServiceRegistry([myFactory]);
const factory = registry.get(ref1)!;
await expect(factory('catalog')).rejects.toThrow(
await expect(registry.get(ref1, 'catalog')).rejects.toThrow(
"Failed to instantiate service '1' because the top-level factory function threw an error, Error: top-level error",
);
});
@@ -290,17 +337,16 @@ describe('ServiceRegistry', () => {
service: ref1,
deps: {},
async factory() {
return pluginId => {
throw new Error(`error in plugin ${pluginId}`);
return () => {
throw new Error(`error in plugin`);
};
},
});
const registry = new ServiceRegistry([myFactory]);
const factory = registry.get(ref1)!;
await expect(factory('catalog')).rejects.toThrow(
"Failed to instantiate service '1' for 'catalog' because the factory function threw an error, Error: error in plugin catalog",
await expect(registry.get(ref1, 'catalog')).rejects.toThrow(
"Failed to instantiate service '1' for 'catalog' because the factory function threw an error, Error: error in plugin",
);
});
@@ -313,9 +359,8 @@ describe('ServiceRegistry', () => {
});
const registry = new ServiceRegistry([]);
const factory = registry.get(ref)!;
await expect(factory('catalog')).rejects.toThrow(
await expect(registry.get(ref, 'catalog')).rejects.toThrow(
"Failed to instantiate service '1' because the default factory loader threw an error, Error: default factory error",
);
});
@@ -16,8 +16,8 @@
import {
ServiceFactory,
FactoryFunc,
ServiceRef,
pluginMetadataServiceRef,
} from '@backstage/backend-plugin-api';
import { stringifyError } from '@backstage/errors';
@@ -37,7 +37,9 @@ export class ServiceRegistry {
readonly #implementations: Map<
ServiceFactory,
{
factoryFunc: Promise<FactoryFunc<unknown>>;
factoryFunc: Promise<
(deps: { [name in string]: unknown }) => Promise<unknown>
>;
byPlugin: Map<string, Promise<unknown>>;
}
>;
@@ -58,67 +60,123 @@ export class ServiceRegistry {
this.#implementations = new Map();
}
get<T>(ref: ServiceRef<T>): FactoryFunc<T> | undefined {
let factory = this.#providedFactories.get(ref.id);
const { __defaultFactory: defaultFactory } = ref as InternalServiceRef<T>;
if (!factory && !defaultFactory) {
#resolveFactory(
ref: ServiceRef<unknown>,
pluginId: string,
): Promise<ServiceFactory> | undefined {
// Special case handling of the plugin metadata service, generating a custom factory for it each time
if (ref.id === pluginMetadataServiceRef.id) {
return Promise.resolve({
scope: 'plugin',
service: pluginMetadataServiceRef,
deps: {},
factory: async () => async () => ({
getId() {
return pluginId;
},
}),
});
}
let resolvedFactory: Promise<ServiceFactory> | ServiceFactory | undefined =
this.#providedFactories.get(ref.id);
const { __defaultFactory: defaultFactory } =
ref as InternalServiceRef<unknown>;
if (!resolvedFactory && !defaultFactory) {
return undefined;
}
return async (pluginId: string): Promise<T> => {
if (!factory) {
let loadedFactory = this.#loadedDefaultFactories.get(defaultFactory!);
if (!loadedFactory) {
loadedFactory = Promise.resolve()
.then(() => defaultFactory!(ref))
.then(f =>
typeof f === 'function' ? f() : f,
) as Promise<ServiceFactory>;
this.#loadedDefaultFactories.set(defaultFactory!, loadedFactory);
}
// NOTE: This await is safe as long as #providedFactories is not mutated.
factory = await loadedFactory.catch(error => {
throw new Error(
`Failed to instantiate service '${
ref.id
}' because the default factory loader threw an error, ${stringifyError(
error,
)}`,
if (!resolvedFactory) {
let loadedFactory = this.#loadedDefaultFactories.get(defaultFactory!);
if (!loadedFactory) {
loadedFactory = Promise.resolve()
.then(() => defaultFactory!(ref))
.then(f =>
typeof f === 'function' ? f() : f,
) as Promise<ServiceFactory>;
this.#loadedDefaultFactories.set(defaultFactory!, loadedFactory);
}
resolvedFactory = loadedFactory.catch(error => {
throw new Error(
`Failed to instantiate service '${
ref.id
}' because the default factory loader threw an error, ${stringifyError(
error,
)}`,
);
});
}
return Promise.resolve(resolvedFactory);
}
#separateMapForTheRootService = new Map<ServiceFactory, Promise<unknown>>();
#checkForMissingDeps(factory: ServiceFactory, pluginId: string) {
const missingDeps = Object.values(factory.deps).filter(ref => {
if (ref.id === pluginMetadataServiceRef.id) {
return false;
}
if (this.#providedFactories.get(ref.id)) {
return false;
}
return !(ref as InternalServiceRef<unknown>).__defaultFactory;
});
if (missingDeps.length) {
const missing = missingDeps.map(r => `'${r.id}'`).join(', ');
throw new Error(
`Failed to instantiate service '${factory.service.id}' for '${pluginId}' because the following dependent services are missing: ${missing}`,
);
}
}
get<T>(ref: ServiceRef<T>, pluginId: string): Promise<T> | undefined {
return this.#resolveFactory(ref, pluginId)?.then(factory => {
if (factory.scope === 'root') {
let existing = this.#separateMapForTheRootService.get(factory);
if (!existing) {
this.#checkForMissingDeps(factory, pluginId);
const rootDeps = new Array<Promise<[name: string, impl: unknown]>>();
for (const [name, serviceRef] of Object.entries(factory.deps)) {
if (serviceRef.scope !== 'root') {
throw new Error(
`Failed to instantiate 'root' scoped service '${ref.id}' because it depends on '${serviceRef.scope}' scoped service '${serviceRef.id}'.`,
);
}
const target = this.get(serviceRef, pluginId)!;
rootDeps.push(target.then(impl => [name, impl]));
}
existing = Promise.all(rootDeps).then(entries =>
factory.factory(Object.fromEntries(entries)),
);
});
this.#separateMapForTheRootService.set(factory, existing);
}
return existing as Promise<T>;
}
let implementation = this.#implementations.get(factory);
if (!implementation) {
const missingRefs = new Array<ServiceRef<unknown>>();
const factoryDeps: { [name in string]: FactoryFunc<unknown> } = {};
this.#checkForMissingDeps(factory, pluginId);
const rootDeps = new Array<Promise<[name: string, impl: unknown]>>();
for (const [name, serviceRef] of Object.entries(factory.deps)) {
const target = this.get(serviceRef);
if (!target) {
missingRefs.push(serviceRef);
} else {
factoryDeps[name] = target;
if (serviceRef.scope === 'root') {
const target = this.get(serviceRef, pluginId)!;
rootDeps.push(target.then(impl => [name, impl]));
}
}
if (missingRefs.length) {
const missing = missingRefs.map(r => `'${r.id}'`).join(', ');
throw new Error(
`Failed to instantiate service '${ref.id}' for '${pluginId}' because the following dependent services are missing: ${missing}`,
);
}
implementation = {
factoryFunc: Promise.resolve()
.then(() => factory!.factory(factoryDeps))
factoryFunc: Promise.all(rootDeps)
.then(entries => factory.factory(Object.fromEntries(entries)))
.catch(error => {
const cause = stringifyError(error);
throw new Error(
`Failed to instantiate service '${
ref.id
}' because the top-level factory function threw an error, ${stringifyError(
error,
)}`,
`Failed to instantiate service '${ref.id}' because the top-level factory function threw an error, ${cause}`,
);
}),
byPlugin: new Map(),
@@ -129,24 +187,29 @@ export class ServiceRegistry {
let result = implementation.byPlugin.get(pluginId) as Promise<any>;
if (!result) {
result = implementation.factoryFunc.then(func =>
Promise.resolve()
.then(() => func(pluginId))
.catch(error => {
throw new Error(
`Failed to instantiate service '${
ref.id
}' for '${pluginId}' because the factory function threw an error, ${stringifyError(
error,
)}`,
);
}),
);
const allDeps = new Array<Promise<[name: string, impl: unknown]>>();
for (const [name, serviceRef] of Object.entries(factory.deps)) {
const target = this.get(serviceRef, pluginId)!;
allDeps.push(target.then(impl => [name, impl]));
}
result = implementation.factoryFunc
.then(func =>
Promise.all(allDeps).then(entries =>
func(Object.fromEntries(entries)),
),
)
.catch(error => {
const cause = stringifyError(error);
throw new Error(
`Failed to instantiate service '${ref.id}' for '${pluginId}' because the factory function threw an error, ${cause}`,
);
});
implementation.byPlugin.set(pluginId, result);
}
return result;
};
});
}
}
+1 -2
View File
@@ -18,7 +18,6 @@ import {
ServiceFactory,
BackendFeature,
ExtensionPoint,
FactoryFunc,
ServiceRef,
} from '@backstage/backend-plugin-api';
import { BackstageBackend } from './BackstageBackend';
@@ -47,7 +46,7 @@ export interface CreateSpecializedBackendOptions {
}
export type ServiceHolder = {
get<T>(api: ServiceRef<T>): FactoryFunc<T> | undefined;
get<T>(api: ServiceRef<T>, pluginId: string): Promise<T> | undefined;
};
/**
+73 -35
View File
@@ -66,10 +66,10 @@ export interface BackendRegistrationPoints {
}
// @public (undocumented)
export const cacheServiceRef: ServiceRef<PluginCacheManager>;
export const cacheServiceRef: ServiceRef<PluginCacheManager, 'plugin'>;
// @public (undocumented)
export const configServiceRef: ServiceRef<Config>;
export const configServiceRef: ServiceRef<Config, 'root'>;
// @public (undocumented)
export function createBackendModule<
@@ -105,22 +105,25 @@ export function createExtensionPoint<T>(options: {
// @public (undocumented)
export function createServiceFactory<
TService,
TScope extends 'root' | 'plugin',
TImpl extends TService,
TDeps extends {
[name in string]: unknown;
[name in string]: ServiceRef<unknown>;
},
TOpts extends
| {
[name in string]: unknown;
}
| undefined = undefined,
>(factory: {
service: ServiceRef<TService>;
deps: TypesToServiceRef<TDeps>;
>(config: {
service: ServiceRef<TService, TScope>;
deps: TDeps;
factory(
deps: DepsToDepFactories<TDeps>,
deps: ServiceRefsToInstances<TDeps, 'root'>,
options: TOpts,
): Promise<FactoryFunc<TImpl>>;
): TScope extends 'root'
? Promise<TImpl>
: Promise<(deps: ServiceRefsToInstances<TDeps>) => Promise<TImpl>>;
}): undefined extends TOpts
? (options?: TOpts) => ServiceFactory<TService>
: (options: TOpts) => ServiceFactory<TService>;
@@ -128,21 +131,26 @@ export function createServiceFactory<
// @public (undocumented)
export function createServiceRef<T>(options: {
id: string;
scope?: 'plugin';
defaultFactory?: (
service: ServiceRef<T>,
service: ServiceRef<T, 'plugin'>,
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>;
}): ServiceRef<T>;
}): ServiceRef<T, 'plugin'>;
// @public (undocumented)
export const databaseServiceRef: ServiceRef<PluginDatabaseManager>;
export function createServiceRef<T>(options: {
id: string;
scope: 'root';
defaultFactory?: (
service: ServiceRef<T, 'root'>,
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>;
}): ServiceRef<T, 'root'>;
// @public (undocumented)
export type DepsToDepFactories<T> = {
[key in keyof T]: (pluginId: string) => Promise<T[key]>;
};
export const databaseServiceRef: ServiceRef<PluginDatabaseManager, 'plugin'>;
// @public (undocumented)
export const discoveryServiceRef: ServiceRef<PluginEndpointDiscovery>;
export const discoveryServiceRef: ServiceRef<PluginEndpointDiscovery, 'plugin'>;
// @public
export type ExtensionPoint<T> = {
@@ -152,9 +160,6 @@ export type ExtensionPoint<T> = {
$$ref: 'extension-point';
};
// @public (undocumented)
export type FactoryFunc<Impl> = (pluginId: string) => Promise<Impl>;
// @public (undocumented)
export interface HttpRouterService {
// (undocumented)
@@ -162,7 +167,7 @@ export interface HttpRouterService {
}
// @public (undocumented)
export const httpRouterServiceRef: ServiceRef<HttpRouterService>;
export const httpRouterServiceRef: ServiceRef<HttpRouterService, 'plugin'>;
// @public (undocumented)
export interface Logger {
@@ -173,7 +178,7 @@ export interface Logger {
}
// @public (undocumented)
export const loggerServiceRef: ServiceRef<Logger>;
export const loggerServiceRef: ServiceRef<Logger, 'plugin'>;
// @public (undocumented)
export function loggerToWinstonLogger(
@@ -183,33 +188,66 @@ export function loggerToWinstonLogger(
// @public (undocumented)
export const permissionsServiceRef: ServiceRef<
PermissionAuthorizer | PermissionEvaluator
PermissionAuthorizer | PermissionEvaluator,
'plugin'
>;
// @public (undocumented)
export const schedulerServiceRef: ServiceRef<PluginTaskScheduler>;
export interface PluginMetadata {
// (undocumented)
getId(): string;
}
// @public (undocumented)
export type ServiceFactory<TService = unknown> = {
service: ServiceRef<TService>;
deps: {
[key in string]: ServiceRef<unknown>;
};
factory(deps: {
[key in string]: unknown;
}): Promise<FactoryFunc<TService>>;
};
export const pluginMetadataServiceRef: ServiceRef<PluginMetadata, 'plugin'>;
// @public (undocumented)
export const rootLoggerServiceRef: ServiceRef<Logger, 'root'>;
// @public (undocumented)
export const schedulerServiceRef: ServiceRef<PluginTaskScheduler, 'plugin'>;
// @public (undocumented)
export type ServiceFactory<TService = unknown> =
| {
scope: 'root';
service: ServiceRef<TService, 'root'>;
deps: {
[key in string]: ServiceRef<unknown>;
};
factory(deps: {
[key in string]: unknown;
}): Promise<TService>;
}
| {
scope: 'plugin';
service: ServiceRef<TService, 'plugin'>;
deps: {
[key in string]: ServiceRef<unknown>;
};
factory(deps: {
[key in string]: unknown;
}): Promise<
(deps: {
[key in string]: unknown;
}) => Promise<TService>
>;
};
// @public
export type ServiceRef<T> = {
export type ServiceRef<
TService,
TScope extends 'root' | 'plugin' = 'root' | 'plugin',
> = {
id: string;
T: T;
scope: TScope;
T: TService;
toString(): string;
$$ref: 'service';
};
// @public (undocumented)
export const tokenManagerServiceRef: ServiceRef<TokenManager>;
export const tokenManagerServiceRef: ServiceRef<TokenManager, 'plugin'>;
// @public (undocumented)
export type TypesToServiceRef<T> = {
@@ -217,5 +255,5 @@ export type TypesToServiceRef<T> = {
};
// @public (undocumented)
export const urlReaderServiceRef: ServiceRef<UrlReader>;
export const urlReaderServiceRef: ServiceRef<UrlReader, 'plugin'>;
```
@@ -21,5 +21,6 @@ import { createServiceRef } from '../system/types';
* @public
*/
export const configServiceRef = createServiceRef<Config>({
id: 'core.config',
id: 'core.root.config',
scope: 'root',
});
@@ -26,3 +26,6 @@ export { discoveryServiceRef } from './discoveryServiceRef';
export { tokenManagerServiceRef } from './tokenManagerServiceRef';
export { permissionsServiceRef } from './permissionsServiceRef';
export { schedulerServiceRef } from './schedulerServiceRef';
export { rootLoggerServiceRef } from './rootLoggerServiceRef';
export { pluginMetadataServiceRef } from './pluginMetadataServiceRef';
export type { PluginMetadata } from './pluginMetadataServiceRef';
@@ -0,0 +1,31 @@
/*
* 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 { createServiceRef } from '../system/types';
/**
* @public
*/
export interface PluginMetadata {
getId(): string;
}
/**
* @public
*/
export const pluginMetadataServiceRef = createServiceRef<PluginMetadata>({
id: 'core.plugin-metadata',
});
@@ -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.
*/
import { createServiceRef } from '../system/types';
import { Logger } from './loggerServiceRef';
/**
* @public
*/
export const rootLoggerServiceRef = createServiceRef<Logger>({
id: 'core.root.logger',
scope: 'root',
});
@@ -14,11 +14,5 @@
* limitations under the License.
*/
export type {
ServiceRef,
TypesToServiceRef,
DepsToDepFactories,
FactoryFunc,
ServiceFactory,
} from './types';
export type { ServiceRef, TypesToServiceRef, ServiceFactory } from './types';
export { createServiceRef, createServiceFactory } from './types';
@@ -19,63 +19,87 @@
*
* @public
*/
export type ServiceRef<T> = {
export type ServiceRef<
TService,
TScope extends 'root' | 'plugin' = 'root' | 'plugin',
> = {
id: string;
/**
* This determines the scope at which this service is available.
*
* Root scoped services are available to all other services but
* may only depend on other root scoped services.
*
* Plugin scoped services are only available to other plugin scoped
* services but may depend on all other services.
*/
scope: TScope;
/**
* Utility for getting the type of the service, using `typeof serviceRef.T`.
* Attempting to actually read this value will result in an exception.
*/
T: T;
T: TService;
toString(): string;
$$ref: 'service';
};
/**
* @internal
*/
export type InternalServiceRef<T> = ServiceRef<T> & {
/**
* The default factory that will be used to create service
* instances if no other factory is provided.
*/
__defaultFactory?: (
service: ServiceRef<T>,
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>;
};
/** @public */
export type TypesToServiceRef<T> = { [key in keyof T]: ServiceRef<T[key]> };
/** @public */
export type DepsToDepFactories<T> = {
[key in keyof T]: (pluginId: string) => Promise<T[key]>;
};
export type ServiceFactory<TService = unknown> =
| {
// This scope prop is needed in addition to the service ref, as TypeScript
// can't properly discriminate the two factory types otherwise.
scope: 'root';
service: ServiceRef<TService, 'root'>;
deps: { [key in string]: ServiceRef<unknown> };
factory(deps: { [key in string]: unknown }): Promise<TService>;
}
| {
scope: 'plugin';
service: ServiceRef<TService, 'plugin'>;
deps: { [key in string]: ServiceRef<unknown> };
factory(deps: { [key in string]: unknown }): Promise<
(deps: { [key in string]: unknown }) => Promise<TService>
>;
};
/** @public */
export type FactoryFunc<Impl> = (pluginId: string) => Promise<Impl>;
/** @public */
export type ServiceFactory<TService = unknown> = {
service: ServiceRef<TService>;
deps: { [key in string]: ServiceRef<unknown> };
factory(deps: { [key in string]: unknown }): Promise<FactoryFunc<TService>>;
};
/**
* @public
*/
export function createServiceRef<T>(options: {
id: string;
scope?: 'plugin';
defaultFactory?: (
service: ServiceRef<T>,
service: ServiceRef<T, 'plugin'>,
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>;
}): ServiceRef<T, 'plugin'>;
/** @public */
export function createServiceRef<T>(options: {
id: string;
scope: 'root';
defaultFactory?: (
service: ServiceRef<T, 'root'>,
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>;
}): ServiceRef<T, 'root'>;
export function createServiceRef<T>(options: {
id: string;
scope?: 'plugin' | 'root';
defaultFactory?:
| ((
service: ServiceRef<T, 'plugin'>,
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>)
| ((
service: ServiceRef<T, 'root'>,
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>);
}): ServiceRef<T> {
const { id, defaultFactory } = options;
const { id, scope = 'plugin', defaultFactory } = options;
return {
id,
scope,
get T(): T {
throw new Error(`tried to read ServiceRef.T of ${this}`);
},
@@ -84,32 +108,51 @@ export function createServiceRef<T>(options: {
},
$$ref: 'service', // TODO: declare
__defaultFactory: defaultFactory,
} as InternalServiceRef<T>;
} as ServiceRef<T, typeof scope> & {
__defaultFactory?: (
service: ServiceRef<T>,
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>;
};
}
/** @ignore */
type ServiceRefsToInstances<
T extends { [key in string]: ServiceRef<unknown> },
TScope extends 'root' | 'plugin' = 'root' | 'plugin',
> = {
[name in {
[key in keyof T]: T[key] extends ServiceRef<unknown, TScope> ? key : never;
}[keyof T]]: T[name] extends ServiceRef<infer TImpl> ? TImpl : never;
};
/**
* @public
*/
export function createServiceFactory<
TService,
TScope extends 'root' | 'plugin',
TImpl extends TService,
TDeps extends { [name in string]: unknown },
TDeps extends { [name in string]: ServiceRef<unknown> },
TOpts extends { [name in string]: unknown } | undefined = undefined,
>(factory: {
service: ServiceRef<TService>;
deps: TypesToServiceRef<TDeps>;
>(config: {
service: ServiceRef<TService, TScope>;
deps: TDeps;
factory(
deps: DepsToDepFactories<TDeps>,
deps: ServiceRefsToInstances<TDeps, 'root'>,
options: TOpts,
): Promise<FactoryFunc<TImpl>>;
): TScope extends 'root'
? Promise<TImpl>
: Promise<(deps: ServiceRefsToInstances<TDeps>) => Promise<TImpl>>;
}): undefined extends TOpts
? (options?: TOpts) => ServiceFactory<TService>
: (options: TOpts) => ServiceFactory<TService> {
return (options?: TOpts) => ({
service: factory.service,
deps: factory.deps,
factory(deps: DepsToDepFactories<TDeps>) {
return factory.factory(deps, options!);
},
});
return (options?: TOpts) =>
({
scope: config.service.scope,
service: config.service,
deps: config.deps,
factory(deps: ServiceRefsToInstances<TDeps, 'root'>) {
return config.factory(deps, options!);
},
} as ServiceFactory<TService>);
}
-4
View File
@@ -287,13 +287,10 @@ export async function loadConfig(
let handle: NodeJS.Timeout | undefined;
try {
handle = setInterval(async () => {
console.info(`Checking for config update`);
const newRemoteConfigs = await loadRemoteConfigFiles();
if (await hasConfigChanged(remoteConfigs, newRemoteConfigs)) {
remoteConfigs = newRemoteConfigs;
console.info(`Remote config change, reloading config ...`);
watchProp.onChange([...remoteConfigs, ...fileConfigs, ...envConfigs]);
console.info(`Remote config reloaded`);
}
}, remoteProp.reloadIntervalSeconds * 1000);
} catch (error) {
@@ -303,7 +300,6 @@ export async function loadConfig(
if (watchProp.stopSignal) {
watchProp.stopSignal.then(() => {
if (handle !== undefined) {
console.info(`Stopping remote config watch`);
clearInterval(handle);
handle = undefined;
}
@@ -251,7 +251,6 @@ describe.each(['beta', 'stable'])('react-router %s', rrVersion => {
await new Promise(r => setTimeout(r, 500));
rendered.debug();
await expect(
rendered.findByText('Path at inside: /foo/bar'),
).resolves.toBeInTheDocument();
@@ -344,7 +343,6 @@ describe.each(['beta', 'stable'])('react-router %s', rrVersion => {
await expect(
rendered.findByText('Path at inside: /foo/blob/baz'),
).resolves.toBeInTheDocument();
rendered.debug();
});
it('should throw errors for routing to other routeRefs with unsupported parameters', () => {
@@ -352,6 +350,7 @@ describe.each(['beta', 'stable'])('react-router %s', rrVersion => {
const root = (
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={<div />} />
<Route path="foo:id" element={<ExtensionPage3 />}>
<Route
path="bar"
@@ -294,13 +294,13 @@ describe('discovery', () => {
await expect(
rendered.findByText('Path at inside: /foo/blob/baz'),
).resolves.toBeInTheDocument();
rendered.debug();
});
it('should throw errors for routing to other routeRefs with unsupported parameters', () => {
const root = (
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={<div />} />
<Route path="foo:id" element={<ExtensionPage3 />}>
<Route
path="bar"
@@ -38,7 +38,7 @@ const id = {
const setEdge = jest.fn();
const renderElement = jest.fn((props: RenderLabelProps) => (
<text>{props.edge.label}</text>
<div>{props.edge.label}</div>
));
const minProps = {
@@ -53,8 +53,7 @@ const edgeWithLabel = { ...edge, label };
describe('<Edge />', () => {
beforeEach(() => {
// jsdom does not support SVG elements so we have to fall back to HTMLUnknownElement
Object.defineProperty(window.HTMLUnknownElement.prototype, 'getBBox', {
Object.defineProperty(window.SVGElement.prototype, 'getBBox', {
value: () => ({ width: 100, height: 100 }),
configurable: true,
});
@@ -63,26 +62,40 @@ describe('<Edge />', () => {
afterEach(jest.clearAllMocks);
it('does not render the supplied label element if label is missing', () => {
const { container } = render(<Edge {...minProps} />);
const { container } = render(
<svg>
<Edge {...minProps} />
</svg>,
);
expect(container.getElementsByTagName('g')).toHaveLength(0);
});
it('renders the supplied label element if label is present', () => {
const { getByText } = render(<Edge {...minProps} edge={edgeWithLabel} />);
const { getByText } = render(
<svg>
<Edge {...minProps} edge={edgeWithLabel} />
</svg>,
);
expect(getByText(label)).toBeInTheDocument();
});
it('passes down edge properties to the render method if label is present', () => {
const edgeWithRandomProp = { ...edge, label, randomProp: true };
render(
<Edge {...minProps} render={renderElement} edge={edgeWithRandomProp} />,
<svg>
<Edge {...minProps} render={renderElement} edge={edgeWithRandomProp} />
</svg>,
);
expect(renderElement).toHaveBeenCalledWith({ edge: edgeWithRandomProp });
});
it('calls setEdge with edge ID and actual label size after rendering', () => {
const { getByText } = render(<Edge {...minProps} edge={edgeWithLabel} />);
const { getByText } = render(
<svg>
<Edge {...minProps} edge={edgeWithLabel} />
</svg>,
);
expect(getByText(label)).toBeInTheDocument();
// Updates the edge in the graph
@@ -23,7 +23,7 @@ import { RenderNodeProps } from './types';
const node = { id: 'abc', x: 0, y: 0, width: 0, height: 0 };
const setNode = jest.fn(() => new dagre.graphlib.Graph());
const renderElement = jest.fn((props: RenderNodeProps) => (
<text>{props.node.id}</text>
<div>{props.node.id}</div>
));
const minProps = {
@@ -34,8 +34,7 @@ const minProps = {
describe('<Node />', () => {
beforeEach(() => {
// jsdom does not support SVG elements so we have to fall back to HTMLUnknownElement
Object.defineProperty(window.HTMLUnknownElement.prototype, 'getBBox', {
Object.defineProperty(window.SVGElement.prototype, 'getBBox', {
value: () => ({ width: 100, height: 100 }),
configurable: true,
});
@@ -44,19 +43,31 @@ describe('<Node />', () => {
afterEach(jest.clearAllMocks);
it('renders the supplied element', () => {
const { getByText } = render(<Node {...minProps} />);
const { getByText } = render(
<svg>
<Node {...minProps} />
</svg>,
);
expect(getByText(minProps.node.id)).toBeInTheDocument();
});
it('passes down node properties to the render method', () => {
const nodeWithRandomProp = { ...node, randomProp: true };
render(<Node {...minProps} node={nodeWithRandomProp} />);
render(
<svg>
<Node {...minProps} node={nodeWithRandomProp} />
</svg>,
);
expect(renderElement).toHaveBeenCalledWith({ node: nodeWithRandomProp });
});
it('calls setNode with node ID and actual size after rendering', () => {
const { getByText } = render(<Node {...minProps} />);
const { getByText } = render(
<svg>
<Node {...minProps} />
</svg>,
);
expect(getByText(minProps.node.id)).toBeInTheDocument();
// Updates the node in the graph
@@ -15,7 +15,7 @@
*/
import React from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import { renderInTestApp, withLogCollector } from '@backstage/test-utils';
import { GaugeCard } from './GaugeCard';
@@ -40,7 +40,12 @@ describe('<GaugeCard />', () => {
it('handles invalid numbers', async () => {
const badProps = { title: 'Tingle upgrade', progress: 'hejjo' } as any;
const { getByText } = await renderInTestApp(<GaugeCard {...badProps} />);
expect(getByText(/N\/A.*/)).toBeInTheDocument();
const { error } = await withLogCollector(async () => {
const { getByText } = await renderInTestApp(<GaugeCard {...badProps} />);
expect(getByText(/N\/A.*/)).toBeInTheDocument();
});
expect(error).toEqual([
expect.stringMatching(/^Warning: `NaN` is an invalid value/),
]);
});
});
@@ -58,6 +58,7 @@ describe('<HeaderTabs />', () => {
const TextualBadge = React.forwardRef<HTMLSpanElement>((props, ref) => (
<Badge
classes={useStyles()}
overlap="rectangular"
color="secondary"
badgeContent="three new alarms"
>
@@ -14,7 +14,11 @@
* limitations under the License.
*/
import { renderWithEffects, TestApiProvider } from '@backstage/test-utils';
import {
renderWithEffects,
TestApiProvider,
withLogCollector,
} from '@backstage/test-utils';
import { HomepageTimer } from './HomepageTimer';
import React from 'react';
import { lightTheme } from '@backstage/theme';
@@ -35,13 +39,18 @@ it('changes default timezone to GMT', async () => {
context: 'test',
});
const rendered = await renderWithEffects(
<ThemeProvider theme={lightTheme}>
<TestApiProvider apis={[[configApiRef, configApi]]}>
<HomepageTimer />
</TestApiProvider>
</ThemeProvider>,
);
const { warn } = await withLogCollector(async () => {
const rendered = await renderWithEffects(
<ThemeProvider theme={lightTheme}>
<TestApiProvider apis={[[configApiRef, configApi]]}>
<HomepageTimer />
</TestApiProvider>
</ThemeProvider>,
);
expect(rendered.getByText('GMT')).toBeInTheDocument();
expect(rendered.getByText('GMT')).toBeInTheDocument();
});
expect(warn).toEqual([
'The timezone America/New_Pork is invalid. Defaulting to GMT',
]);
});
@@ -61,11 +61,19 @@ describe('extensions', () => {
const Component = () => <div />;
const routeRef = createRouteRef({ id: 'foo' });
const extension1 = createComponentExtension({
component: {
sync: Component,
},
let extension1: ReturnType<typeof createComponentExtension>;
const { warn } = withLogCollector(['warn'], () => {
extension1 = createComponentExtension({
component: {
sync: Component,
},
});
});
expect(warn).toEqual([
expect.stringMatching(
/^Declaring extensions without name is DEPRECATED. /,
),
]);
const extension2 = createRoutableExtension({
name: 'Extension2',
@@ -73,7 +81,7 @@ describe('extensions', () => {
mountPoint: routeRef,
});
const ExtensionComponent1 = plugin.provide(extension1);
const ExtensionComponent1 = plugin.provide(extension1!);
const ExtensionComponent2 = plugin.provide(extension2);
const element1 = <ExtensionComponent1 />;
@@ -19,6 +19,7 @@ import mockFs from 'mock-fs';
import child_process from 'child_process';
import path from 'path';
import {
Task,
buildAppTask,
checkAppExistsTask,
checkPathExistsTask,
@@ -27,6 +28,13 @@ import {
templatingTask,
} from './tasks';
jest.spyOn(Task, 'log').mockReturnValue(undefined);
jest.spyOn(Task, 'error').mockReturnValue(undefined);
jest.spyOn(Task, 'section').mockReturnValue(undefined);
jest
.spyOn(Task, 'forItem')
.mockImplementation((_a, _b, taskFunc) => taskFunc());
jest.mock('child_process');
// By mocking this the filesystem mocks won't mess with reading all of the package.jsons
@@ -54,6 +54,8 @@ catalog:
# and for the syntax https://docs.microsoft.com/en-us/graph/query-parameters#filter-parameter
# This and userGroupMemberFilter are mutually exclusive, only one can be specified
filter: accountEnabled eq true and userType eq 'member'
# See https://docs.microsoft.com/en-us/graph/api/resources/schemaextension?view=graph-rest-1.0
select: ['id', 'displayName', 'description']
# Optional configuration block
userGroupMember:
# Optional filter for users, use group membership to get users.
@@ -193,6 +193,7 @@ export type MicrosoftGraphProviderConfig = {
clientId?: string;
clientSecret?: string;
userFilter?: string;
userSelect?: string[];
userExpand?: string;
userGroupMemberFilter?: string;
userGroupMemberSearch?: string;
@@ -232,6 +233,7 @@ export function readMicrosoftGraphOrg(
options: {
userExpand?: string;
userFilter?: string;
userSelect?: string[];
userGroupMemberSearch?: string;
userGroupMemberFilter?: string;
groupExpand?: string;
+6
View File
@@ -74,6 +74,12 @@ export interface Config {
* E.g. "securityEnabled eq false and mailEnabled eq true"
*/
groupFilter?: string;
/**
* The fields to be fetched on query.
*
* E.g. ["id", "displayName", "description"]
*/
userSelect?: string[];
/**
* The search criteria to apply to extract users by groups memberships.
*
@@ -62,6 +62,12 @@ export type MicrosoftGraphProviderConfig = {
* E.g. "accountEnabled eq true and userType eq 'member'"
*/
userFilter?: string;
/**
* The fields to be fetched on query.
*
* E.g. ["id", "displayName", "description"]
*/
userSelect?: string[];
/**
* The "expand" argument to apply to users.
*
@@ -144,6 +150,7 @@ export function readMicrosoftGraphConfig(
const userExpand = providerConfig.getOptionalString('userExpand');
const userFilter = providerConfig.getOptionalString('userFilter');
const userSelect = providerConfig.getOptionalStringArray('userSelect');
const userGroupMemberFilter = providerConfig.getOptionalString(
'userGroupMemberFilter',
);
@@ -196,6 +203,7 @@ export function readMicrosoftGraphConfig(
clientSecret,
userExpand,
userFilter,
userSelect,
userGroupMemberFilter,
userGroupMemberSearch,
groupExpand,
@@ -1002,6 +1002,12 @@ describe('read microsoft graph', () => {
};
}
async function* getExampleUsersEmail() {
yield {
mail: 'user.name@example.com',
};
}
async function getExampleUserProfile(userId: string) {
return {
id: userId,
@@ -1109,6 +1115,34 @@ describe('read microsoft graph', () => {
);
});
it('should read users with userSelect', async () => {
client.getOrganization.mockResolvedValue({
id: 'tenantid',
displayName: 'Organization Name',
});
client.getUsers.mockImplementation(getExampleUsersEmail);
client.getUserPhotoWithSizeLimit.mockResolvedValue(
'data:image/jpeg;base64,...',
);
client.getGroups.mockImplementation(getExampleGroups);
client.getGroupMembers.mockImplementation(getExampleGroupMembers);
await readMicrosoftGraphOrg(client, 'tenantid', {
logger: getVoidLogger(),
userSelect: ['mail'],
});
expect(client.getUsers).toHaveBeenCalledTimes(1);
expect(client.getUsers).toHaveBeenCalledWith(
{
select: ['mail'],
},
undefined,
);
});
it('should read users using userExpand and userGroupMemberFilter', async () => {
client.getOrganization.mockResolvedValue({
id: 'tenantid',
@@ -89,6 +89,7 @@ export async function readMicrosoftGraphUsers(
queryMode?: 'basic' | 'advanced';
userFilter?: string;
userExpand?: string;
userSelect?: string[];
transformer?: UserTransformer;
logger: Logger;
},
@@ -105,6 +106,7 @@ export async function readMicrosoftGraphUsers(
{
filter: options.userFilter,
expand: options.userExpand,
select: options.userSelect,
},
options.queryMode,
)) {
@@ -145,6 +147,7 @@ export async function readMicrosoftGraphUsersInGroups(
options: {
queryMode?: 'basic' | 'advanced';
userExpand?: string;
userSelect?: string[];
userGroupMemberSearch?: string;
userGroupMemberFilter?: string;
groupExpand?: string;
@@ -534,6 +537,7 @@ export async function readMicrosoftGraphOrg(
options: {
userExpand?: string;
userFilter?: string;
userSelect?: string[];
userGroupMemberSearch?: string;
userGroupMemberFilter?: string;
groupExpand?: string;
@@ -565,6 +569,7 @@ export async function readMicrosoftGraphOrg(
const { users: usersWithFilter } = await readMicrosoftGraphUsers(client, {
queryMode: options.queryMode,
userFilter: options.userFilter,
userSelect: options.userSelect,
userExpand: options.userExpand,
transformer: options.userTransformer,
logger: options.logger,
+1 -1
View File
@@ -109,7 +109,7 @@ export type CatalogProcessorResult =
| CatalogProcessorRefreshKeysResult;
// @alpha
export const catalogServiceRef: ServiceRef<CatalogApi>;
export const catalogServiceRef: ServiceRef<CatalogApi, 'plugin'>;
// @public
export type DeferredEntity = {
+4 -6
View File
@@ -31,13 +31,11 @@ export const catalogServiceRef = createServiceRef<CatalogApi>({
createServiceFactory({
service,
deps: {
discoveryFactory: discoveryServiceRef,
discoveryApi: discoveryServiceRef,
},
factory: async ({ discoveryFactory }) => {
const discoveryApi = await discoveryFactory('root');
const catalogClient = new CatalogClient({ discoveryApi });
return async _pluginId => {
return catalogClient;
async factory() {
return async ({ discoveryApi }) => {
return new CatalogClient({ discoveryApi });
};
},
}),
+1
View File
@@ -436,6 +436,7 @@ export function humanizeEntityRef(
entityRef: Entity | CompoundEntityRef,
opts?: {
defaultKind?: string;
defaultNamespace?: string | false;
},
): string;
@@ -34,6 +34,23 @@ describe('humanizeEntityRef', () => {
expect(title).toEqual('component:software');
});
it('formats entity in default namespace without skipping default namespace', () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
const title = humanizeEntityRef(entity, { defaultNamespace: false });
expect(title).toEqual('component:default/software');
});
it('formats entity in other namespace', () => {
const entity = {
apiVersion: 'v1',
@@ -52,6 +69,24 @@ describe('humanizeEntityRef', () => {
expect(title).toEqual('component:test/software');
});
it('formats entity in other namespace and hides this namespace', () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
namespace: 'test',
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
const title = humanizeEntityRef(entity, { defaultNamespace: 'test' });
expect(title).toEqual('component:software');
});
it('formats entity and hides default kind', () => {
const entity = {
apiVersion: 'v1',
@@ -70,6 +105,27 @@ describe('humanizeEntityRef', () => {
expect(title).toEqual('test/software');
});
it('formats entity and hides default kind and hiding namespace', () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
namespace: 'test',
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
const title = humanizeEntityRef(entity, {
defaultKind: 'Component',
defaultNamespace: 'test',
});
expect(title).toEqual('software');
});
it('formats entity name in default namespace', () => {
const entityName = {
kind: 'Component',
@@ -80,6 +136,16 @@ describe('humanizeEntityRef', () => {
expect(title).toEqual('component:software');
});
it('formats entity name in default namespace and does not skip default namespace', () => {
const entityName = {
kind: 'Component',
namespace: 'default',
name: 'software',
};
const title = humanizeEntityRef(entityName, { defaultNamespace: false });
expect(title).toEqual('component:default/software');
});
it('formats entity name in other namespace', () => {
const entityName = {
kind: 'Component',
@@ -91,6 +157,19 @@ describe('humanizeEntityRef', () => {
expect(title).toEqual('component:test/software');
});
it('formats entity name in other namespace with skipping this namespace', () => {
const entityName = {
kind: 'Component',
namespace: 'test',
name: 'software',
};
const title = humanizeEntityRef(entityName, {
defaultNamespace: 'test',
});
expect(title).toEqual('component:software');
});
it('renders link for entity name and hides default kind', () => {
const entityName = {
kind: 'Component',
@@ -103,4 +182,31 @@ describe('humanizeEntityRef', () => {
});
expect(title).toEqual('test/software');
});
it('renders link for entity name and hides default kind with skipping namespace', () => {
const entityName = {
kind: 'Component',
namespace: 'test',
name: 'software',
};
const title = humanizeEntityRef(entityName, {
defaultKind: 'component',
defaultNamespace: 'test',
});
expect(title).toEqual('software');
});
it('formats entity name in default namespace without skip of default namespace', () => {
const entityName = {
kind: 'Component',
namespace: 'default',
name: 'software',
};
const title = humanizeEntityRef(entityName, {
defaultNamespace: false,
});
expect(title).toEqual('component:default/software');
});
});
@@ -20,10 +20,17 @@ import {
DEFAULT_NAMESPACE,
} from '@backstage/catalog-model';
/** @public */
/**
* @param defaultNamespace - if set to false then namespace is never omitted,
* if set to string which matches namespace of entity then omitted
*
* @public */
export function humanizeEntityRef(
entityRef: Entity | CompoundEntityRef,
opts?: { defaultKind?: string },
opts?: {
defaultKind?: string;
defaultNamespace?: string | false;
},
) {
const defaultKind = opts?.defaultKind;
let kind;
@@ -40,7 +47,14 @@ export function humanizeEntityRef(
name = entityRef.name;
}
if (namespace === DEFAULT_NAMESPACE) {
if (namespace === undefined || namespace === '') {
namespace = DEFAULT_NAMESPACE;
}
if (opts?.defaultNamespace !== undefined) {
if (opts?.defaultNamespace === namespace) {
namespace = undefined;
}
} else if (namespace === DEFAULT_NAMESPACE) {
namespace = undefined;
}
+4
View File
@@ -76,8 +76,12 @@ export const EntityTagsPickerFieldExtension: FieldExtensionComponent<
// @public
export interface EntityTagsPickerUiOptions {
// (undocumented)
helperText?: string;
// (undocumented)
kinds?: string[];
// (undocumented)
showCounts?: boolean;
}
// @public
@@ -16,8 +16,8 @@
import React, { useState } from 'react';
import useAsync from 'react-use/lib/useAsync';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { GetEntitiesRequest } from '@backstage/catalog-client';
import { Entity, makeValidator } from '@backstage/catalog-model';
import { GetEntityFacetsRequest } from '@backstage/catalog-client';
import { makeValidator } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { FormControl, TextField } from '@material-ui/core';
@@ -32,6 +32,8 @@ import { FieldExtensionComponentProps } from '../../../extensions';
*/
export interface EntityTagsPickerUiOptions {
kinds?: string[];
showCounts?: boolean;
helperText?: string;
}
/**
@@ -45,26 +47,34 @@ export const EntityTagsPicker = (
) => {
const { formData, onChange, uiSchema } = props;
const catalogApi = useApi(catalogApiRef);
const [tagOptions, setTagOptions] = useState<string[]>([]);
const [inputValue, setInputValue] = useState('');
const [inputError, setInputError] = useState(false);
const tagValidator = makeValidator().isValidTag;
const kinds = uiSchema['ui:options']?.kinds;
const showCounts = uiSchema['ui:options']?.showCounts;
const helperText = uiSchema['ui:options']?.helperText;
const { loading, value: existingTags } = useAsync(async () => {
const tagsRequest: GetEntitiesRequest = { fields: ['metadata.tags'] };
const facet = 'metadata.tags';
const tagsRequest: GetEntityFacetsRequest = { facets: [facet] };
if (kinds) {
tagsRequest.filter = { kind: kinds };
}
const entities = await catalogApi.getEntities(tagsRequest);
const { facets } = await catalogApi.getEntityFacets(tagsRequest);
return [
...new Set(
entities.items
.flatMap((e: Entity) => e.metadata?.tags)
.filter(Boolean) as string[],
const tagFacets = Object.fromEntries(
facets[facet].map(({ value, count }) => [value, count]),
);
setTagOptions(
Object.keys(tagFacets).sort((a, b) =>
showCounts ? tagFacets[b] - tagFacets[a] : a.localeCompare(b),
),
].sort();
);
return tagFacets;
});
const setTags = (_: React.ChangeEvent<{}>, values: string[] | null) => {
@@ -102,15 +112,21 @@ export const EntityTagsPicker = (
value={formData || []}
inputValue={inputValue}
loading={loading}
options={existingTags || []}
options={tagOptions}
ChipProps={{ size: 'small' }}
renderOption={option =>
showCounts ? `${option} (${existingTags?.[option]})` : option
}
renderInput={params => (
<TextField
{...params}
label="Tags"
onChange={e => setInputValue(e.target.value)}
error={inputError}
helperText="Add any relevant tags, hit 'Enter' to add new tags. Valid format: [a-z0-9+#] separated by [-], at most 63 characters"
helperText={
helperText ??
"Add any relevant tags, hit 'Enter' to add new tags. Valid format: [a-z0-9+#] separated by [-], at most 63 characters"
}
/>
)}
/>
+55 -55
View File
@@ -2866,126 +2866,126 @@ __metadata:
languageName: node
linkType: hard
"@swc/core-android-arm-eabi@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-android-arm-eabi@npm:1.2.247"
"@swc/core-android-arm-eabi@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-android-arm-eabi@npm:1.2.249"
dependencies:
"@swc/wasm": 1.2.122
conditions: os=android & cpu=arm
languageName: node
linkType: hard
"@swc/core-android-arm64@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-android-arm64@npm:1.2.247"
"@swc/core-android-arm64@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-android-arm64@npm:1.2.249"
dependencies:
"@swc/wasm": 1.2.130
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
"@swc/core-darwin-arm64@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-darwin-arm64@npm:1.2.247"
"@swc/core-darwin-arm64@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-darwin-arm64@npm:1.2.249"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@swc/core-darwin-x64@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-darwin-x64@npm:1.2.247"
"@swc/core-darwin-x64@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-darwin-x64@npm:1.2.249"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@swc/core-freebsd-x64@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-freebsd-x64@npm:1.2.247"
"@swc/core-freebsd-x64@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-freebsd-x64@npm:1.2.249"
dependencies:
"@swc/wasm": 1.2.130
conditions: os=freebsd & cpu=x64
languageName: node
linkType: hard
"@swc/core-linux-arm-gnueabihf@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.2.247"
"@swc/core-linux-arm-gnueabihf@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.2.249"
dependencies:
"@swc/wasm": 1.2.130
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"@swc/core-linux-arm64-gnu@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-linux-arm64-gnu@npm:1.2.247"
"@swc/core-linux-arm64-gnu@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-linux-arm64-gnu@npm:1.2.249"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@swc/core-linux-arm64-musl@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-linux-arm64-musl@npm:1.2.247"
"@swc/core-linux-arm64-musl@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-linux-arm64-musl@npm:1.2.249"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@swc/core-linux-x64-gnu@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-linux-x64-gnu@npm:1.2.247"
"@swc/core-linux-x64-gnu@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-linux-x64-gnu@npm:1.2.249"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@swc/core-linux-x64-musl@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-linux-x64-musl@npm:1.2.247"
"@swc/core-linux-x64-musl@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-linux-x64-musl@npm:1.2.249"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@swc/core-win32-arm64-msvc@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-win32-arm64-msvc@npm:1.2.247"
"@swc/core-win32-arm64-msvc@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-win32-arm64-msvc@npm:1.2.249"
dependencies:
"@swc/wasm": 1.2.130
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@swc/core-win32-ia32-msvc@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-win32-ia32-msvc@npm:1.2.247"
"@swc/core-win32-ia32-msvc@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-win32-ia32-msvc@npm:1.2.249"
dependencies:
"@swc/wasm": 1.2.130
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@swc/core-win32-x64-msvc@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-win32-x64-msvc@npm:1.2.247"
"@swc/core-win32-x64-msvc@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-win32-x64-msvc@npm:1.2.249"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@swc/core@npm:^1.2.239":
version: 1.2.247
resolution: "@swc/core@npm:1.2.247"
version: 1.2.249
resolution: "@swc/core@npm:1.2.249"
dependencies:
"@swc/core-android-arm-eabi": 1.2.247
"@swc/core-android-arm64": 1.2.247
"@swc/core-darwin-arm64": 1.2.247
"@swc/core-darwin-x64": 1.2.247
"@swc/core-freebsd-x64": 1.2.247
"@swc/core-linux-arm-gnueabihf": 1.2.247
"@swc/core-linux-arm64-gnu": 1.2.247
"@swc/core-linux-arm64-musl": 1.2.247
"@swc/core-linux-x64-gnu": 1.2.247
"@swc/core-linux-x64-musl": 1.2.247
"@swc/core-win32-arm64-msvc": 1.2.247
"@swc/core-win32-ia32-msvc": 1.2.247
"@swc/core-win32-x64-msvc": 1.2.247
"@swc/core-android-arm-eabi": 1.2.249
"@swc/core-android-arm64": 1.2.249
"@swc/core-darwin-arm64": 1.2.249
"@swc/core-darwin-x64": 1.2.249
"@swc/core-freebsd-x64": 1.2.249
"@swc/core-linux-arm-gnueabihf": 1.2.249
"@swc/core-linux-arm64-gnu": 1.2.249
"@swc/core-linux-arm64-musl": 1.2.249
"@swc/core-linux-x64-gnu": 1.2.249
"@swc/core-linux-x64-musl": 1.2.249
"@swc/core-win32-arm64-msvc": 1.2.249
"@swc/core-win32-ia32-msvc": 1.2.249
"@swc/core-win32-x64-msvc": 1.2.249
dependenciesMeta:
"@swc/core-android-arm-eabi":
optional: true
@@ -3015,7 +3015,7 @@ __metadata:
optional: true
bin:
swcx: run_swcx.js
checksum: 8ad850c5637405473cb7680865c0b1bdd9e955c0f32e86e6564d43c6a3fb0941b69d7ca152b661694e614530c69fe9824f32d10b606564187730d1d146f62805
checksum: c47f17fefccd94fb7be3787e832f3e3fd62c07e22f8bf566435bd1de032efd9fdefb4a64e0eefd684a9a24902509a235afeacb5ed93e03512fa09064bf1e1268
languageName: node
linkType: hard
+55 -55
View File
@@ -13047,126 +13047,126 @@ __metadata:
languageName: node
linkType: hard
"@swc/core-android-arm-eabi@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-android-arm-eabi@npm:1.2.247"
"@swc/core-android-arm-eabi@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-android-arm-eabi@npm:1.2.249"
dependencies:
"@swc/wasm": 1.2.122
conditions: os=android & cpu=arm
languageName: node
linkType: hard
"@swc/core-android-arm64@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-android-arm64@npm:1.2.247"
"@swc/core-android-arm64@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-android-arm64@npm:1.2.249"
dependencies:
"@swc/wasm": 1.2.130
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
"@swc/core-darwin-arm64@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-darwin-arm64@npm:1.2.247"
"@swc/core-darwin-arm64@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-darwin-arm64@npm:1.2.249"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@swc/core-darwin-x64@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-darwin-x64@npm:1.2.247"
"@swc/core-darwin-x64@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-darwin-x64@npm:1.2.249"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@swc/core-freebsd-x64@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-freebsd-x64@npm:1.2.247"
"@swc/core-freebsd-x64@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-freebsd-x64@npm:1.2.249"
dependencies:
"@swc/wasm": 1.2.130
conditions: os=freebsd & cpu=x64
languageName: node
linkType: hard
"@swc/core-linux-arm-gnueabihf@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.2.247"
"@swc/core-linux-arm-gnueabihf@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.2.249"
dependencies:
"@swc/wasm": 1.2.130
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"@swc/core-linux-arm64-gnu@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-linux-arm64-gnu@npm:1.2.247"
"@swc/core-linux-arm64-gnu@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-linux-arm64-gnu@npm:1.2.249"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@swc/core-linux-arm64-musl@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-linux-arm64-musl@npm:1.2.247"
"@swc/core-linux-arm64-musl@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-linux-arm64-musl@npm:1.2.249"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@swc/core-linux-x64-gnu@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-linux-x64-gnu@npm:1.2.247"
"@swc/core-linux-x64-gnu@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-linux-x64-gnu@npm:1.2.249"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@swc/core-linux-x64-musl@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-linux-x64-musl@npm:1.2.247"
"@swc/core-linux-x64-musl@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-linux-x64-musl@npm:1.2.249"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@swc/core-win32-arm64-msvc@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-win32-arm64-msvc@npm:1.2.247"
"@swc/core-win32-arm64-msvc@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-win32-arm64-msvc@npm:1.2.249"
dependencies:
"@swc/wasm": 1.2.130
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@swc/core-win32-ia32-msvc@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-win32-ia32-msvc@npm:1.2.247"
"@swc/core-win32-ia32-msvc@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-win32-ia32-msvc@npm:1.2.249"
dependencies:
"@swc/wasm": 1.2.130
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@swc/core-win32-x64-msvc@npm:1.2.247":
version: 1.2.247
resolution: "@swc/core-win32-x64-msvc@npm:1.2.247"
"@swc/core-win32-x64-msvc@npm:1.2.249":
version: 1.2.249
resolution: "@swc/core-win32-x64-msvc@npm:1.2.249"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@swc/core@npm:^1.2.239":
version: 1.2.247
resolution: "@swc/core@npm:1.2.247"
version: 1.2.249
resolution: "@swc/core@npm:1.2.249"
dependencies:
"@swc/core-android-arm-eabi": 1.2.247
"@swc/core-android-arm64": 1.2.247
"@swc/core-darwin-arm64": 1.2.247
"@swc/core-darwin-x64": 1.2.247
"@swc/core-freebsd-x64": 1.2.247
"@swc/core-linux-arm-gnueabihf": 1.2.247
"@swc/core-linux-arm64-gnu": 1.2.247
"@swc/core-linux-arm64-musl": 1.2.247
"@swc/core-linux-x64-gnu": 1.2.247
"@swc/core-linux-x64-musl": 1.2.247
"@swc/core-win32-arm64-msvc": 1.2.247
"@swc/core-win32-ia32-msvc": 1.2.247
"@swc/core-win32-x64-msvc": 1.2.247
"@swc/core-android-arm-eabi": 1.2.249
"@swc/core-android-arm64": 1.2.249
"@swc/core-darwin-arm64": 1.2.249
"@swc/core-darwin-x64": 1.2.249
"@swc/core-freebsd-x64": 1.2.249
"@swc/core-linux-arm-gnueabihf": 1.2.249
"@swc/core-linux-arm64-gnu": 1.2.249
"@swc/core-linux-arm64-musl": 1.2.249
"@swc/core-linux-x64-gnu": 1.2.249
"@swc/core-linux-x64-musl": 1.2.249
"@swc/core-win32-arm64-msvc": 1.2.249
"@swc/core-win32-ia32-msvc": 1.2.249
"@swc/core-win32-x64-msvc": 1.2.249
dependenciesMeta:
"@swc/core-android-arm-eabi":
optional: true
@@ -13196,7 +13196,7 @@ __metadata:
optional: true
bin:
swcx: run_swcx.js
checksum: 8ad850c5637405473cb7680865c0b1bdd9e955c0f32e86e6564d43c6a3fb0941b69d7ca152b661694e614530c69fe9824f32d10b606564187730d1d146f62805
checksum: c47f17fefccd94fb7be3787e832f3e3fd62c07e22f8bf566435bd1de032efd9fdefb4a64e0eefd684a9a24902509a235afeacb5ed93e03512fa09064bf1e1268
languageName: node
linkType: hard