Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CardView - implement observables #28422

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions packages/devextreme/js/__internal/core/reactive/core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/* eslint-disable spellcheck/spell-checker */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable max-classes-per-file */

import { type Subscription, SubscriptionBag } from './subscription';
import type {
Callback, Gettable, Subscribable, Updatable,
} from './types';

export class Observable<T> implements Subscribable<T>, Updatable<T>, Gettable<T> {
private readonly callbacks: Set<Callback<T>> = new Set();

constructor(private value: T) {}

update(value: T): void {
if (this.value === value) {
return;
}
this.value = value;

this.callbacks.forEach((c) => {
c(value);
});
}

updateFunc(func: (oldValue: T) => T): void {
this.update(func(this.value));
}

subscribe(callback: Callback<T>): Subscription {
this.callbacks.add(callback);
callback(this.value);

return {
unsubscribe: () => this.callbacks.delete(callback),
};
}

unreactive_get(): T {
return this.value;
}

dispose(): void {
this.callbacks.clear();
}
}

export class InterruptableComputed<
TArgs extends readonly any[], TValue,
> extends Observable<TValue> {
private readonly depValues: [...TArgs];

private readonly depInitialized: boolean[];

private isInitialized = false;

private readonly subscriptions = new SubscriptionBag();

constructor(
compute: (...args: TArgs) => TValue,
deps: { [I in keyof TArgs]: Subscribable<TArgs[I]> },
) {
super(undefined as any);

this.depValues = deps.map(() => undefined) as any;
this.depInitialized = deps.map(() => false);

deps.forEach((dep, i) => {
this.subscriptions.add(dep.subscribe((v) => {
this.depValues[i] = v;

if (!this.isInitialized) {
this.depInitialized[i] = true;
this.isInitialized = this.depInitialized.every((e) => e);
}

if (this.isInitialized) {
this.update(compute(...this.depValues));
}
}));
});
}

dispose(): void {
super.dispose();
this.subscriptions.unsubscribe();
}
}
3 changes: 3 additions & 0 deletions packages/devextreme/js/__internal/core/reactive/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './subscription';
export * from './types';
export * from './utilities';
17 changes: 17 additions & 0 deletions packages/devextreme/js/__internal/core/reactive/subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export interface Subscription {
unsubscribe: () => void;
}

export class SubscriptionBag implements Subscription {
private readonly subscriptions: Subscription[] = [];

add(subscription: Subscription): void {
this.subscriptions.push(subscription);
}

unsubscribe(): void {
this.subscriptions.forEach((subscription) => {
subscription.unsubscribe();
});
}
}
28 changes: 28 additions & 0 deletions packages/devextreme/js/__internal/core/reactive/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/* eslint-disable spellcheck/spell-checker */
import type { Subscription } from './subscription';

export interface Subscribable<T> {
subscribe: (callback: Callback<T>) => Subscription;
}

export type MaybeSubscribable<T> = T | Subscribable<T>;

export type MapMaybeSubscribable<T> = { [K in keyof T]: MaybeSubscribable<T[K]> };

export function isSubscribable<T>(value: unknown): value is Subscribable<T> {
return typeof value === 'object' && !!value && 'subscribe' in value;
}

export type Callback<T> = (value: T) => void;

export interface Updatable<T> {
update: (value: T) => void;
updateFunc: (func: (oldValue: T) => T) => void;
}

export interface Gettable<T> {
unreactive_get: () => T;
}

export type SubsGets<T> = Subscribable<T> & Gettable<T>;
export type SubsGetsUpd<T> = Subscribable<T> & Gettable<T> & Updatable<T>;
217 changes: 217 additions & 0 deletions packages/devextreme/js/__internal/core/reactive/utilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/* eslint-disable spellcheck/spell-checker */
import {
beforeEach, describe, expect, it, jest,
} from '@jest/globals';

import {
computed, interruptableComputed, state, toSubscribable,
} from './utilities';

describe('state', () => {
let myState = state('some value');

beforeEach(() => {
myState = state('some value');
});

describe('unreactive_get', () => {
it('should return value', () => {
expect(myState.unreactive_get()).toBe('some value');
});

it('should return current value if it was updated', () => {
myState.update('new value');
expect(myState.unreactive_get()).toBe('new value');
});
});

describe('subscribe', () => {
it('should call callback on initial set', () => {
const callback = jest.fn();
myState.subscribe(callback);

expect(callback).toBeCalledTimes(1);
expect(callback).toBeCalledWith('some value');
});

it('should call callback on update', () => {
const callback = jest.fn();
myState.subscribe(callback);

myState.update('new value');

expect(callback).toBeCalledTimes(2);
expect(callback).toHaveBeenNthCalledWith(1, 'some value');
expect(callback).toHaveBeenNthCalledWith(2, 'new value');
});

it('should not trigger update if value is not changed', () => {
const callback = jest.fn();
myState.subscribe(callback);

expect(callback).toBeCalledTimes(1);

myState.update('some value');

expect(callback).toBeCalledTimes(1);
});
});

describe('dispose', () => {
it('should prevent all updates', () => {
const callback = jest.fn();
myState.subscribe(callback);

expect(callback).toBeCalledTimes(1);
expect(callback).toBeCalledWith('some value');

// @ts-expect-error
myState.dispose();
myState.update('new value');

expect(callback).toBeCalledTimes(1);
});
});
});

describe('computed', () => {
let myState1 = state('some value');
let myState2 = state('other value');
let myComputed = computed(
(v1, v2) => `${v1} ${v2}`,
[myState1, myState2],
);

beforeEach(() => {
myState1 = state('some value');
myState2 = state('other value');
myComputed = computed(
(v1, v2) => `${v1} ${v2}`,
[myState1, myState2],
);
});

describe('unreactive_get', () => {
it('should calculate initial value', () => {
expect(myComputed.unreactive_get()).toBe('some value other value');
});

it('should return current value if it dependency is updated', () => {
myState1.update('new value');
expect(myComputed.unreactive_get()).toBe('new value other value');
});
});

describe('subscribe', () => {
it('should call callback on initial set', () => {
const callback = jest.fn();
myComputed.subscribe(callback);

expect(callback).toBeCalledTimes(1);
expect(callback).toBeCalledWith('some value other value');
});

it('should call callback on update of dependency', () => {
const callback = jest.fn();
myComputed.subscribe(callback);

myState1.update('new value');

expect(callback).toBeCalledTimes(2);
expect(callback).toHaveBeenNthCalledWith(1, 'some value other value');
expect(callback).toHaveBeenNthCalledWith(2, 'new value other value');
});
});
});

describe('interruptableComputed', () => {
let myState1 = state('some value');
let myState2 = state('other value');
let myComputed = interruptableComputed(
(v1, v2) => `${v1} ${v2}`,
[myState1, myState2],
);

beforeEach(() => {
myState1 = state('some value');
myState2 = state('other value');
myComputed = interruptableComputed(
(v1, v2) => `${v1} ${v2}`,
[myState1, myState2],
);
});

describe('unreactive_get', () => {
it('should calculate initial value', () => {
expect(myComputed.unreactive_get()).toBe('some value other value');
});

it('should return current value if it was updated', () => {
myComputed.update('new value');
expect(myComputed.unreactive_get()).toBe('new value');
});

it('should return current value if it dependency is updated', () => {
myState1.update('new value');
expect(myComputed.unreactive_get()).toBe('new value other value');
});
});

describe('subscribe', () => {
it('should call callback on initial set', () => {
const callback = jest.fn();
myComputed.subscribe(callback);

expect(callback).toBeCalledTimes(1);
expect(callback).toBeCalledWith('some value other value');
});

it('should call callback on update', () => {
const callback = jest.fn();
myComputed.subscribe(callback);

myComputed.update('new value');

expect(callback).toBeCalledTimes(2);
expect(callback).toHaveBeenNthCalledWith(1, 'some value other value');
expect(callback).toHaveBeenNthCalledWith(2, 'new value');
});

it('should call callback on update of dependency', () => {
const callback = jest.fn();
myComputed.subscribe(callback);

myState1.update('new value');

expect(callback).toBeCalledTimes(2);
expect(callback).toHaveBeenNthCalledWith(1, 'some value other value');
expect(callback).toHaveBeenNthCalledWith(2, 'new value other value');
});

it('should not trigger update if value is not changed', () => {
const callback = jest.fn();
myComputed.subscribe(callback);

expect(callback).toBeCalledTimes(1);

myComputed.update('some value other value');

expect(callback).toBeCalledTimes(1);
});
});
});

describe('toSubscribable', () => {
it('should wrap value if it is not subscribable', () => {
const callback = jest.fn();
toSubscribable('some value').subscribe(callback);

expect(callback).toBeCalledTimes(1);
expect(callback).toBeCalledWith('some value');
});

it('should return value as is if subscribable', () => {
const myState = state(1);
expect(toSubscribable(myState)).toBe(myState);
});
});
Loading
Loading