Compare commits

...
Author SHA1 Message Date
Niels LyngsøandGitHub 246bb4c33d Merge branch 'main' into v17/feature/hook-api 2026-04-13 19:25:14 +02:00
Niels Lyngsø ac811c2624 hook package + controller + tests 2026-04-11 20:49:42 +02:00
7 changed files with 260 additions and 0 deletions
+1
View File
@@ -14,6 +14,7 @@
"./embedded-media": "./dist-cms/packages/embedded-media/index.js",
"./extension-api": "./dist-cms/libs/extension-api/index.js",
"./extension-types": "./dist-cms/packages/extension-types/index.d.ts",
"./hook-api": "./dist-cms/libs/hook-api/index.js",
"./localization-api": "./dist-cms/libs/localization-api/index.js",
"./observable-api": "./dist-cms/libs/observable-api/index.js",
"./action": "./dist-cms/packages/core/action/index.js",
@@ -0,0 +1,194 @@
import { UmbHookController } from './hook.controller.js';
import { expect } from '@open-wc/testing';
describe('UmbHookController', () => {
type TestData = { value: number; log?: Array<string> };
let hook: UmbHookController<TestData>;
beforeEach(() => {
hook = new UmbHookController<TestData>();
});
it('returns initial data when no hooks are registered', async () => {
const data: TestData = { value: 1 };
const result = await hook.execute(data);
expect(result).to.equal(data);
});
it('single hook transforms data', async () => {
hook.add((data) => ({ ...data, value: data.value + 10 }));
const result = await hook.execute({ value: 1 });
expect(result.value).to.equal(11);
});
it('executes hooks in ascending weight order', async () => {
const log: Array<number> = [];
hook.add((data) => {
log.push(20);
return data;
}, 20);
hook.add((data) => {
log.push(10);
return data;
}, 10);
hook.add((data) => {
log.push(30);
return data;
}, 30);
await hook.execute({ value: 0 });
expect(log).to.deep.equal([10, 20, 30]);
});
it('maintains insertion order for same weight', async () => {
const log: Array<string> = [];
hook.add((data) => {
log.push('first');
return data;
}, 0);
hook.add((data) => {
log.push('second');
return data;
}, 0);
hook.add((data) => {
log.push('third');
return data;
}, 0);
await hook.execute({ value: 0 });
expect(log).to.deep.equal(['first', 'second', 'third']);
});
it('uses default weight of 0', async () => {
const log: Array<string> = [];
hook.add((data) => {
log.push('default');
return data;
});
hook.add((data) => {
log.push('weight-1');
return data;
}, 1);
hook.add((data) => {
log.push('weight-neg1');
return data;
}, -1);
await hook.execute({ value: 0 });
expect(log).to.deep.equal(['weight-neg1', 'default', 'weight-1']);
});
it('awaits async hooks', async () => {
hook.add(async (data) => {
return new Promise<TestData>((resolve) => {
setTimeout(() => resolve({ ...data, value: data.value + 5 }), 10);
});
});
hook.add((data) => ({ ...data, value: data.value * 2 }));
const result = await hook.execute({ value: 1 });
expect(result.value).to.equal(12);
});
it('flows mutated data through the chain', async () => {
hook.add(
(data) => {
data.value = 42;
return data;
},
0,
);
hook.add(
(data) => {
data.log = [`received:${data.value}`];
return data;
},
1,
);
const result = await hook.execute({ value: 0 });
expect(result.value).to.equal(42);
expect(result.log).to.deep.equal(['received:42']);
});
it('short-circuits when a hook throws', async () => {
let thirdCalled = false;
hook.add((data) => ({ ...data, value: 1 }), 0);
hook.add(() => {
throw new Error('cancelled');
}, 1);
hook.add((data) => {
thirdCalled = true;
return data;
}, 2);
try {
await hook.execute({ value: 0 });
expect.fail('should have thrown');
} catch (e) {
expect((e as Error).message).to.equal('cancelled');
}
expect(thirdCalled).to.be.false;
});
it('short-circuits on async rejection', async () => {
let thirdCalled = false;
hook.add((data) => ({ ...data, value: 1 }), 0);
hook.add(async () => {
return Promise.reject(new Error('async cancel'));
}, 1);
hook.add((data) => {
thirdCalled = true;
return data;
}, 2);
try {
await hook.execute({ value: 0 });
expect.fail('should have thrown');
} catch (e) {
expect((e as Error).message).to.equal('async cancel');
}
expect(thirdCalled).to.be.false;
});
it('removes a hook by method reference', async () => {
const addTen = (data: TestData): TestData => ({ ...data, value: data.value + 10 });
const addFive = (data: TestData): TestData => ({ ...data, value: data.value + 5 });
hook.add(addTen);
hook.add(addFive);
hook.remove(addTen);
const result = await hook.execute({ value: 0 });
expect(result.value).to.equal(5);
});
it('destroy clears all hooks', async () => {
hook.add((data) => ({ ...data, value: 999 }));
hook.destroy();
const result = await hook.execute({ value: 1 });
expect(result.value).to.equal(1);
});
});
@@ -0,0 +1,31 @@
import type { UmbHookEntry, UmbHookMethod } from './types.js';
export class UmbHookController<T> {
#entries: Array<UmbHookEntry<T>> = [];
add(method: UmbHookMethod<T>, weight: number = 0): void {
this.#entries.push({ method, weight });
}
remove(method: UmbHookMethod<T>): void {
const index = this.#entries.findIndex((entry) => entry.method === method);
if (index !== -1) {
this.#entries.splice(index, 1);
}
}
async execute(data: T): Promise<T> {
const sortedEntries = [...this.#entries].sort((a, b) => a.weight - b.weight);
const result = await sortedEntries.reduce(async (prev, entry) => {
const current = await prev;
return entry.method(current);
}, Promise.resolve(data));
return result;
}
destroy(): void {
this.#entries = [];
}
}
@@ -0,0 +1,2 @@
export * from './hook.controller.js';
export type * from './types.js';
@@ -0,0 +1,8 @@
{
"name": "@umbraco-backoffice-lib/hook-api",
"private": true,
"type": "module",
"scripts": {
"build": "vite build"
}
}
@@ -0,0 +1,6 @@
export type UmbHookMethod<T> = (data: T) => Promise<T> | T;
export interface UmbHookEntry<T> {
method: UmbHookMethod<T>;
weight: number;
}
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite';
import { rmSync } from 'fs';
import { getDefaultConfig } from '../../vite-config-base';
const dist = '../../../dist-cms/libs/hook-api';
// delete the unbundled dist folder
rmSync(dist, { recursive: true, force: true });
export default defineConfig({
...getDefaultConfig({
dist,
base: '/umbraco/backoffice/libs/hook-api',
entry: {
index: './index.ts',
},
}),
});