# HG changeset patch # User cin # Date 2023-01-08 23:12:42 # Node ID cede47727a1b0867d19be1d168aceb00b5e74e3f # Parent fbe158a5752ad4d38382a265d9db5a402e4f1f01 added observable subject producer diff --git a/djx/readme.md b/djx/readme.md --- a/djx/readme.md +++ b/djx/readme.md @@ -169,7 +169,20 @@ desired methods and use the special form ### DjxWidgetBase -TODO +This is the base class for the djx widgets. It declares the abstract method +`render()` which is used to render the content of the widget, like `_TemplatedMixin`. + +This class extends `dijit/_WidgetBase` and contains logic from `_AttachMixin` thus +it is capable to handle `data-dojo-attach-*` attributes from the rendered markup. + +```tsx +@djclass +export class MyFirstWidget extends djbase(DjxWidgetBase) { + render() { + return

My first widget

; + } +} +``` ### Markup (.tsx) @@ -178,10 +191,16 @@ Add to your `tsconfig.json` the followin ```json { "compilerOptions": { - "types": ["@implab/djx"], + "types": [ + "@implab/djx", + "@implab/dojo-typings" + ], + "skipLibCheck": true, "experimentalDecorators": true, "jsxFactory": "createElement", "jsx": "react", + "target": "ES5", // minimal supported version + "lib": ["ES2015", "DOM"] } } @@ -195,4 +214,93 @@ import { createElement } from "@implab/d You are ready to go! -TODO +### Adding reactive behavior: refs, watch(...) and watchFor(...) + +This library adds some reactive traits to update the generated DOM of the widget. +Dojo 1.x adds some standard options to deal with dynamic changes: + +* `data-dojo-attach-point` allows to get reference to an element (or a nested widget) +* widget attribute mappings, allows to bind widget's property to a property of + the element, referenced by `data-dojo-attach-point`. + +The typical implementation of this technique would look like + +```tsx +import { createElement } from "@implab/djx/tsx"; +import {djclass, djbase, bind} from "@implab/djx/declare"; + +@djclass +export class MyFirstWidget extends djbase(DjxWidgetBase) { + + // @bind will generate special attribute mapping + // _setCaptionAttr = { node: "captionNode", type: "innerHTML" } + @bind({ node: "captionNode", type: "innerHTML" }) + caption = "My first widget"; + + render() { + return

; + } +} +``` + +Despite this is a natural way for the dojo it has some disadvantages: + +1. The compiler doesn't check existence of the attach-point. +2. Attribute mappings support only simple mappings, it's difficult to update the + complex rendition. + +This library helps you to get both goals with special trait `watch(...)` + +```tsx +import { createElement } from "@implab/djx/tsx"; +import { djclass, djbase} from "@implab/djx/declare" + +@djclass +export class MyFirstWidget extends djbase(DjxWidgetBase) { + + caption = "My first widget"; + + render() { + return

{watch(this,"caption", value => value)}

; + } +} +``` + +In this example we replaced attach-point with simple call to `watch` function +which renders string value to text representation (text node). It will create a +rendition which will observe the `caption` property of the widget and update its +contents according to the value changes of the property. + +The key feature of this approach that the rendering function within `watch` may +return a complex rendition. + +```tsx +// inside some widget +render() { + return
+ {watch(this,"user", value => value && [ + , + + ])} +
; +} + +private readonly _logoutClick = () => { /* do logout */ } + +``` + +The `watch` function has two forms: + +* `watch(stateful, prop, render)` - observes the specified property of the + `dojo/Stateful` object (or widget) +* `watch(observable, render)` - observes the specified observable. It supports + `rxjs` or `@implab/djx/observable` observables. + +The `render` callback may return almost anything which will be converted to DOM: + +* `boolean`, `null`, `undefined` - ignored, +* `string` - converted to text node, +* `array` - converted to DocumentFragment of its elements, +* DOM Nodes and widgets are left intact, +* any other kind of value will cause an error. + diff --git a/djx/src/main/ts/observable.ts b/djx/src/main/ts/observable.ts --- a/djx/src/main/ts/observable.ts +++ b/djx/src/main/ts/observable.ts @@ -409,7 +409,7 @@ export const stateful = (producer: Pr case "active": if (hasValue) s.next(lastValue); // if hasValue is true, - // lastValue has a valid value + // lastValue has a valid value subscribers.push(s); return () => { if (_subscribers === subscribers) { @@ -428,10 +428,56 @@ export const stateful = (producer: Pr }; }; -const subject = (producer: Producer): Producer => { +/** Create the producer which will be called once when the first subscriber is + * attached, next subscribers would share the same producer. When all + * subscribers are removed the producer will be cleaned up. + * + * Use this wrapper to prevent spawning multiple producers. + * + * @param producer The source producer + * @returns The wrapped producer + */ +export const subject = (producer: Producer): Producer => { const fusedProducer = fuse(producer); - return () => { + let subscribers: Sink[] = []; + + let cleanup = noop; + const sink: Sink = { + isClosed: () => false, + complete: () => { + const _subscribers = subscribers; + subscribers = []; + _subscribers.forEach(s => s.complete()); + cleanup(); + }, + error: e => { + const _subscribers = subscribers; + subscribers = []; + _subscribers.forEach(s => s.error(e)); + cleanup(); + }, + next: v => { + const _subscribers = subscribers; + _subscribers.forEach(s => s.next(v)); + } + }; + + return client => { + const _subscribers = subscribers; + subscribers.push(client); + if (subscribers.length === 1) + cleanup = fusedProducer(sink) ?? noop; + + return () => { + if (_subscribers === subscribers) { + const pos = subscribers.indexOf(client); + if (pos >= 0) + subscribers.splice(pos,1); + if (!subscribers.length) + cleanup(); + } + }; }; }; \ No newline at end of file diff --git a/djx/src/test/ts/plan.ts b/djx/src/test/ts/plan.ts --- a/djx/src/test/ts/plan.ts +++ b/djx/src/test/ts/plan.ts @@ -1,3 +1,4 @@ import "./declare-tests"; import "./observable-tests"; -import "./state-tests"; \ No newline at end of file +import "./state-tests"; +import "./subject-tests"; \ No newline at end of file diff --git a/djx/src/test/ts/subject-tests.ts b/djx/src/test/ts/subject-tests.ts new file mode 100644 --- /dev/null +++ b/djx/src/test/ts/subject-tests.ts @@ -0,0 +1,78 @@ +import { observe, subject } from "./observable"; +import * as tap from "tap"; + +tap.test("Subject tests", t => { + + let nextEvent: (value: string) => void = () => void (0); + + const subj1 = observe(subject(({ next }) => { + t.comment("Start subject"); + + nextEvent = next; + + return () => { + nextEvent = () => void (0); + t.comment("Stop subject"); + }; + })); + + const h1 = subj1.subscribe({ + next: v => t.comment(`h1 next: ${v}`) + }); + + nextEvent("first"); + + const h2 = subj1.subscribe({ + next: v => t.comment(`h2 next: ${v}`) + }); + + nextEvent("second"); + + h1.unsubscribe(); + + nextEvent("third"); + + h2.unsubscribe(); + + t.pass("Subject finished"); + t.end(); +}).catch(e => console.error(e)); + + +tap.test("Subject tests #2", t => { + + let nextEvent: (value: string) => void = () => void (0); + + const subj1 = observe(subject(({ next, complete }) => { + t.comment("Start subject"); + + complete(); + nextEvent = next; + + return () => { + nextEvent = () => void (0); + t.comment("Stop subject"); + }; + })); + + const h1 = subj1.subscribe({ + next: v => t.comment(`h1 next: ${v}`) + }); + + nextEvent("first"); + + const h2 = subj1.subscribe({ + next: v => t.comment(`h2 next: ${v}`) + }); + + nextEvent("second"); + + h1.unsubscribe(); + + nextEvent("third"); + + h2.unsubscribe(); + + t.pass("Subject finished"); + t.end(); +}).catch(e => console.error(e)); \ No newline at end of file diff --git a/playground/package-lock.json b/playground/package-lock.json --- a/playground/package-lock.json +++ b/playground/package-lock.json @@ -28,15 +28,6 @@ "typescript": "4.8.3" } }, - "../djx/build/npm/package": { - "name": "@implab/djx", - "dev": true, - "license": "BSD-2-Clause", - "peerDependencies": { - "@implab/core-amd": "^1.4.6", - "dojo": "^1.10.0" - } - }, "node_modules/@eslint/eslintrc": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.1.tgz", @@ -113,8 +104,13 @@ } }, "node_modules/@implab/djx": { - "resolved": "../djx/build/npm/package", - "link": true + "resolved": "file:../djx/build/npm/package", + "dev": true, + "license": "BSD-2-Clause", + "peerDependencies": { + "@implab/core-amd": "^1.4.6", + "dojo": "^1.10.0" + } }, "node_modules/@implab/dojo-typings": { "version": "1.0.2", @@ -2783,7 +2779,7 @@ "requires": {} }, "@implab/djx": { - "version": "file:../djx/build/npm/package", + "dev": true, "requires": {} }, "@implab/dojo-typings": {