diff --git a/src/utilities/data-layer/data-layer-group.js b/src/utilities/data-layer/data-layer-group.js new file mode 100644 index 0000000..5f06821 --- /dev/null +++ b/src/utilities/data-layer/data-layer-group.js @@ -0,0 +1,36 @@ +import { DataLayerItem } from './data-layer-item.js'; + +export class DataLayerGroup { + constructor() { + this._items = new Map(); + + this._initProperties(Object.getPrototypeOf(this), 'data', (key, value) => { + this._items.set(key, new DataLayerItem(value, { callingContext: this })); + Object.defineProperty(this, key, { + configurable: true, + get: () => this._items.get(key).value, + set: value => { this._items.get(key).value = value; }, + }); + }); + + this._initProperties(Object.getPrototypeOf(this), 'actions', (key, value) => { + this[key] = value.bind(this); + }); + } + + getItem(key) { + return this._items.get(key); + } + + _initProperties(base, field, initializer) { + const properties = base.constructor[field]; + if (!properties) return; + + this._initProperties(Object.getPrototypeOf(base)); + + Object.keys(properties).forEach(key => { + if (key in this) throw new Error(`Cannot define duplicate property ${key} in ${base.constructor.name || 'anonymous class'}`); + initializer(key, properties[key]); + }); + } +} diff --git a/src/utilities/data-layer/data-layer-item.js b/src/utilities/data-layer/data-layer-item.js new file mode 100644 index 0000000..a1acb14 --- /dev/null +++ b/src/utilities/data-layer/data-layer-item.js @@ -0,0 +1,90 @@ +import { AbortableGroup } from './util/abortable.js'; + +let __activeComputedValue = null; + +export class DataLayerItem { + constructor(value, { defaultValue = null, callingContext = this } = {}) { + this.evaluating = false; + + this._isComputed = typeof value === 'function'; + this._subscribers = new Set(); + + if (this._isComputed) { + this._defaultValue = defaultValue; + this._dependenciesEvaluating = new Set(); + this._getter = value.bind(callingContext); + this._inProgressCompute = new AbortableGroup(); + this._needsFirstCompute = true; + this._value = defaultValue; + } else { + this._value = value; + } + } + + get value() { + __activeComputedValue?.addDependency(this); + if (this._needsFirstCompute) this._firstCompute(); + return this._value; + } + + set value(value) { + if (this._isComputed) throw new Error('Cannot set value of computed property'); + this._setValue(value); + } + + addDependency(dependency) { + if (dependency === this) return; + + dependency.subscribe(this._onDependencyChange.bind(this)); + if (dependency.evaluating) this._onDependencyChange(dependency); + } + + flush() { + if (!this._isComputed) return; + this._compute(); + } + + subscribe(callback, immediate = false) { + this._subscribers.add(callback); + if (immediate) callback(this); + } + + _compute() { + this._inProgressCompute.abort(); + this._setValue(this._defaultValue, true); + if (this._dependenciesEvaluating.size) return; + + this._inProgressCompute.add().run(this._getter, value => this._setValue(value, false), err => this._onError(err)); + } + + _firstCompute() { + this._needsFirstCompute = false; + __activeComputedValue = this; + this._compute(); + __activeComputedValue = null; + } + + _notify() { + this._subscribers.forEach(callback => callback(this)); + } + + _onDependencyChange(dependency) { + if (dependency.evaluating) this._dependenciesEvaluating.add(dependency); + else this._dependenciesEvaluating.delete(dependency); + this.flush(); + } + + _onError(err) { + console.error(err); + this._setValue(this._defaultValue, false); + } + + _setValue(value, evaluating = false) { + evaluating = evaluating || this._dependenciesEvaluating?.size > 0; + if (this._value === value && this.evaluating === evaluating) return; + + this._value = value; + this.evaluating = evaluating; + this._notify(); + } +} diff --git a/src/utilities/data-layer/demo-component.js b/src/utilities/data-layer/demo-component.js new file mode 100644 index 0000000..7f18a92 --- /dev/null +++ b/src/utilities/data-layer/demo-component.js @@ -0,0 +1,86 @@ +import '@brightspace-ui/core/components/loading-spinner/loading-spinner.js'; +import { css, html, LitElement } from 'lit'; +import { DataLayerGroup, declareDependencies } from './index.js'; +import { DataLayerController } from './lit/index.js'; + +class FooData extends DataLayerGroup { + static actions = { + flushBar() { this.getItem('bar').flush(); }, + }; + + static data = { + foo: 'foo', + async bar() { + declareDependencies(this.foo); + await new Promise(resolve => setTimeout(resolve, 1000)); + return `${this.foo}bar`; + }, + baz() { return `${this.bar}baz`; }, + async baz2() { + declareDependencies(this.bar); + await new Promise(resolve => setTimeout(resolve, 5000)); + return `${this.bar}baz2`; + }, + }; +} + +const fooData = new FooData(); + +class DataLayerDemo extends LitElement { + static properties = { + linkLoading: { type: Boolean, attribute: 'link-loading' }, + _data: { state: true }, + _loading: { state: true }, + }; + + static styles = css` + :host { + display: block; + } + `; + + constructor() { + super(); + + this._data = new DataLayerController(this, fooData, [ + 'foo', + 'bar', + 'baz', + 'baz2', + ]); + } + + render() { + if (this.linkLoading && this._data.loading) { + return html``; + } + + return html` + ${this._renderItem('foo')} + ${this._renderItem('bar')} + ${this._renderItem('baz')} + ${this._renderItem('baz2')} + `; + } + + _append(e) { + fooData.getItem(e.target?.dataset.prop).value += '!'; + } + + _flush(e) { + fooData.getItem(e.target?.dataset.prop).flush(); + } + + _renderItem(prop) { + return html` +
+ ${prop}: + + + ${this._data.getLoading(prop) ? html`` : this._data[prop]} +
+ `; + } +} + +customElements.define('d2l-data-layer-demo', DataLayerDemo); diff --git a/src/utilities/data-layer/index.js b/src/utilities/data-layer/index.js new file mode 100644 index 0000000..c1529e0 --- /dev/null +++ b/src/utilities/data-layer/index.js @@ -0,0 +1,6 @@ +export { DataLayerItem } from './data-layer-item.js'; +export { DataLayerGroup } from './data-layer-group.js'; + +export function declareDependencies() { + // No-op, simply passing values to this function will cause the dependencies to be registered. +} diff --git a/src/utilities/data-layer/lit/data-layer-controller.js b/src/utilities/data-layer/lit/data-layer-controller.js new file mode 100644 index 0000000..e4fbcd5 --- /dev/null +++ b/src/utilities/data-layer/lit/data-layer-controller.js @@ -0,0 +1,38 @@ +export class DataLayerController { + constructor(host, dataLayerGroup, props = []) { + (this.host = host).addController(this); + this.loading = false; + this._loadingProps = new Set(); + + props.forEach(prop => { + dataLayerGroup.getItem(prop).subscribe(v => { + this._updateProp(prop, v.value); + this._updateLoading(prop, v.evaluating); + this[prop] = v.value; + + this.host.requestUpdate(); + }, true); + }); + } + + getLoading(prop) { + if (!prop) return this.loading; + return this._loadingProps.has(prop); + } + + _updateLoading(prop, loading) { + if (loading && !this._loadingProps.has(prop)) this._loadingProps.add(prop); + else if (!loading && this._loadingProps.has(prop)) this._loadingProps.delete(prop); + else return; + + this.loading = !!this._loadingProps.size; + this.host.requestUpdate(); + } + + _updateProp(prop, value) { + if (this[prop] === value) return; + + this[prop] = value; + this.host.requestUpdate(); + } +} diff --git a/src/utilities/data-layer/lit/index.js b/src/utilities/data-layer/lit/index.js new file mode 100644 index 0000000..05a24b7 --- /dev/null +++ b/src/utilities/data-layer/lit/index.js @@ -0,0 +1 @@ +export { DataLayerController } from './data-layer-controller.js'; diff --git a/src/utilities/data-layer/test.html b/src/utilities/data-layer/test.html new file mode 100644 index 0000000..6edfd78 --- /dev/null +++ b/src/utilities/data-layer/test.html @@ -0,0 +1,33 @@ + + + + + + + + + + + +

Default

+ + + + + +

Link-loading

+ + + + + +
+ + diff --git a/src/utilities/data-layer/test.js b/src/utilities/data-layer/test.js new file mode 100644 index 0000000..f800513 --- /dev/null +++ b/src/utilities/data-layer/test.js @@ -0,0 +1,50 @@ +import { DataLayerGroup, DataLayerItem, declareDependencies } from './index.js'; + +class FooData extends DataLayerGroup { + static actions = { + flushBar() { this.getItem('bar').flush(); }, + }; + + static data = { + foo: 'foo', + async bar() { + declareDependencies(this.foo); + await new Promise(resolve => setTimeout(resolve, 1000)); + return `${this.foo}bar`; + }, + baz() { return `${this.bar}baz`; }, + }; +} + +const dataLayer = new FooData(); + +const immediate = false; +dataLayer.getItem('bar').subscribe(v => console.log('!!! bar changed', v.value, v.evaluating, v._dependenciesEvaluating.size), immediate); +dataLayer.getItem('baz').subscribe(v => console.log('!!! baz changed', v.value, v.evaluating, v._dependenciesEvaluating.size), immediate); + +console.log('requesting foo'); +console.log('foo', dataLayer.foo); // foo +console.log('bar', dataLayer.bar); // null +console.log('baz', dataLayer.baz); // null +await new Promise(resolve => setTimeout(resolve, 2000)); +console.log('bar', dataLayer.bar); // foobar +console.log('baz', dataLayer.baz); // foobarbaz + +const q = new DataLayerItem(5); +console.log(q.value); + +console.log('updating foo'); +dataLayer.foo = 'foo2'; // foo2bar, foo2barbaz + +await new Promise(resolve => setTimeout(resolve, 2000)); + +console.log('flushing bar'); +dataLayer.flushBar(); // foo2bar, foo2barbaz + +try { dataLayer.bar = 'bar'; } catch (e) { console.log('caught', e.message); } +try { + new class extends DataLayerGroup { + static data = { getItem: 'foo' }; + }() + // new FooData2(); +} catch (e) { console.log('caught', e.message); } diff --git a/src/utilities/data-layer/util/abortable.js b/src/utilities/data-layer/util/abortable.js new file mode 100644 index 0000000..ad1a805 --- /dev/null +++ b/src/utilities/data-layer/util/abortable.js @@ -0,0 +1,28 @@ +export class Abortable { + constructor(cleanup = () => {}) { + this._abortPromise = new Promise((_, reject) => { this.abort = () => reject('Abortable_aborted'); }); + this._cleanup = cleanup; + } + + run(fn, onFulfilled, onRejected = () => {}) { + Promise.race([this._abortPromise, fn()]).finally(this._cleanup).then(onFulfilled).catch(err => { + if (err !== 'Abortable_aborted') onRejected(err); + }); + } +} + +export class AbortableGroup { + constructor() { + this._abortables = new Set(); + } + + abort() { + this._abortables.forEach(abortable => abortable.abort()); + } + + add() { + const abortable = new Abortable(() => this._abortables.delete(abortable)); + this._abortables.add(abortable); + return abortable; + } +}