Typescript MutableObservable and MutableObservableCollection

Date: 2025-04-03
import { BehaviorSubject, Observable } from "rxjs";

// basically a wrapper for BehaviorSubject
export class MutableObservable<T> {
    private Subject: BehaviorSubject<T>;
    constructor(value: T) {
        this.Subject = new BehaviorSubject<T>(value);

        this.getValue = this.getValue.bind(this);
        this.setValue = this.setValue.bind(this);
        this.asObservable = this.asObservable.bind(this);
    }

    getValue(): T {
        return this.Subject.getValue();
    }

    setValue(value: T): void {
        return this.Subject.next(value);
    }

    asObservable() {
        return this.Subject.asObservable();
    }
}

export class MutableObservableCollection<T extends { key: string | number }> {
    private subject: BehaviorSubject<T[]>;

    constructor(initialItems: T[] = []) {
        this.subject = new BehaviorSubject<T[]>(initialItems);
    }

    getItems(): T[] {
        return this.subject.getValue();
    }

    asObservable(): Observable<T[]> {
        return this.subject.asObservable();
    }

    setItems(newItems: T[]): void {
        this.subject.next(newItems);
    }

    updateItem(updatedItem: T): void {
        this.subject.next(
            this.getItems().map(item => item.key === updatedItem.key ? updatedItem : item)
        );
    }

    addItem(newItem: T): void {
        this.subject.next([...this.getItems(), newItem]);
    }

    removeItem(key: string | number): void {
        this.subject.next(this.getItems().filter(item => item.key !== key));
    }

    moveItemUp(key: string | number): void {
        const items = this.getItems();
        const index = items.findIndex(item => item.key === key);

        // Als het item niet bestaat of al bovenaan staat, doe niets
        if (index <= 0) return;

        // Verwissel het item met het item ervoor
        const newItems = [...items];
        [newItems[index - 1], newItems[index]] = [newItems[index], newItems[index - 1]];

        this.subject.next(newItems);
    }

    moveItemDown(key: string | number): void {
        const items = this.getItems();
        const index = items.findIndex(item => item.key === key);

        // Als het item niet bestaat of al onderaan staat, doe niets
        if (index === -1 || index >= items.length - 1) return;

        // Verwissel het item met het item erna
        const newItems = [...items];
        [newItems[index], newItems[index + 1]] = [newItems[index + 1], newItems[index]];

        this.subject.next(newItems);
    }

    getChangedItems(originalItems: T[]): T[] {
        return this.getItems().filter(item => {
            const original = originalItems.find(o => o.key === item.key);
            return original && JSON.stringify(original) !== JSON.stringify(item);
        });
    }
}

React

export function useMutableObservable<T>(subjectInput: MutableObservable<T>): [T, (value: T) => any, (changes: Partial<T>) => any] {
    const state = useMemo(() => ({
        observable: subjectInput.asObservable(),
        setValue: subjectInput.setValue,
        getValue: subjectInput.getValue,
        updateValue: (_changes: Partial<T>) => 0
    }), [subjectInput]);

    const [result, setResult] = useState<[T, (value: T) => any, (changes: Partial<T>) => any]>([state.getValue(), state.setValue, state.updateValue]);
    const resultRef = useRef(state.getValue());
    resultRef.current = state.getValue();
    useEffect(() => {
        if (!state?.observable) return;
        const subscription = state.observable.subscribe((x: any) => {
            if (x === resultRef.current) return;
            setResult([x, state.setValue, state.updateValue]);
        });
        return () => subscription.unsubscribe();
    }, [state]);

    return result;
}

export function useObservable<T>(subject: Observable<T | undefined>, initialValue?: T): T | undefined {
    const [data, setData] = useState<T | undefined>(initialValue);
    useEffect(() => {
        if (!subject) { return; }
        const subscription = subject.subscribe((x: any) => {
            setData(x);
        });
        return () => subscription.unsubscribe();
    }, [subject]);
    return data;
}

WIP

iimport { Observable, Subject, Subscription } from "rxjs";
import { AdvancedCollection } from "./MutableObservable";

export interface ISubscribable<T> {
    subscribe(
        next?: ((value: T) => void) | null,
        error?: ((error: any) => void) | null,
        complete?: (() => void) | null
    ): Subscription;
}

export interface ObservableValue<T> extends ISubscribable<T> {
    getValue(): T;
    setValue(fn: (old: T) => T): void;
    setPartial(v: Partial<T>): void;
    asObservable(): Observable<T>;
}

/**
 * Maakt een gecontroleerde observable waarde met toegang tot de huidige waarde.
 * Emit geen waarde automatisch bij constructie (zoals BehaviorSubject zou doen),
 * maar houdt wel de laatste waarde bij.
 */
export function makeObservableValue<T>(initialValue: T): ObservableValue<T> {
    let currentValue = initialValue;
    const subject = new Subject<T>();
    const o = subject.asObservable();
    const subscribe = o.subscribe.bind(o);
    const setValue = (fn: (old: T) => T) => {
        const newValue = fn(currentValue);
        currentValue = newValue;
        subject.next(newValue);
    };
    return {
        getValue: () => currentValue,
        setPartial: (v: Partial<T>) => setValue(c => ({ ...c, ...v })),
        setValue: setValue,
        asObservable: () => subject.asObservable(),
        subscribe: subscribe
    };
}

export function makeChildObservableValue<T, K extends keyof T>(parent: ObservableValue<T>, key: K): ObservableValue<T[K]> {
    return makeChildObservableValueFlexible(parent, (p) => p[key], (p, v) => p.setPartial({ [key]: v } as any));
}

export function makeChildObservableValueFlexible<T, C>(parent: ObservableValue<T>, getValueFromParent: (parent: T) => C, setInParent: (parent: ObservableValue<T>, childValue: C) => any): ObservableValue<C> {
    const child = makeObservableValue(getValueFromParent(parent.getValue()));

    const originalSetValue = child.setValue;
    child.setValue = (fn: (old: C) => C) => {
        originalSetValue(fn);
        const newVal = child.getValue();
        setInParent(parent, newVal);
    };
    return child;
}

export function makeChildAdvancedCollection<TParent, TItem>(parent: ObservableValue<TParent>, getArray: (p: TParent) => TItem[], setArray: (p: ObservableValue<TParent>, newArray: TItem[]) => void, keyFn: (item: TItem) => string | number): AdvancedCollection<TItem> {
    const initialItems = getArray(parent.getValue());
    const ac = new AdvancedCollection<TItem>(keyFn, initialItems);

    // Override setItems zodat het ook de parent update
    const originalSetItems = ac.setItems.bind(ac);
    ac.setItems = (newItems: TItem[]) => {
        originalSetItems(newItems);
        setArray(parent, newItems);
    };

    return ac;
}


export interface IAdvancedCollection<T> {
    getItems(): T[];
    asObservable(): Observable<T[]>;
    asObservableValue(): ObservableValue<T[]>;
    setItems(newItems: T[]): void;
    updateItem(updatedItem: T): void;
    addItem(newItem: T): void;
    addItems(newItems: T[]): void;
    removeItem(key: string | number): void;
    getItem(key: string | number): T | undefined;
    moveItemUp(key: string | number): void;
    moveItemDown(key: string | number): void;
    getChangedItems(originalItems: T[]): T[];
}

export class AdvancedCollection<T> implements IAdvancedCollection<T> {
    private ov: ObservableValue<T[]>;
    private keyFn: (o: T) => string | number;

    constructor(keyFn: (o: T) => string | number, initialItems: T[] = []) {
        this.ov = makeObservableValue(initialItems);
        this.keyFn = keyFn;
    }

    getItems(): T[] {
        return [...this.ov.getValue()];
    }

    asObservable(): Observable<T[]> {
        return this.ov.asObservable();
    }

    asObservableValue(): ObservableValue<T[]> {
        return this.ov;
    }

    setItems(newItems: T[]): void {
        this.ov.setValue(_ => newItems);
    }

    updateItem(updatedItem: T): void {
        this.setItems(this.getItems().map(item => this.keyFn(item) === this.keyFn(updatedItem) ? updatedItem : item));
    }

    addItem(newItem: T): void {
        this.setItems([...this.getItems(), newItem]);
    }

    addItems(newItems: T[]): void {
        this.setItems([...this.getItems(), ...newItems]);
    }

    removeItem(key: string | number): void {
        const newItems = this.getItems().filter(item => this.keyFn(item) !== key);
        this.setItems(newItems);
    }

    getItem(key: string | number): T | undefined {
        return this.getItems().find(item => this.keyFn(item) === key);
    }

    moveItemUp(key: string | number): void {
        const items = this.getItems();
        const index = items.findIndex(item => this.keyFn(item) === key);

        // Als het item niet bestaat of al bovenaan staat, doe niets
        if (index <= 0) return;

        // Verwissel het item met het item ervoor
        const newItems = [...items];
        [newItems[index - 1], newItems[index]] = [newItems[index], newItems[index - 1]];

        this.setItems(newItems);
    }

    moveItemDown(key: string | number): void {
        const items = this.getItems();
        const index = items.findIndex(item => this.keyFn(item) === key);

        // Als het item niet bestaat of al onderaan staat, doe niets
        if (index === -1 || index >= items.length - 1) return;

        // Verwissel het item met het item erna
        const newItems = [...items];
        [newItems[index], newItems[index + 1]] = [newItems[index + 1], newItems[index]];

        this.setItems(newItems);
    }

    getChangedItems(originalItems: T[]): T[] {
        return this.getItems().filter(item => {
            const original = originalItems.find(o => this.keyFn(o) === this.keyFn(item));
            return original && JSON.stringify(original) !== JSON.stringify(item);
        });
    }
}


export class MutableObservableCollection<T extends { key: string | number }> extends AdvancedCollection<T> {
    constructor(initialItems: T[] = []) {
        super((o: T) => o.key, initialItems);
    }
}


// react hooks:

export function useObservableValue<T>(subject: ObservableValue<T>): T {
    const [, forceUpdate] = useReducer(x => x + 1, 0);
    useEffect(() => {
        if (!subject) { return; }
        const subscription = subject.subscribe(forceUpdate);
        return () => subscription.unsubscribe();
    }, [subject]);
    return subject.getValue();
}

export function useObservableValueDebug<T>(subject: ObservableValue<T>, key: string): T {
    const [, forceUpdate] = useReducer(x => x + 1, 0);
    useEffect(() => {
        if (!subject) { return; }
        console.log('observable-value subscribe', key);
        const subscription = subject.subscribe((x: T) => {
            console.log('observable-value changed', key, x);
            forceUpdate();
        });
        return () => {
            console.log('observable-value unsubscribe', key);
            subscription.unsubscribe();
        };
    }, [subject, key]);
    console.log('observable-value render', key, subject.getValue());
    return subject.getValue();
}

Example

function DashboardFieldsTest(props: { dashboard: ObservableValue<IDashboardDetailData> }) {
    const { dashboard } = props;

    const nameField = makeChildObservableValueFlexible(dashboard, d => d.name, (d, value) => d.setPartial({ name: value }));
    const titleField = makeChildObservableValueFlexible(dashboard, d => d.title, (d, value) => d.setPartial({ title: value }));
    const arrayField = makeChildAdvancedCollection(dashboard, d => d.filters, (d, value) => d.setPartial({ filters: value }), (x) => x.field);
    return <>
        <ObservableEditor editorName="TextEditor" label={translate("DashboardDetailView.name")} field={nameField} />
        {/* <ObservableEditor editorName="TextEditor" label={translate("DashboardDetailView.name")} field={nameField} /> */}
        <ArrayEditor field={arrayField} />
        <Button onClick={() => {
            arrayField.addItem({ field: nanoid(), operator: ListViewFilterOperator.Between, values: [""] });
            console.log("dashboard.getValue().filters", dashboard.getValue().filters);
        }}>
            Add item</Button>
        <ObservableEditor editorName="TextEditor" label={translate("DashboardDetailView.title")} field={titleField} />
    </>;
}

function ArrayEditor(props: { field: IAdvancedCollection<IWidgetFilterArg> }) {
    const value = useObservableValue(props.field.asObservableValue());
    return <>{value.map(x => <div key={x.field}>{x.field}  <Button onClick={() => props.field.removeItem(x.field)}>delete</Button></div>)}</>

}
import { nanoid } from "nanoid";
import { Observable, Subject, Subscription } from "rxjs";
import { AdvancedCollection } from "./MutableObservable";
import { ensureTypedArray } from "./tshelper";

export interface ISubscribable<T> {
    subscribe(
        next?: ((value: T) => void) | null,
        error?: ((error: any) => void) | null,
        complete?: (() => void) | null
    ): Subscription;
}

export interface ObservableValue<T> extends ISubscribable<T> {
    getValue(): T;
    setValue(fn: (old: T) => T): void;
    setPartial(v: Partial<T>): void;
    asObservable(): Observable<T>;
}

/**
 * Maakt een gecontroleerde observable waarde met toegang tot de huidige waarde.
 * Emit geen waarde automatisch bij constructie (zoals BehaviorSubject zou doen),
 * maar houdt wel de laatste waarde bij.
 */
export function makeObservableValue<T>(initialValue: T): ObservableValue<T> {
    let currentValue = initialValue;
    const subject = new Subject<T>();
    const o = subject.asObservable();
    const subscribe = o.subscribe.bind(o);
    const setValue = (fn: (old: T) => T) => {
        const newValue = fn(currentValue);
        currentValue = newValue;
        subject.next(newValue);
    };
    return {
        getValue: () => currentValue,
        setPartial: (v: Partial<T>) => setValue(c => ({ ...c, ...v })),
        setValue: setValue,
        asObservable: () => subject.asObservable(),
        subscribe: subscribe
    };
}

export function makeChildObservableValue<T, K extends keyof T>(parent: ObservableValue<T>, key: K): ObservableValue<T[K]> {
    return makeChildObservableValueFlexible(parent, (p) => p[key], (p, v) => p.setPartial({ [key]: v } as any));
}

export function makeChildObservableValueFlexible<T, C>(parent: ObservableValue<T>, getValueFromParent: (parent: T) => C, setInParent: (parent: ObservableValue<T>, childValue: C) => any): ObservableValue<C> {
    const child = makeObservableValue(getValueFromParent(parent.getValue()));

    const originalSetValue = child.setValue;
    child.setValue = (fn: (old: C) => C) => {
        originalSetValue(fn);
        const newVal = child.getValue();
        setInParent(parent, newVal);
    };
    child.setPartial = (v: Partial<C>) => child.setValue(x => ({ ...x, ...v }));
    return child;
}

export function makeChildAdvancedCollection<TParent, TItem>(parent: ObservableValue<TParent>, getArray: (p: TParent) => TItem[], setArray: (p: ObservableValue<TParent>, newArray: TItem[]) => void, keyFn: (item: TItem) => string | number): AdvancedCollection<TItem> {
    const initialItems = getArray(parent.getValue());
    const ac = new AdvancedCollection<TItem>(keyFn, initialItems);

    // Override setItems zodat het ook de parent update
    const originalSetItems = ac.setItems.bind(ac);
    ac.setItems = (newItems: TItem[]) => {
        originalSetItems(newItems);
        setArray(parent, newItems);
    };

    return ac;
}

export function addKeys<T>(arr: T[]): (T & { key: string })[] {
    return ensureTypedArray(arr).map(x => ({
        ...x,
        key: addOrCreateKey(x)
    }));
}

function addOrCreateKey(x: any): string {
    if (!x?.key) return nanoid();
    return x.key;
}
94100cookie-checkTypescript MutableObservable and MutableObservableCollection