This commit is contained in:
Michael Telatynski
2025-06-24 09:50:02 +01:00
parent 9763807c42
commit 20098f132b
6 changed files with 103 additions and 14 deletions
@@ -110,14 +110,14 @@ originalComponent: (props: CustomRoomPreviewBarComponentProps) => JSX.Element) =
// @public
export interface DialogApiExtension {
openDialog<M, P>(initialOptions: DialogOptions, dialog: ComponentType<P & DialogProps<M>>, props?: P): DialogHandle<M>;
openDialog<M, P extends object>(initialOptions: DialogOptions, dialog: ComponentType<P & DialogProps<M>>, props: P): DialogHandle<M>;
}
// @public
export type DialogHandle<M> = {
finished: Promise<{
ok: boolean;
model: M;
model: M | null;
}>;
close(): void;
};
@@ -130,7 +130,7 @@ export interface DialogOptions {
// @public
export type DialogProps<M> = {
onSubmit(model: M): void;
cancel(): void;
onCancel(): void;
};
// @alpha @deprecated (undocumented)
@@ -287,7 +287,6 @@ export interface Profile {
// @public
export interface ProfileApiExtension {
// Warning: (ae-forgotten-export) The symbol "Watchable" needs to be exported by the entry point index.d.ts
readonly profile: Watchable<Profile>;
}
@@ -318,6 +317,18 @@ export type Variables = {
[key: string]: number | string | undefined;
};
// @public
export class Watchable<T> {
constructor(currentValue: T);
// (undocumented)
unwatch(listener: (value: T) => void): void;
// (undocumented)
get value(): T;
set value(value: T);
// (undocumented)
watch(listener: (value: T) => void): void;
}
// @alpha @deprecated (undocumented)
export interface WidgetPermissionsCustomisations<Widget, Capability> {
preapproveCapabilities?(widget: Widget, requestedCapabilities: Set<Capability>): Promise<Set<Capability>>;
+1 -1
View File
@@ -43,7 +43,7 @@
"typescript": "^5.7.3",
"vite": "^6.1.6",
"vite-plugin-dts": "^4.5.0",
"vitest": "^3.0.5",
"vitest": "^3.2.4",
"vitest-sonar-reporter": "^2.0.0"
},
"peerDependencies": {
@@ -26,7 +26,7 @@ export type DialogHandle<M> = {
/**
* Promise that resolves when the dialog is finished.
*/
finished: Promise<{ ok: boolean; model: M }>;
finished: Promise<{ ok: boolean; model: M | null }>;
/**
* Method to close the dialog.
*/
@@ -46,7 +46,7 @@ export type DialogProps<M> = {
/**
* Cancel the dialog programmatically.
*/
cancel(): void;
onCancel(): void;
};
/**
@@ -60,9 +60,9 @@ export interface DialogApiExtension {
* @param dialog - The body component to render in the dialog. This component should accept props of type `P`.
* @param props - Additional props to pass to the body
*/
openDialog<M, P>(
openDialog<M, P extends object>(
initialOptions: DialogOptions,
dialog: ComponentType<P & DialogProps<M>>,
props?: P,
props: P,
): DialogHandle<M>;
}
@@ -0,0 +1,58 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { expect, test, vitest } from "vitest";
import { Watchable } from "./watchable";
test("initial value is set correctly", () => {
const watchable = new Watchable(42);
expect(watchable.value).toBe(42);
});
test("value can be updated", () => {
const watchable = new Watchable(100);
watchable.value = 200;
expect(watchable.value).toBe(200);
});
test("watchers are notified on value change", () => {
const watchable = new Watchable(1);
const listener = vitest.fn();
watchable.watch(listener);
watchable.value = 2; // This should trigger the listener
expect(listener).toHaveBeenCalledExactlyOnceWith(2);
watchable.unwatch(listener); // Clean up after the test
});
test("watchers are not notified if value does not change", () => {
const watchable = new Watchable(10);
const listener = vitest.fn();
watchable.watch(listener);
watchable.value = 10; // This should not trigger the listener
expect(listener).not.toHaveBeenCalled();
watchable.unwatch(listener); // Clean up after the test
});
test("when value is an object, shallow comparison works", () => {
const watchable = new Watchable({ a: 1, b: 2 });
const listener = vitest.fn();
watchable.watch(listener);
// Update with a new object that has the same properties
watchable.value = { a: 3, b: 2 }; // This should trigger the listener
expect(listener).toHaveBeenCalledExactlyOnceWith({ a: 3, b: 2 });
listener.mockClear();
watchable.value = { a: 3, b: 2 }; // This should not trigger the listener again
expect(listener).not.toHaveBeenCalled();
watchable.unwatch(listener); // Clean up after the test
});
@@ -7,8 +7,21 @@ Please see LICENSE files in the repository root for full details.
type WatchFn<T> = (value: T) => void;
function shallowCompare<T extends object>(obj1: T, obj2: T): boolean {
return (
Object.keys(obj1).length === Object.keys(obj2).length &&
Object.keys(obj1).every((key) => obj1[key as keyof T] === obj2[key as keyof T])
);
}
function isObject(value: unknown): value is object {
return value !== null && typeof value === "object";
}
/**
* Utility class to wrap a value and allow listeners to be notified when the value changes.
* If T is an object, it will use a shallow comparison to determine if the value has changed.
* @public
*/
export class Watchable<T> {
private readonly listeners = new Set<WatchFn<T>>();
@@ -20,11 +33,17 @@ export class Watchable<T> {
}
public set value(value: T) {
if (this.currentValue !== value) {
this.currentValue = value;
for (const listener of this.listeners) {
listener(this.currentValue);
}
// If the value hasn't changed, do nothing.
if (value === this.currentValue) {
return;
}
if (isObject(value) && isObject(this.currentValue) && shallowCompare(this.currentValue as object, value)) {
return;
}
this.currentValue = value;
for (const listener of this.listeners) {
listener(this.currentValue);
}
}
@@ -17,3 +17,4 @@ export type * from "./api/auth";
export type * from "./api/dialog";
export type * from "./api/profile";
export type * from "./api/navigation";
export * from "./api/watchable";