Move view model code to shared components package (#31024)

* Remove vm related code from element-web/src

* Add and export view model code from package

* Update imports

* Rewrite vm tests using vitest

* Add github action to run vm tests

* Fix lint errors

* Mvoe tests over to jest

* Try fixing code coverage

* Second attempt at fixing code coverage
This commit is contained in:
R Midhun Suresh
2025-10-15 19:19:12 +05:30
committed by GitHub
parent ac96ab0d46
commit 2598e4ea22
22 changed files with 29 additions and 17 deletions

View File

@@ -54,7 +54,6 @@
"@storybook/test-runner": "^0.23.0",
"concurrently": "^9.2.1",
"eslint": "8",
"jest-image-snapshot": "^6.5.1",
"eslint-plugin-storybook": "^9.1.10",
"jest-image-snapshot": "^6.5.1",
"patch-package": "^8.0.1",

View File

@@ -8,8 +8,8 @@
import React, { type JSX, useMemo, type ComponentType } from "react";
import { omitBy, pickBy } from "lodash";
import { MockViewModel } from "./MockViewModel";
import { type ViewModel } from "./ViewModel";
import { MockViewModel } from "./viewmodel/MockViewModel";
import { type ViewModel } from "./viewmodel/ViewModel";
interface ViewWrapperProps<V> {
/**

View File

@@ -13,7 +13,7 @@ import { fireEvent } from "@testing-library/dom";
import * as stories from "./AudioPlayerView.stories.tsx";
import { AudioPlayerView, type AudioPlayerViewActions, type AudioPlayerViewSnapshot } from "./AudioPlayerView";
import { MockViewModel } from "../../MockViewModel";
import { MockViewModel } from "../../viewmodel/MockViewModel.ts";
const { Default, NoMediaName, NoSize, HasError } = composeStories(stories);

View File

@@ -7,7 +7,7 @@
import React, { type ChangeEventHandler, type JSX, type KeyboardEventHandler, type MouseEventHandler } from "react";
import { type ViewModel } from "../../ViewModel";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { MediaBody } from "../../message-body/MediaBody";
import { Flex } from "../../utils/Flex";

View File

@@ -9,7 +9,7 @@ import React from "react";
import { type Meta, type StoryFn } from "@storybook/react-vite";
import { TextualEventView as TextualEventComponent } from "./TextualEventView";
import { MockViewModel } from "../../MockViewModel";
import { MockViewModel } from "../../viewmodel/MockViewModel";
export default {
title: "Event/TextualEvent",

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
import React, { type ReactNode, type JSX } from "react";
import { type ViewModel } from "../../ViewModel";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
export type TextualEventViewSnapshot = {

View File

@@ -27,7 +27,6 @@ export * from "./utils/DateUtils";
export * from "./utils/numbers";
// MVVM
export * from "./viewmodel";
export * from "./ViewWrapper";
export type * from "./ViewModel";
export * from "./useViewModel";
export * from "./MockViewModel";

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
import { useSyncExternalStore } from "react";
import { type ViewModel } from "./ViewModel";
import { type ViewModel } from "./viewmodel/ViewModel";
/**
* A small wrapper around useSyncExternalStore to use a view model in a shared component view

View File

@@ -0,0 +1,51 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type ViewModel } from "./ViewModel";
import { Disposables } from "./Disposables";
import { Snapshot } from "./Snapshot";
import { ViewModelSubscriptions } from "./ViewModelSubscriptions";
export abstract class BaseViewModel<T, P> implements ViewModel<T> {
protected subs: ViewModelSubscriptions;
protected snapshot: Snapshot<T>;
protected props: P;
protected disposables = new Disposables();
protected constructor(props: P, initialSnapshot: T) {
this.props = props;
this.subs = new ViewModelSubscriptions();
this.snapshot = new Snapshot(initialSnapshot, () => {
this.subs.emit();
});
}
public subscribe = (listener: () => void): (() => void) => {
return this.subs.add(listener);
};
/**
* Returns the current snapshot of the view model.
*/
public getSnapshot = (): T => {
return this.snapshot.current;
};
/**
* Relinquish any resources held by this view-model.
*/
public dispose(): void {
this.disposables.dispose();
}
/**
* Whether this view-model has been disposed.
*/
public get isDisposed(): boolean {
return this.disposables.isDisposed;
}
}

View File

@@ -0,0 +1,70 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type { EventEmitter } from "events";
/**
* Something that needs to be eventually disposed. This can be:
* - A function that does the disposing
* - An object containing a dispose method which does the disposing
*/
export type DisposableItem = { dispose: () => void } | (() => void);
/**
* This class provides a way for the view-model to track any resource
* that it needs to eventually relinquish.
*/
export class Disposables {
private readonly disposables: DisposableItem[] = [];
private _isDisposed: boolean = false;
/**
* Relinquish all tracked disposable values
*/
public dispose(): void {
if (this.isDisposed) return;
this._isDisposed = true;
for (const disposable of this.disposables) {
if (typeof disposable === "function") {
disposable();
} else {
disposable.dispose();
}
}
}
/**
* Track a value that needs to be eventually relinquished
*/
public track<T extends DisposableItem>(disposable: T): T {
this.throwIfDisposed();
this.disposables.push(disposable);
return disposable;
}
/**
* Add an event listener that will be removed on dispose
*/
public trackListener(emitter: EventEmitter, event: string, callback: (...args: unknown[]) => void): void {
this.throwIfDisposed();
emitter.on(event, callback);
this.track(() => {
emitter.off(event, callback);
});
}
private throwIfDisposed(): void {
if (this.isDisposed) throw new Error("Disposable is already disposed");
}
/**
* Whether this disposable has been disposed
*/
public get isDisposed(): boolean {
return this._isDisposed;
}
}

View File

@@ -0,0 +1,43 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
/**
* This is the output of the viewmodel that the view consumes.
* Updating snapshot through this object will make react re-render
* components.
*/
export class Snapshot<T> {
public constructor(
private snapshot: T,
private emit: () => void,
) {}
/**
* Replace current snapshot with a new snapshot value.
* @param snapshot New snapshot value
*/
public set(snapshot: T): void {
this.snapshot = snapshot;
this.emit();
}
/**
* Update a part of the current snapshot by merging into the existing snapshot.
* @param snapshot A subset of the snapshot to merge into the current snapshot.
*/
public merge(snapshot: Partial<T>): void {
this.snapshot = { ...this.snapshot, ...snapshot };
this.emit();
}
/**
* The current value of the snapshot.
*/
public get current(): T {
return this.snapshot;
}
}

View File

@@ -0,0 +1,34 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
/**
* Utility class for view models to manage subscriptions to their updates
*/
export class ViewModelSubscriptions {
private listeners = new Set<() => void>();
/**
* Subscribe to changes in the view model.
* @param listener Will be called whenever the snapshot changes.
* @returns A function to unsubscribe from the view model updates.
*/
public add = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
/**
* Emit an update to all subscribed listeners.
*/
public emit = (): void => {
for (const listener of this.listeners) {
listener();
}
};
}

View File

@@ -0,0 +1,13 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export * from "./BaseViewModel";
export * from "./Disposables";
export * from "./Snapshot";
export * from "./ViewModelSubscriptions";
export type * from "./ViewModel";
export * from "./MockViewModel";

View File

@@ -0,0 +1,57 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { EventEmitter } from "events";
import { Disposables } from "..";
describe("Disposable", () => {
it("isDisposed is true after dispose() is called", () => {
const disposables = new Disposables();
expect(disposables.isDisposed).toEqual(false);
disposables.dispose();
expect(disposables.isDisposed).toEqual(true);
});
it("dispose() calls the correct disposing function", () => {
const disposables = new Disposables();
const item1 = {
foo: 5,
dispose: jest.fn(),
};
disposables.track(item1);
const item2 = jest.fn();
disposables.track(item2);
disposables.dispose();
expect(item1.dispose).toHaveBeenCalledTimes(1);
expect(item2).toHaveBeenCalledTimes(1);
});
it("Throws error if acting on already disposed disposables", () => {
const disposables = new Disposables();
disposables.dispose();
expect(() => {
disposables.track(jest.fn);
}).toThrow();
});
it("Removes tracked event listeners on dispose", () => {
const disposables = new Disposables();
const emitter = new EventEmitter();
const fn = jest.fn();
disposables.trackListener(emitter, "FooEvent", fn);
emitter.emit("FooEvent");
expect(fn).toHaveBeenCalled();
disposables.dispose();
expect(emitter.listenerCount("FooEvent", fn)).toEqual(0);
});
});

View File

@@ -0,0 +1,41 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { Snapshot } from "..";
interface TestSnapshot {
key1: string;
key2: number;
key3: boolean;
}
describe("Snapshot", () => {
it("should accept an initial value", () => {
const snapshot = new Snapshot<TestSnapshot>({ key1: "foo", key2: 5, key3: false }, jest.fn());
expect(snapshot.current).toStrictEqual({ key1: "foo", key2: 5, key3: false });
});
it("should call emit callback when state changes", () => {
const emit = jest.fn();
const snapshot = new Snapshot<TestSnapshot>({ key1: "foo", key2: 5, key3: false }, emit);
snapshot.merge({ key3: true });
expect(emit).toHaveBeenCalledTimes(1);
});
it("should swap out entire snapshot on set call", () => {
const snapshot = new Snapshot<TestSnapshot>({ key1: "foo", key2: 5, key3: false }, jest.fn());
const newValue = { key1: "bar", key2: 8, key3: true };
snapshot.set(newValue);
expect(snapshot.current).toStrictEqual(newValue);
});
it("should merge partial snapshot on merge call", () => {
const snapshot = new Snapshot<TestSnapshot>({ key1: "foo", key2: 5, key3: false }, jest.fn());
snapshot.merge({ key2: 10 });
expect(snapshot.current).toStrictEqual({ key1: "foo", key2: 10, key3: false });
});
});