mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-12 16:35:45 +01:00
Implement api for extension point typings (#1638)
Currently, the only way to explore available extension points is through our documentation or by browsing the source code. Once you find them, there is no guard rails and the usage is prone to user errors. This new api allows the declaration of extension points as types in code. This way, exposing an extension point is as easy as exporting it from a module. Both the implementation and the developer who uses the extension point work with the same shared type that allows auto-completion and type-checks for safety. This feature is backwards-compatible as the generic methods all have sensible defaults for the type parameters. Co-authored-by: Sebastian Sdorra <sebastian.sdorra@cloudogu.com> Co-authored-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
This commit is contained in:
committed by
GitHub
parent
b6b304f338
commit
7286a62a80
@@ -22,7 +22,7 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import { Binder } from "./binder";
|
||||
import { Binder, ExtensionPointDefinition, SimpleDynamicExtensionPointDefinition } from "./binder";
|
||||
|
||||
describe("binder tests", () => {
|
||||
let binder: Binder;
|
||||
@@ -99,4 +99,48 @@ describe("binder tests", () => {
|
||||
expect(extensions[0]).toEqual("planetD");
|
||||
expect(extensions[1]).toEqual("planetB");
|
||||
});
|
||||
|
||||
it("should allow typings for extension points but still be backwards-compatible", () => {
|
||||
type TestExtensionPointA = ExtensionPointDefinition<"test.extension.a", number, undefined>;
|
||||
type TestExtensionPointB = ExtensionPointDefinition<"test.extension.b", number, { testProp: boolean[] }>;
|
||||
|
||||
binder.bind<TestExtensionPointA>("test.extension.a", 2, () => false);
|
||||
const binderExtensionA = binder.getExtension<TestExtensionPointA>("test.extension.a");
|
||||
expect(binderExtensionA).not.toBeNull();
|
||||
binder.bind<TestExtensionPointB>("test.extension.b", 2);
|
||||
const binderExtensionsB = binder.getExtensions<TestExtensionPointB>("test.extension.b", {
|
||||
testProp: [true, false]
|
||||
});
|
||||
expect(binderExtensionsB).toHaveLength(1);
|
||||
binder.bind("test.extension.c", 2, () => false);
|
||||
const binderExtensionC = binder.getExtension("test.extension.c");
|
||||
expect(binderExtensionC).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should allow typings for dynamic extension points", () => {
|
||||
type MarkdownCodeLanguageRendererProps = {
|
||||
language?: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type MarkdownCodeLanguageRendererExtensionPoint<
|
||||
S extends string | undefined = undefined
|
||||
> = SimpleDynamicExtensionPointDefinition<
|
||||
"markdown-renderer.code.",
|
||||
(props: any) => any,
|
||||
MarkdownCodeLanguageRendererProps,
|
||||
S
|
||||
>;
|
||||
type UmlExtensionPoint = MarkdownCodeLanguageRendererExtensionPoint<"uml">;
|
||||
|
||||
binder.bind<UmlExtensionPoint>("markdown-renderer.code.uml", props => props.value);
|
||||
|
||||
const language = "uml";
|
||||
const extensionPointName = `markdown-renderer.code.${language}` as const;
|
||||
const dynamicExtension = binder.getExtension<MarkdownCodeLanguageRendererExtensionPoint>(extensionPointName, {
|
||||
language: "uml",
|
||||
value: "const a = 2;"
|
||||
});
|
||||
expect(dynamicExtension).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,14 +22,22 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
type Predicate = (props: any) => boolean;
|
||||
type Predicate<P extends Record<any, any> = Record<any, any>> = (props: P) => boolean;
|
||||
|
||||
type ExtensionRegistration = {
|
||||
predicate: Predicate;
|
||||
extension: any;
|
||||
type ExtensionRegistration<P, T> = {
|
||||
predicate: Predicate<P>;
|
||||
extension: T;
|
||||
extensionName: string;
|
||||
};
|
||||
|
||||
export type ExtensionPointDefinition<N extends string, T, P> = {
|
||||
name: N;
|
||||
type: T;
|
||||
props: P;
|
||||
};
|
||||
|
||||
export type SimpleDynamicExtensionPointDefinition<P extends string, T, Props, S extends string | undefined> = ExtensionPointDefinition<S extends string ? `${P}${S}` : `${P}${string}`, T, Props>;
|
||||
|
||||
/**
|
||||
* Binder is responsible for binding plugin extensions to their corresponding extension points.
|
||||
* The Binder class is mainly exported for testing, plugins should only use the default export.
|
||||
@@ -37,7 +45,7 @@ type ExtensionRegistration = {
|
||||
export class Binder {
|
||||
name: string;
|
||||
extensionPoints: {
|
||||
[key: string]: Array<ExtensionRegistration>;
|
||||
[key: string]: Array<ExtensionRegistration<unknown, unknown>>;
|
||||
};
|
||||
|
||||
constructor(name: string) {
|
||||
@@ -52,7 +60,22 @@ export class Binder {
|
||||
* @param extension provided extension
|
||||
* @param predicate to decide if the extension gets rendered for the given props
|
||||
*/
|
||||
bind(extensionPoint: string, extension: any, predicate?: Predicate, extensionName?: string) {
|
||||
bind<E extends ExtensionPointDefinition<string, unknown, undefined>>(
|
||||
extensionPoint: E["name"],
|
||||
extension: E["type"]
|
||||
): void;
|
||||
bind<E extends ExtensionPointDefinition<string, unknown, any>>(
|
||||
extensionPoint: E["name"],
|
||||
extension: E["type"],
|
||||
predicate?: Predicate<E["props"]>,
|
||||
extensionName?: string
|
||||
): void;
|
||||
bind<E extends ExtensionPointDefinition<string, unknown, any>>(
|
||||
extensionPoint: E["name"],
|
||||
extension: E["type"],
|
||||
predicate?: Predicate<E["props"]>,
|
||||
extensionName?: string
|
||||
) {
|
||||
if (!this.extensionPoints[extensionPoint]) {
|
||||
this.extensionPoints[extensionPoint] = [];
|
||||
}
|
||||
@@ -70,7 +93,15 @@ export class Binder {
|
||||
* @param extensionPoint name of extension point
|
||||
* @param props of the extension point
|
||||
*/
|
||||
getExtension(extensionPoint: string, props?: object) {
|
||||
getExtension<E extends ExtensionPointDefinition<string, any, undefined>>(extensionPoint: E["name"]): E["type"] | null;
|
||||
getExtension<E extends ExtensionPointDefinition<string, any, any>>(
|
||||
extensionPoint: E["name"],
|
||||
props: E["props"]
|
||||
): E["type"] | null;
|
||||
getExtension<E extends ExtensionPointDefinition<any, unknown, any>>(
|
||||
extensionPoint: E["name"],
|
||||
props?: E["props"]
|
||||
): E["type"] | null {
|
||||
const extensions = this.getExtensions(extensionPoint, props);
|
||||
if (extensions.length > 0) {
|
||||
return extensions[0];
|
||||
@@ -84,10 +115,20 @@ export class Binder {
|
||||
* @param extensionPoint name of extension point
|
||||
* @param props of the extension point
|
||||
*/
|
||||
getExtensions(extensionPoint: string, props?: object): Array<any> {
|
||||
getExtensions<E extends ExtensionPointDefinition<string, any, undefined>>(
|
||||
extensionPoint: E["name"]
|
||||
): Array<E["type"]>;
|
||||
getExtensions<E extends ExtensionPointDefinition<string, any, any>>(
|
||||
extensionPoint: E["name"],
|
||||
props: E["props"]
|
||||
): Array<E["type"]>;
|
||||
getExtensions<E extends ExtensionPointDefinition<string, unknown, any>>(
|
||||
extensionPoint: E["name"],
|
||||
props?: E["props"]
|
||||
): Array<E["type"]> {
|
||||
let registrations = this.extensionPoints[extensionPoint] || [];
|
||||
if (props) {
|
||||
registrations = registrations.filter(reg => reg.predicate(props || {}));
|
||||
registrations = registrations.filter(reg => reg.predicate(props));
|
||||
}
|
||||
registrations.sort(this.sortExtensions);
|
||||
return registrations.map(reg => reg.extension);
|
||||
@@ -96,14 +137,17 @@ export class Binder {
|
||||
/**
|
||||
* Returns true if at least one extension is bound to the extension point and its props.
|
||||
*/
|
||||
hasExtension(extensionPoint: string, props?: object): boolean {
|
||||
return this.getExtensions(extensionPoint, props).length > 0;
|
||||
hasExtension<E extends ExtensionPointDefinition<any, unknown, any>>(
|
||||
extensionPoint: E["name"],
|
||||
props?: E["props"]
|
||||
): boolean {
|
||||
return this.getExtensions<E>(extensionPoint, props).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort extensions in ascending order, starting with entries with specified extensionName.
|
||||
*/
|
||||
sortExtensions = (a: ExtensionRegistration, b: ExtensionRegistration) => {
|
||||
sortExtensions = (a: ExtensionRegistration<unknown, unknown>, b: ExtensionRegistration<unknown, unknown>) => {
|
||||
const regA = a.extensionName ? a.extensionName.toUpperCase() : "";
|
||||
const regB = b.extensionName ? b.extensionName.toUpperCase() : "";
|
||||
|
||||
|
||||
@@ -22,6 +22,6 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
export { default as binder, Binder } from "./binder";
|
||||
export { default as binder, Binder, ExtensionPointDefinition } from "./binder";
|
||||
export * from "./useBinder";
|
||||
export { default as ExtensionPoint } from "./ExtensionPoint";
|
||||
|
||||
Reference in New Issue
Block a user