├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── docs ├── 0-Overview │ ├── 0.0-Getting-Started.md │ ├── 0.1-Displaying-Data.md │ ├── 0.2-Updating-Data.md │ ├── 0.3-Computed-Properties.md │ ├── 0.4-Handling-Events.md │ └── 0.5-Arrays-and-Hashes.md ├── 1-Reactive-Programming │ └── 1.0-Computed-Chains.md ├── api │ ├── Component.md │ ├── Computed.md │ ├── Observable.md │ ├── ObservableArray.md │ ├── Template.md │ ├── VirtualNode.md │ └── index.md └── index.md ├── mkdocs.yml ├── package-lock.json ├── package.json ├── src ├── cascade │ ├── Cascade.render.test.tsx │ ├── Cascade.test.ts │ ├── Cascade.ts │ ├── Decorators.ts │ ├── ObservableArray_Decorator.test.ts │ ├── ObservableHash_Decorator.test.ts │ └── Observable_Decorator.test.ts ├── dom │ ├── Component.diff.test.tsx │ ├── Component.diff_Nested_Children.test.tsx │ ├── Component.dispose.test.tsx │ ├── Component.portal.test.tsx │ ├── Component.test.tsx │ ├── Component.toNode.test.tsx │ ├── Component.ts │ ├── Component.update.test.tsx │ ├── ComponentNode.ts │ ├── Component_Test1.test.tsx │ ├── Component_Test10.test.tsx │ ├── Component_Test2.test.tsx │ ├── Component_Test3.test.tsx │ ├── Component_Test4.test.tsx │ ├── Component_Test5.test.tsx │ ├── Component_Test6.test.tsx │ ├── Component_Test7.test.tsx │ ├── Component_Test9.test.tsx │ ├── Fragment.test.tsx │ ├── Fragment.ts │ ├── IVirtualNode.ts │ ├── Portal.tsx │ ├── Ref.ts │ ├── VirtualNode.toNode.test.tsx │ └── VirtualNode.ts ├── graph │ ├── Computed.test.ts │ ├── Computed.ts │ ├── ComputedQueue.ts │ ├── Graph.ts │ ├── IObservable.ts │ ├── Observable.alwaysNotify.test.ts │ ├── Observable.test.ts │ ├── Observable.ts │ ├── ObservableArray.test.ts │ ├── ObservableArray.ts │ ├── ObservableHash.test.ts │ ├── ObservableHash.ts │ ├── Subscribe.test.ts │ ├── graph_Test0.test.ts │ ├── graph_Test1.test.ts │ ├── graph_Test2.test.ts │ ├── graph_Test3.test.ts │ ├── graph_Test4.test.ts │ ├── graph_Test5.test.ts │ ├── graph_Test6.test.ts │ ├── graph_Test7.test.ts │ └── graph_Test8.test.ts ├── jsx │ ├── Elements.ts │ ├── HTMLElements.ts │ ├── JSX.ts │ └── SVGElements.ts ├── modules │ └── Cascade.ts └── util │ ├── CascadeError.ts │ ├── DecoratorUtil.test.ts │ ├── DecoratorUtil.ts │ ├── Diff.test.ts │ ├── Diff.ts │ └── PromiseUtil.ts ├── tsconfig.json ├── webpack.config.js └── webpack.dev.config.js /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [12.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: npm install, build, and test 21 | run: | 22 | npm ci 23 | npm test 24 | env: 25 | CI: true 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build 25 | build/Release 26 | 27 | # Dependency directory 28 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 29 | node_modules 30 | 31 | # Build directory 32 | dist/ 33 | docs/apidocs/ 34 | 35 | # Packed files 36 | *.tgz 37 | 38 | # Editor configuration files 39 | # .vscode/ 40 | 41 | # MkDocs files 42 | site 43 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules/ 3 | 4 | # Documentation directory 5 | docs/ 6 | 7 | # Build directory 8 | dist/bundle/ 9 | dist/tests/ 10 | dist/mocha/ 11 | 12 | src/tests/ 13 | src/mocha/ 14 | 15 | # log, packed files 16 | *.log 17 | *.tgz 18 | 19 | # Ignore files 20 | .gitignore 21 | .npmignore 22 | 23 | # Build configuration files 24 | tsconfig.json 25 | webpack.config.js 26 | webpack.dev.config.js 27 | .travis.yml 28 | 29 | # Editor configuration files 30 | .vscode/ 31 | 32 | # MkDocs files 33 | mkdocs.yml 34 | site 35 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "tabWidth": 4 7 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Mocha Tests", 11 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 12 | "args": [ 13 | "--timeout", 14 | "999999", 15 | "--colors", 16 | "${workspaceFolder}/dist/tests" 17 | ], 18 | "internalConsoleOptions": "openOnSessionStart", 19 | "skipFiles": [ 20 | "/**" 21 | ], 22 | "preLaunchTask": "npm: build" 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib", 3 | "[javascript]": { 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | "editor.formatOnPaste": true, 6 | "editor.formatOnSave": true, 7 | }, 8 | "[typescript]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode", 10 | "editor.formatOnPaste": true, 11 | "editor.formatOnSave": true, 12 | }, 13 | "[typescriptreact]": { 14 | "editor.defaultFormatter": "esbenp.prettier-vscode", 15 | "editor.formatOnPaste": true, 16 | "editor.formatOnSave": true, 17 | }, 18 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Sean Johnson 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cascade 2 | 3 | ![](https://github.com/sjohnsonaz/cascade/workflows/Node%20CI/badge.svg) [![npm version](https://badge.fury.io/js/cascade.svg)](https://badge.fury.io/js/cascade) 4 | 5 | A JavaScript/TypeScript library for creating modern user interfaces. It combines Reactive ViewModels with Functional DOM Components to create seamless flow of data. 6 | 7 | ## Reactive ViewModels 8 | 9 | Cascade builds ViewModels with reactive properties to synchronize data. Properties may be marked as observable, so that changes may be watched, or computed, which then watch for changes in related observables. With this, a dynamic tree of data may be built, all which is updated automatically. 10 | 11 | Furthermore, any Functional DOM Component which references an observable or computed, will be updated automatically. 12 | 13 | ### TypeScript decorators 14 | 15 | Simply use the `@observable` decorator, which will automatically detect if the property is a value, an array, or a getter function. Computed values must be declared as a getter, and arrays must be declared with their types. Observable hashes may be created with `@hash`. 16 | 17 | > **Note:** Decorators depend on TypeScript. You must set `"experimentalDecorators": true` in your `tsconfig.json` file. 18 | 19 | ```typescript 20 | class User { 21 | @observable firstName: string = ''; 22 | @observable lastName: string = ''; 23 | @observable get fullName() { 24 | return this.firstName + ' ' + this.lastName; 25 | } 26 | @observable list: number[] = [1, 2, 3, 4]; 27 | @array array: number[] = [5, 6, 7, 8]; 28 | @hash hash: {} = { 29 | 'property': 'value' 30 | }; 31 | } 32 | ``` 33 | 34 | > **Note:** Type detection for arrays depends on the optional package `reflect-metadata`. You must also set `"emitDecoratorMetadata": true` in your `tsconfig.json` file. For IE10 and below, you must also include `es6-shim` or similar polyfills. If you don't wish to install polyfills, then you must use `@array` instead of `@observable`. 35 | 36 | ### JavaScript usage 37 | 38 | You may also create observable properties directly. 39 | 40 | ```typescript 41 | Cascade.createObservable(obj: any, property: string, value?: T); 42 | 43 | Cascade.createObservableArray(obj: any, property: string, value?: Array); 44 | 45 | Cascade.createObservableHash(obj: any, property: string, value?: IHash); 46 | 47 | Cascade.createComputed(obj: any, property: string, definition: (n?: T) => T, defer?: boolean, setter?: (n: T) => any); 48 | ``` 49 | 50 | You may also create the observables as objects. Keep in mind, these are accessed as methods instead of direct usage. 51 | 52 | ```typescript 53 | Observable(value?: T); 54 | 55 | ObservableArray(value?: Array); 56 | 57 | ObservableHash(value?: IHash); 58 | 59 | Computed(definition: (n?: T) => T, defer: boolean = false, thisArg?: any, setter?: (n: T) => any); 60 | ``` 61 | 62 | > **Note:** Internet Explorer does not support `ObservableHash`. It also requires `ObservableArray` values to be modified by function calls instead of setters. 63 | > 64 | > In modern browsers which support `Proxy` objects, we can simply modify indexed values with: 65 | > 66 | > `viewModel.list[4] = 5;` 67 | > 68 | > However, in Internet Explorer, we would need to write: 69 | > 70 | > `viewModel.list.set(4, 5);` 71 | 72 | ## Functional DOM Components 73 | 74 | Cascade uses either JSX or direct JavaScript calls to create a Virtual Dom. These Virtual Nodes can then be rendered into DOM Nodes for display. 75 | 76 | ```typescript 77 | Cascade.createElement( 78 | type: string | Component, 79 | props: T, 80 | ...children: Array 81 | ): IVirtualNode; 82 | ``` 83 | 84 | Components may be defined by simply extending the Component class. Any property which references an observable will cause the Component to render any time the observable updates. 85 | 86 | ```typescript 87 | interface IUserViewProps { 88 | user: User; 89 | } 90 | 91 | class UserView extends Component { 92 | render() { 93 | return ( 94 |
{this.props.user.fullName}
95 | ); 96 | } 97 | } 98 | ``` 99 | 100 | ### Using Components 101 | 102 | Components can then be rendered by either calling 103 | 104 | ```typescript 105 | Cascade.createElement(UserView, { user: User }); 106 | ``` 107 | 108 | or with JSX by calling 109 | 110 | ```typescript 111 | 112 | ``` 113 | 114 | > **Note** Using JSX requires the options `"jsx": "react"` and `"reactNamespace": "Cascade"` in your `tsconfig.json` file. `Cascade` must also be imported into any `.jsx` or `.tsx` file. 115 | 116 | ### Component and VirtualNode Properties 117 | 118 | Components and VirtualNodes have optional props 119 | 120 | `key: string` 121 | 122 | Specifying a `key` for a Component or VirtualNode will improve rendering speeds in certain cases. This is a string, which should be unique to that node within its parent. It is most useful for a set of children which change often, such as arrays or conditional children. 123 | 124 | `ref: (n: Node) => void` 125 | 126 | A `ref` callback will receive the resulting `Node` whenever the Component or VirtualNode is rendered for the first time. This is useful for directly modifying the `Node` after rendering. 127 | 128 | ## Rendering 129 | 130 | Cascade will render directly to any DOM node specified. Simply call 131 | 132 | ```typescript 133 | Cascade.render( 134 | node: HTMLElement | string, 135 | virtualNode: IVirtualNode 136 | ): void; 137 | ``` 138 | 139 | For example 140 | 141 | ```typescript 142 | Cascade.render( 143 | document.getElementById('root'), 144 | 145 | ); 146 | ``` 147 | 148 | ## Troubleshooting and Optimization 149 | 150 | ### Computed Subscriptions 151 | 152 | Computed properties subscribe to observables simply by reading them. So any property that is read, will generate a subscription. If you don't want to subscribe, use `Cascade.peek(obj: any, property: string)` to read the value without subscribing. 153 | 154 | Also, if you need to call methods inside of a computed, those methods may read from observables as well. This behavior may or may not be what you intend. To protect against this, use `Cascade.wrapContext(callback: () => any, thisArg?: any)`, which will capture any generated subscriptions without actually subscribing to them. 155 | 156 | ### Component Subscriptions 157 | 158 | Components manage their subscriptions through the `Component.root` computed property. Internally, this calls the `Component.render` method, so any observable read while rendering will generate a subscription. In order to reduce re-renders, read observable properites as late as possible. Meaning, it's better to read inside a child component, than inside a parent and then pass the value into the child. This way only the child re-renders when the value is updated. 159 | 160 | ### Multiple Installations 161 | 162 | If a Component or Computed is not correctly updating, there may be more than one copy of Cascade referenced. There must be exactly one copy for subscriptions to be tracked correctly. 163 | -------------------------------------------------------------------------------- /docs/0-Overview/0.0-Getting-Started.md: -------------------------------------------------------------------------------- 1 | Using Cascade requires only a few steps. It's designed for TypeScript, but is completely compatible with pure JavaScript. 2 | 3 | ## Installing TypeScript 4 | 5 | For simplicity in these tutorials, you can download a simple boilerplate at [https://github.com/sjohnsonaz/ts-boilerplate](https://github.com/sjohnsonaz/ts-boilerplate). Follow its instructions to get a TypeScript environment up and running. Otherwise, you will need to set up Node, TypeScript, and Webpack to compile and run these projects. 6 | 7 | ## Installing Cascade 8 | 9 | For TypeScript, it is recommended to install Cascade via npm. It should be installed as a "dependency" for most projects, unless you're creating a shared library. Shared libraries should install Cascade as a "peerDependency". 10 | 11 | npm install cascade --save 12 | 13 | Cascade can also be downloaded from [https://github.com/sjohnsonaz/cascade/releases](https://github.com/sjohnsonaz/cascade/releases). -------------------------------------------------------------------------------- /docs/0-Overview/0.1-Displaying-Data.md: -------------------------------------------------------------------------------- 1 | In this tutorial, we take a look at Cascade, what it can do, and the fundamental parts of any Cascade project. You should already have a TypeScript environment running, and have included Cascade in your project dependencies. 2 | 3 | ## Project Organization 4 | 5 | Any Cascade project includes **Application State** and **Components**. This is similar to many common patterns such as **MVC** or **MVVM**, where you have a distinct separation between your data, and how you display it. Ultimately, the specifics of your pattern are up to you, but the overal concept is the same. We will introduce some common patterns in later tutorials. 6 | 7 | Before we get started, your TypeScript project should have a `tsconfig.json` file. Open this file, and ensure the following options are set: 8 | 9 | ```` json 10 | "jsx": "react", 11 | "reactNamespace": "Cascade", 12 | "experimentalDecorators": true, 13 | "emitDecoratorMetadata": true 14 | ```` 15 | 16 | The `emitDecoratorMetadata` value is optional, as we will see later. 17 | 18 | Also, ensure that you have `src` and `public` directories in your project. 19 | 20 | ## Building your Project 21 | 22 | It will be easier to develop our project if we can run it. Let's start by setting up a simple set of files and getting the to build. 23 | 24 | Inside the `public` directory, create a file `index.html`. This will be the main HTML file that runs the project. 25 | 26 | ```` html 27 | 28 | 29 | 30 | 31 | Tutorial 0 32 | 33 | 34 | 35 |
36 | 37 | 38 | 39 | 40 | ```` 41 | 42 | This file creates a `
` to mount our component, and then loads the script. Note that we're loading our script from `bundle/main.js`, which is a file that doesn't currently exist. We are going to build it later. 43 | 44 | Inside the `src/scripts` directory, create a file `main.tsx`. 45 | 46 | ```` TypeScript 47 | window.onload = function () { 48 | console.log('started'); 49 | } 50 | ```` 51 | 52 | We now must set up Webpack to build our `main.tsx` file. Inside the webpack configs (there are two if you're using the ts-boilerplate), ensure that the `entry` and `output` objects have the correct values. it should read: 53 | 54 | ```` javascript 55 | entry: { 56 | 'main': './src/scripts/main.tsx' 57 | }, 58 | output: { 59 | filename: './public/bundle/[name].js', 60 | libraryTarget: 'var', 61 | library: '[name]' 62 | }, 63 | ```` 64 | 65 | This is likely very similar to what is already there. Simply change `main.ts` to `main.tsx`, the filename from `dist` to `public`, and remove the `.min` portion of the filename. 66 | 67 | Now to test it, run 68 | 69 | npm run dev 70 | 71 | If you want to run automatically as you're developing, call 72 | 73 | npm run watch 74 | 75 | If you want to run a minified version of the file, call 76 | 77 | npm run min 78 | 79 | If it is successful, open the `public/index.html` file in your browser. Open the browser's console (F12 on Windows, Ctrl+Shift+J on Windows and Linux, or Cmd+Opt+J on Mac), and you should see `started` printed to the screen. 80 | 81 | ## Application State 82 | 83 | Cascade stores its data in **Application State** objects. Depending on what you want to store, they may contain values, other objects, arrays, or even methods to manipulate the data. 84 | 85 | Let's say we want to create a **Model** of a **User**. It should store common information for our users, like first and last name. We also will want to edit it. 86 | 87 | So, we start by declaring a class, and adding several properties. Inside your project, create a folder `src/scripts/models`, and inside it a file `User.ts`. 88 | 89 | ```` TypeScript 90 | export default class User { 91 | firstName: string; 92 | lastName: string; 93 | } 94 | ```` 95 | 96 | Fantastic! We now have a User class which will correctly store our data! We have also exported it as default from this file. 97 | 98 | ## Components 99 | 100 | Cascade displays its data in **Components**, which are simply classes that render to the DOM. They may render Nodes, strings, numbers, nothing, or even other Components. 101 | 102 | So, since we have our User model, let's display it! Inside your project, create a folder `src/scripts/views/user`, and inside it a file `UserView.tsx`. 103 | 104 | First, we must import Cascade into the file. 105 | 106 | ```` TypeScript 107 | import Cascade, { Component } from 'cascade'; 108 | ```` 109 | 110 | We have imported Cascade, and the Component class. While we will not necessarily use the `Cascade` import directly in our code, the JSX interpreter will transpile our JSX statements into Cascade calls. This process turns what appear to be XML elements into: 111 | 112 | ```` TypeScript 113 | Cascade.createElement(type: string | (new (props: T, ...children: Array) => Component), props: T, ...children: Array): IVirtualNode;`. 114 | ```` 115 | 116 | Now we must import our User model. 117 | 118 | ```` TypeScript 119 | import User from '../../models/User'; 120 | ```` 121 | 122 | We now must define what properties our component takes. Our component will take in a user. These appear as XML Attributes in our JSX code. 123 | 124 | ```` TypeScript 125 | export interface IUserViewProps { 126 | user: User; 127 | } 128 | ```` 129 | 130 | Next we need to define our component itself. 131 | 132 | ```` TypeScript 133 | export default class UserView extends Component { 134 | render() { 135 | let {user} = this.props; 136 | return ( 137 |
138 |

First name: {user.firstName}

139 |

Last name: {user.lastName}

140 |
141 | ); 142 | } 143 | } 144 | ```` 145 | 146 | This component takes in a user, and displays the `firstName` and `lastName` inside to `

` elements, wrapped inside one `

` element. 147 | 148 | There are a couple things to note: 149 | 150 | 1. We used the Props interface inside the Component definition. This provides intellisense both when writing the component, and later when we use it. 151 | 2. We used object destructuring to get `user` from `this.props`. This is a shorthand that is much simpler than `let user = this.props.user;`. It especially comes in handy if you're doing that for multiple properties. 152 | 3. Every component must define a render method, even if it returns nothing. 153 | 4. A component may return exactly one value. Here we wrapped our multiple `

` tags in a single `

` tag. 154 | 5. We user the `{}` notation to insert the `user` values into the elements. 155 | 156 | ## Rendering 157 | 158 | We now must render our **Application State** into **Component** and display it to the DOM. 159 | 160 | Inside the `main.tsx` we must import all of our files, and then render them. 161 | 162 | ```` TypeScript 163 | import Cascade from 'cascade' 164 | 165 | import User from './models/User'; 166 | import UserView from './views/UserView'; 167 | 168 | window.onload = function () { 169 | var user = new User(); 170 | user.firstName = 'First'; 171 | user.lastName = 'Last'; 172 | 173 | Cascade.render( 174 | document.getElementById('root'), 175 | 176 | ); 177 | }; 178 | ```` 179 | 180 | There are a couple things to note: 181 | 182 | 1. We imported Cascade, our `User`, and our `UserView`. 183 | 2. We write our code inside the onload function to ensure it runs after everything is ready. 184 | 3. We create a new user and set the first and last name properties. 185 | 5. We get the `root` element from the DOM 186 | 6. We create a new `UserView` with JSX, pass in the user. 187 | 7. We pass the `root` element and the `UserView` to `Cascade.render()`. 188 | 189 | Now build the project and run the HTML file in your browser. It should display your user in the HTML you specified. 190 | -------------------------------------------------------------------------------- /docs/0-Overview/0.2-Updating-Data.md: -------------------------------------------------------------------------------- 1 | We've successfully created a working Application. When we run it, data is presented to the browser as intended. But what happens if we want to change the data? Currently, if we change the model, nothing. Lucky for you, Cascade makes this simple. 2 | 3 | ## Observable Properties 4 | 5 | Cascade provides **Observable** properties for objects. Once established, these special properties may be **subscribed** to. Then, if the value of the property changes, the subscribers will be notified with the updated value. These special properties can be read and written to exactly like regular properties. 6 | 7 | We can use the TypeScript decorator `@observable` in the class definition to make a property observable. 8 | 9 | ```` Typescript 10 | @observable property: type = value; 11 | ```` 12 | If we don't want to use the decorator, we can attach an observable property to an object with the call: 13 | 14 | ```` Typescript 15 | Cascade.createObservable(obj: any, property: string, value?: T); 16 | ```` 17 | 18 | Let's take our `User` example from the last chapter, and make it observable. So, simply import the `@observable` decorator, and add it in front of any properties you want to observe. 19 | 20 | ```` TypeScript 21 | import { observable } from 'cascade'; 22 | 23 | export default class User { 24 | @observable firstName: string; 25 | @observable lastName: string; 26 | } 27 | ```` 28 | 29 | And there we have it! Our `firstName` and `lastName` properties are now Observables! 30 | 31 | ## Handling Input 32 | 33 | Now that we can watch for changes in our data, let's set up some inputs! 34 | 35 | ```` TypeScript 36 | export default class UserView extends Component { 37 | render() { 38 | let {user} = this.props; 39 | return ( 40 |
41 |

First name: {user.firstName}

42 |

Last name: {user.lastName}

43 |

First name:

44 |

Last name:

45 |
46 | ); 47 | } 48 | } 49 | ```` 50 | 51 | There are a couple of things to note: 52 | 53 | 1. We have included two `` tags. 54 | 2. We inject the value using `{}` notation. 55 | 56 | When you run it, you will see your data now displayed in two text fields. But what happens when you type in the inputs? Currently, still nothing. 57 | 58 | Cascade works with **one way data binding**, meaning that changes to data flow from **Application State** to **Components** not the other way around. This is to prevent **circular references**, where an update moves from A to B to C and so on, but somehow goes back to A. If that happens, we will end up in an infinite loop. 59 | 60 | So, any changes to our `User` will show up in our `UserView`, but changes to our `UserView` don't automatically go back to our `User`. In order for that to happen, we need to handle `Events`. 61 | 62 | An `Event` is triggered when something happens, like when a user clicks the mouse, or presses a key. It can even happen when an AJAX call completes. Normally, a program will execute its instructions until there is nothing left to do, and it will either end, or wait for input. So, we need to handle that input. 63 | 64 | For our `UserView` add these two methods above the `render` method. 65 | 66 | ```` TypeScript 67 | updateFirstName = (event) => { 68 | this.props.user.firstName = event.target.value; 69 | } 70 | 71 | updateLastName = (event) => { 72 | this.props.user.lastName = event.target.value; 73 | } 74 | ```` 75 | 76 | There are a couple of things to note: 77 | 78 | 1. These methods will handle the `Event`, and store the value of the target into our `User`. 79 | 2. We are using the **Arrow Function** notation, as the `this` value may be changed while executing. 80 | 81 | But how do we hook these handlers up to our inputs? 82 | 83 | ```` TypeScript 84 |

First name:

85 |

Last name:

86 | ```` 87 | 88 | And that's it! Any time the user updates the text inputs, the `Event` is triggered, and the `User` is updated. 89 | 90 | We could also use regular methods instead of Arrow Functions for `updateFirstName` and `updateLastName`. In that case, when we inject them, we must use `Function.bind()`. 91 | 92 | ```` TypeScript 93 |

First name:

94 |

Last name:

95 | ```` 96 | 97 | In many cases, this syntax is harder to read, but it is personal preference. In some cases, where you must inject a specific value into the method, `Function.bind(this, value)` is useful. 98 | 99 | ## Running our Application 100 | 101 | So, we can now display and update our `User`. Try running it and see what happens! 102 | -------------------------------------------------------------------------------- /docs/0-Overview/0.3-Computed-Properties.md: -------------------------------------------------------------------------------- 1 | We've looked at creating **Observable** properties, which let us watch for changes in our **Application State**. But how do our **Components** track those changes? 2 | 3 | If you look at the `UserView.render` method, we simply read from our `User`. Cascade tracked those changes for us automatically using **Computed** properties. For our Components, this is done behind the scenes. But we can use them in our Application State just as easily. 4 | 5 | ## Computed Properties 6 | 7 | Cascade provides **Computed** properties for objects, which use a getter function to produce a value. However, any **Observable** properties used in this function, will automatically produce subscriptions. 8 | 9 | For simplicity, we can also use the `@observable` decorator, except in front of a getter function. 10 | 11 | ```` Typescript 12 | @observable get property(): type { 13 | return this.value; 14 | } 15 | ```` 16 | 17 | For our `User` let's add a `fullName` **Computed** property. Add this right under the `firstName` and `lastName` properties. 18 | 19 | ```` Typescript 20 | @observable get fullName() { 21 | return this.firstName + ' ' + this.lastName; 22 | } 23 | ```` 24 | 25 | There are a couple things to note: 26 | 27 | 1. We simply used the values in order to subscribe to them automatically. 28 | 2. We can use as many values as we want. 29 | 3. Whatever we return from this method will be the value of the property. 30 | 31 | ## Subscribing Directly to Observables 32 | 33 | There are two main ways of subscribing to **Observable** properties. We may either subscribe directly, or through a **Computed** property, which we will examine next. 34 | 35 | ```` TypeScript 36 | Cascade.subscribe(obj: any, property: string, subscriberFunction: ISubscriberFunction); 37 | ```` 38 | 39 | The `subscriberFunction` will be called any time the value of the property changes. Keep in mind, simply storing an identical value to the property is not a change, and so there will be no notification. 40 | 41 | ## Hiding Reads 42 | 43 | Any time a **Computed** property references another **Observable** property, Cascade will automatically create a subscription. However, in some cases you may want to read, but not produce a subscription. In this case, read the **Observable** with: 44 | 45 | ```` TypeScript 46 | Cascade.peek(obj: any, property: string): any; 47 | ```` 48 | 49 | ## Update Batching 50 | 51 | Cascade attempts to reduce updates to **Computed** properties whenever possible. For example, lets say we have a some **Application State** with multiple **Observable** properties and a **Computed** property which references them. Now let's say we change all of the **Observable** properties. Cascade will only update the **Computed** once. 52 | 53 | ```` TypeScript 54 | class ViewModel { 55 | @observable a: number = 0; 56 | @observable b: number = 0; 57 | @observable c: number = 0; 58 | @computed get abc(): number { 59 | return this.a + this.b + this.c; 60 | } 61 | } 62 | 63 | // Create our Application State 64 | let viewModel = new ViewModel(); 65 | 66 | // Subscribe to our Computed 67 | Cascade.subscribe(viewModel, 'abc', (value) => { 68 | console.log(viewModel.abc); 69 | }); 70 | 71 | // Update our values 72 | viewModel.a = 1; 73 | viewModel.b = 2; 74 | viewModel.c = 3; 75 | ```` 76 | 77 | In the example above, our `console.log()` will only be called twice. 78 | 79 | ## Avoid Circular References 80 | 81 | In order for updates to flow, we must avoid any "circular references" or "cycles". This means that as an update is occurring, it must not reach the same node more than once. 82 | 83 | For example, let's say we have two **Computed** properties, A and B. And let's say A references B, and B references A. So if A is updated, then we must update B. Similarly if B is updated, we must update A. In which case updating A, will update B, which will update A, which will update B, and so on. 84 | 85 | So it is important that these situations be avoided. 86 | 87 | ## Responding to updates 88 | 89 | It is best to avoid writing to **Observable** properties inside of a **Computed**. In most cases, a second **Computed** which references the first will be enough. 90 | 91 | > **Note: absolutely do not both read and write to an Observable inside of a Computed. This will cause a circular reference.** 92 | 93 | If you must write to an **Observable** or do other work, it is best to wrap it inside of a `window.setTimeout()`, or use: 94 | 95 | ```` TypeScript 96 | Cascade.wrapContext(callback: () => any, thisArg?: any): IObservable; 97 | ```` 98 | -------------------------------------------------------------------------------- /docs/0-Overview/0.4-Handling-Events.md: -------------------------------------------------------------------------------- 1 | The DOM has built-in support for **Event-Driven** programming. This means your application can sit around and wait for user input or other **Events**. Once an `Event` has occurred, its information is sent to a function called an `EventListener`. 2 | 3 | We saw an example of this during the last section for handling `change` events from `` elements. 4 | 5 | 6 | ## Event Listeners 7 | 8 | Using classical JavaScript **DOM Element Attributes** 9 | 10 | ```` TypeScript 11 | /* Code */ 12 | function handleClick() { 13 | ... 14 | } 15 | 16 | /* View */ 17 | 57 |
58 | ); 59 | } 60 | } 61 | 62 | describe('Component', function () { 63 | it('should attach events to DOM elements', function () { 64 | var viewModel = new ViewModel(); 65 | var container = document.createElement('div'); 66 | //document.body.appendChild(container); 67 | Cascade.render(container, ); 68 | var inputElement = container.querySelector('#test9-input') as HTMLInputElement; 69 | var buttonElement = container.querySelector('#test9-button') as HTMLButtonElement; 70 | inputElement.value = '1234'; 71 | var event = document.createEvent('Event'); 72 | event.initEvent('change', true, true); 73 | inputElement.dispatchEvent(event); 74 | buttonElement.click(); 75 | expect(viewModel.clicked).toBe(1); 76 | expect(viewModel.clickedValue).toBe('1234'); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/dom/Component_Test7.test.tsx: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import Cascade from '../cascade/Cascade'; 4 | import { observable } from '../cascade/Decorators'; 5 | 6 | import { wait } from '../util/PromiseUtil'; 7 | 8 | import { Component } from './Component'; 9 | 10 | class ViewModel { 11 | runsA: number = 0; 12 | runsB: number = 0; 13 | @observable a: string = 'a'; 14 | @observable list: number[] = [1, 2, 3, 4]; 15 | } 16 | 17 | interface IParentProps { 18 | viewModel: ViewModel; 19 | } 20 | 21 | class Parent extends Component { 22 | render() { 23 | this.props.viewModel.runsA++; 24 | return ; 25 | } 26 | } 27 | 28 | interface IChildProps { 29 | id: string; 30 | viewModel: ViewModel; 31 | } 32 | 33 | class Child extends Component { 34 | render() { 35 | this.props.viewModel.runsB++; 36 | return ( 37 |
    38 | {this.props.viewModel.list.map((item) => ( 39 |
  • {item}
  • 40 | ))} 41 |
42 | ); 43 | } 44 | } 45 | 46 | describe('Component', function () { 47 | it('should updated directly nested Components with arrays', async function () { 48 | var viewModel = new ViewModel(); 49 | var container = document.createElement('div'); 50 | //document.body.appendChild(container); 51 | Cascade.render(container, ); 52 | 53 | await wait(1); 54 | 55 | viewModel.list.push(5); 56 | 57 | await wait(20); 58 | 59 | expect(container.querySelectorAll('li').length).toBe(5); 60 | expect(viewModel.runsA).toBe(1); 61 | expect(viewModel.runsB).toBe(2); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/dom/Component_Test9.test.tsx: -------------------------------------------------------------------------------- 1 | import Cascade from '../cascade/Cascade'; 2 | import { ComponentNode, VirtualNode } from '../modules/Cascade'; 3 | 4 | import Diff, { DiffOperation } from '../util/Diff'; 5 | 6 | import { Component } from './Component'; 7 | 8 | interface IComponentProps {} 9 | 10 | class OldComponent extends Component { 11 | render() { 12 | return ( 13 |
14 | Text 15 |
16 | ); 17 | } 18 | } 19 | 20 | class NewComponent extends Component { 21 | render() { 22 | return ( 23 |
24 |
Text
25 |
26 | ); 27 | } 28 | } 29 | 30 | describe('Component', function () { 31 | it('should be comparable with Diff', function () { 32 | let oldComponentNode = () as ComponentNode; 33 | let newComponentNode = () as ComponentNode; 34 | var diff = Diff.compare>( 35 | oldComponentNode.toComponent().root.children, 36 | newComponentNode.toComponent().root.children, 37 | (newNode: VirtualNode, oldNode: VirtualNode) => { 38 | var output = false; 39 | if (newNode && oldNode) { 40 | if (newNode.type == oldNode.type) { 41 | // TODO: Add key comparison 42 | output = true; 43 | } 44 | } 45 | return output; 46 | }, 47 | ); 48 | var nodesToAdd = []; 49 | var nodesToRemove = []; 50 | var nodesToLeave = []; 51 | for (var index = 0, length = diff.length; index < length; index++) { 52 | var diffItem = diff[index]; 53 | switch (diffItem.operation) { 54 | case DiffOperation.REMOVE: 55 | nodesToRemove.push(diffItem.item); 56 | break; 57 | case DiffOperation.NONE: 58 | nodesToLeave.push(diffItem.item); 59 | break; 60 | case DiffOperation.ADD: 61 | nodesToAdd.push(diffItem.item); 62 | break; 63 | } 64 | } 65 | expect(nodesToAdd.length).toBe(1); 66 | expect(nodesToRemove.length).toBe(1); 67 | expect(nodesToLeave.length).toBe(0); 68 | expect(nodesToAdd[0].type).toBe('div'); 69 | expect(nodesToRemove[0].type).toBe('span'); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/dom/Fragment.test.tsx: -------------------------------------------------------------------------------- 1 | import Cascade from '../cascade/Cascade'; 2 | import { observable } from '../cascade/Decorators'; 3 | 4 | import { wait } from '../util/PromiseUtil'; 5 | 6 | import { Component } from './Component'; 7 | 8 | describe('Fragment.toNode', function () { 9 | it('should render Fragment Nodes', () => { 10 | var root = ( 11 | <> 12 |
13 | 14 | ); 15 | let rootElement = document.createElement('div'); 16 | let element = Cascade.render(rootElement, root) as HTMLElement; 17 | let div: HTMLElement = rootElement.childNodes[0] as any; 18 | expect(div.id).toBe('testId'); 19 | }); 20 | 21 | it.skip('should be able to Diff Fragment and Element', async () => { 22 | class ViewModel { 23 | @observable value = false; 24 | } 25 | interface IViewProps { 26 | viewModel: ViewModel; 27 | } 28 | 29 | class View extends Component { 30 | render() { 31 | return ( 32 | 33 | {this.props.viewModel.value ? ( 34 |
c
35 | ) : ( 36 | <> 37 |
a
38 |
b
39 | 40 | )} 41 |
42 | ); 43 | } 44 | } 45 | var viewModel = new ViewModel(); 46 | var container = document.createElement('div'); 47 | Cascade.render(container, ); 48 | viewModel.value = true; 49 | await Cascade.track(viewModel, 'value'); 50 | 51 | let span = container.childNodes[0]; 52 | expect(span.childNodes.length).toBe(1); 53 | expect(span.childNodes[0].childNodes.length).toBe(1); 54 | expect(span.childNodes[0].textContent).toBe('c'); 55 | 56 | viewModel.value = false; 57 | await Cascade.track(viewModel, 'value'); 58 | 59 | span = container.childNodes[0]; 60 | expect(span.childNodes.length).toBe(2); 61 | expect(span.childNodes[0].textContent).toBe('a'); 62 | expect(span.childNodes[1].textContent).toBe('b'); 63 | }); 64 | 65 | it.skip('should be able to Diff Fragment and Fragment', async () => { 66 | class ViewModel { 67 | @observable value = false; 68 | } 69 | interface IViewProps { 70 | viewModel: ViewModel; 71 | } 72 | 73 | class View extends Component { 74 | render() { 75 | return ( 76 | 77 | {this.props.viewModel.value ? ( 78 | <> 79 |
c
80 | 81 | ) : ( 82 | <> 83 |
a
84 |
b
85 | 86 | )} 87 |
88 | ); 89 | } 90 | } 91 | var viewModel = new ViewModel(); 92 | var container = document.createElement('div'); 93 | Cascade.render(container, ); 94 | viewModel.value = true; 95 | await Cascade.track(viewModel, 'value'); 96 | 97 | let span = container.childNodes[0]; 98 | expect(span.childNodes.length).toBe(1); 99 | expect(span.childNodes[0].childNodes.length).toBe(1); 100 | expect(span.childNodes[0].textContent).toBe('c'); 101 | 102 | viewModel.value = false; 103 | await Cascade.track(viewModel, 'value'); 104 | 105 | span = container.childNodes[0]; 106 | expect(span.childNodes.length).toBe(2); 107 | expect(span.childNodes[0].textContent).toBe('a'); 108 | expect(span.childNodes[1].textContent).toBe('b'); 109 | }); 110 | 111 | it.skip('should be able to Diff root Fragment and Element', async () => { 112 | class ViewModel { 113 | @observable value = true; 114 | } 115 | interface IViewProps { 116 | viewModel: ViewModel; 117 | } 118 | 119 | class View extends Component { 120 | render() { 121 | if (this.props.viewModel.value) { 122 | return
c
; 123 | } else { 124 | return ( 125 | <> 126 |
a
127 |
b
128 | 129 | ); 130 | } 131 | } 132 | } 133 | var viewModel = new ViewModel(); 134 | var container = document.createElement('div'); 135 | Cascade.render(container, ); 136 | viewModel.value = false; 137 | await Cascade.track(viewModel, 'value'); 138 | 139 | let span = container.childNodes[0]; 140 | expect(span.childNodes.length).toBe(2); 141 | expect(span.childNodes[0].textContent).toBe('a'); 142 | expect(span.childNodes[1].textContent).toBe('b'); 143 | 144 | viewModel.value = true; 145 | await Cascade.track(viewModel, 'value'); 146 | 147 | span = container.childNodes[0]; 148 | expect(span.childNodes.length).toBe(2); 149 | expect(span.childNodes[0].textContent).toBe('a'); 150 | expect(span.childNodes[1].textContent).toBe('b'); 151 | }); 152 | 153 | it.skip('should be able to Diff nested root Fragment to Element', async () => { 154 | class ViewModel { 155 | @observable value = false; 156 | } 157 | interface IViewProps { 158 | viewModel: ViewModel; 159 | } 160 | class WithFragments extends Component { 161 | render() { 162 | return ( 163 | <> 164 |
a
165 |
b
166 | 167 | ); 168 | } 169 | } 170 | class WithoutFragments extends Component { 171 | render() { 172 | return
c
; 173 | } 174 | } 175 | class View extends Component { 176 | render() { 177 | return ( 178 | 179 | {this.props.viewModel.value ? : } 180 | 181 | ); 182 | } 183 | } 184 | var viewModel = new ViewModel(); 185 | var container = document.createElement('div'); 186 | Cascade.render(container, ); 187 | viewModel.value = true; 188 | 189 | await wait(20); 190 | 191 | let span = container.childNodes[0]; 192 | let div = span.childNodes[0]; 193 | let text = div.textContent; 194 | expect(span.childNodes.length).toBe(1); 195 | expect(div.childNodes.length).toBe(1); 196 | expect(text).toBe('c'); 197 | }); 198 | }); 199 | -------------------------------------------------------------------------------- /src/dom/Fragment.ts: -------------------------------------------------------------------------------- 1 | import VirtualNode from './VirtualNode'; 2 | import { IVirtualNode, IVirtualNodeProps } from './IVirtualNode'; 3 | 4 | export default class Fragment implements IVirtualNode { 5 | type: string; 6 | children: any[]; 7 | props: IVirtualNodeProps; 8 | key: string | number; 9 | element: Node; 10 | elementArray: Node[]; 11 | 12 | constructor(props?: IVirtualNodeProps, children?: Array) { 13 | this.storeProps(props, children); 14 | } 15 | 16 | storeProps(props?: IVirtualNodeProps, children?: any[]) { 17 | this.props = props || ({} as any); 18 | this.key = this.props.key; 19 | // TODO: Remove key and ref? 20 | // if (this.props.key) { 21 | // delete this.props.key; 22 | // } 23 | this.children = children; 24 | } 25 | 26 | update(props?: IVirtualNodeProps, children?: Array) { 27 | this.storeProps(props, children); 28 | } 29 | 30 | toNode() { 31 | var node = document.createDocumentFragment(); 32 | for (let index = 0, length = this.children.length; index < length; index++) { 33 | var child = this.children[index]; 34 | switch (typeof child) { 35 | case 'string': 36 | node.appendChild(document.createTextNode(child as string)); 37 | break; 38 | case 'object': 39 | if (child) { 40 | if ((child as IVirtualNode).toNode) { 41 | var renderedNode = (child as IVirtualNode).toNode(); 42 | if (renderedNode instanceof Node) { 43 | node.appendChild(renderedNode); 44 | } 45 | } else { 46 | node.appendChild(document.createTextNode(child.toString())); 47 | } 48 | } 49 | break; 50 | case 'undefined': 51 | break; 52 | // case 'number': 53 | default: 54 | node.appendChild(document.createTextNode(child.toString())); 55 | break; 56 | } 57 | } 58 | if (this.props && this.props.ref) { 59 | if (typeof this.props.ref === 'function') { 60 | this.props.ref(node); 61 | } else { 62 | this.props.ref.current = node; 63 | } 64 | } 65 | let elementArray: Node[] = []; 66 | let childNodes = node.childNodes; 67 | for (let index = 0, length = childNodes.length; index < length; index++) { 68 | elementArray.push(childNodes[index]); 69 | } 70 | this.elementArray = elementArray; 71 | this.element = node; 72 | return node; 73 | } 74 | 75 | toString() { 76 | var container = document.createElement('div') as HTMLElement; 77 | container.appendChild(this.toNode()); 78 | return container.innerHTML; 79 | } 80 | 81 | dispose() { 82 | 83 | } 84 | 85 | getChildLength() { 86 | let childLength = 0; 87 | for (let child of this.children) { 88 | if (child !== null && child !== undefined) { 89 | if (child.getChildLength) { 90 | childLength += child.getChildLength(); 91 | } else if (child.children) { 92 | childLength += child.children.length; 93 | } else { 94 | childLength++; 95 | } 96 | } 97 | } 98 | return childLength; 99 | } 100 | } -------------------------------------------------------------------------------- /src/dom/IVirtualNode.ts: -------------------------------------------------------------------------------- 1 | import Ref from './Ref'; 2 | 3 | export interface IVirtualNodeProps { 4 | key?: string | number; 5 | ref?: Ref | ((node: Node) => void); 6 | } 7 | 8 | export interface IVirtualElementProps extends IVirtualNodeProps { 9 | xmlns?: string; 10 | } 11 | 12 | export interface IVirtualNode { 13 | props: T & IVirtualNodeProps; 14 | children: any[]; 15 | key: string | number; 16 | 17 | toNode(namespace?: string): Node; 18 | toString(): string; 19 | } 20 | -------------------------------------------------------------------------------- /src/dom/Portal.tsx: -------------------------------------------------------------------------------- 1 | import Cascade from '../modules/Cascade'; 2 | import { Component } from "./Component"; 3 | 4 | export interface IPortalProps { 5 | element: HTMLElement; 6 | remove?: boolean; 7 | } 8 | 9 | export default class Portal extends Component { 10 | portal = true; 11 | 12 | render() { 13 | return ( 14 |
{this.children}
15 | ); 16 | } 17 | 18 | afterRender(node: Node, updated: boolean) { 19 | if (!this.props.element.contains(node)) { 20 | if (!this.props.remove) { 21 | this.props.element.appendChild(node); 22 | } 23 | } else { 24 | if (this.props.remove) { 25 | this.props.element.removeChild(node); 26 | } 27 | } 28 | } 29 | 30 | afterDispose(node: Node) { 31 | if (this.props.element.contains(node)) { 32 | this.props.element.removeChild(node); 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /src/dom/Ref.ts: -------------------------------------------------------------------------------- 1 | export default class Ref { 2 | current: T; 3 | } -------------------------------------------------------------------------------- /src/dom/VirtualNode.toNode.test.tsx: -------------------------------------------------------------------------------- 1 | import Cascade from '../cascade/Cascade'; 2 | 3 | import { Component } from './Component'; 4 | import VirtualNode from './VirtualNode'; 5 | 6 | describe('VirtualNode.toNode', function () { 7 | it('should render a Node', function () { 8 | var root = new VirtualNode('div', {}, ['text']); 9 | var node = root.toNode(); 10 | expect(node.textContent).toBe('text'); 11 | }); 12 | 13 | it('should render recursively', function () { 14 | var root = new VirtualNode('div', { id: 'parent' }, [ 15 | new VirtualNode('span', { id: 'child' }, []), 16 | ]); 17 | var node = root.toNode(); 18 | var child = node.querySelector('#child'); 19 | expect(!!child).toBe(true); 20 | }); 21 | 22 | it('should render with JSX', function () { 23 | var root = ( 24 |
25 | text 26 |
27 | ); 28 | let element = Cascade.render(document.createElement('div'), root) as HTMLElement; 29 | var child = element.querySelector('#child'); 30 | expect(!!child).toBe(true); 31 | }); 32 | 33 | it('should not render undefined values', () => { 34 | var root =
{}
; 35 | let element = Cascade.render(document.createElement('div'), root); 36 | expect(element.childNodes.length).toBe(0); 37 | }); 38 | 39 | it('should not render null values', () => { 40 | var root =
{null}
; 41 | let element = Cascade.render(document.createElement('div'), root) as HTMLElement; 42 | expect(element.childNodes.length).toBe(0); 43 | }); 44 | 45 | it('should render falsy values', () => { 46 | var root =
{0}
; 47 | let element = Cascade.render(document.createElement('div'), root) as HTMLElement; 48 | expect((element.childNodes[0] as Text).data).toBe('0'); 49 | }); 50 | 51 | it('should render Object.toString for Object values', () => { 52 | var object = { 53 | toString: function () { 54 | return 'String output'; 55 | }, 56 | }; 57 | var root =
{object}
; 58 | let element = Cascade.render(document.createElement('div'), root) as HTMLElement; 59 | expect((element.childNodes[0] as Text).data).toBe('String output'); 60 | }); 61 | 62 | it('should render standard attributes', () => { 63 | var root =
; 64 | let element = Cascade.render(document.createElement('div'), root) as HTMLElement; 65 | expect(element.id).toBe('testId'); 66 | }); 67 | 68 | it('should not render undefined attributes', () => { 69 | var root =
; 70 | let element = Cascade.render(document.createElement('div'), root) as HTMLElement; 71 | expect(element.id).not.toBe('undefined'); 72 | }); 73 | 74 | it('should not render null attributes', () => { 75 | var root =
; 76 | let element = Cascade.render(document.createElement('div'), root) as HTMLElement; 77 | expect(element.id).not.toBe('null'); 78 | }); 79 | 80 | it('should render form attributes', () => { 81 | var root = ( 82 |
83 |
84 | 85 |
86 | ); 87 | let element = Cascade.render(document.createElement('div'), root) as HTMLElement; 88 | expect((element.childNodes[1] as HTMLInputElement).getAttribute('form')).toBe('formId'); 89 | }); 90 | 91 | it('should render custom attributes', () => { 92 | var root =
; 93 | let element = Cascade.render(document.createElement('div'), root) as HTMLElement; 94 | expect(element.getAttribute('data-custom')).toBe('test value'); 95 | }); 96 | 97 | it('should render role attributes', () => { 98 | var root =
; 99 | let element = Cascade.render(document.createElement('div'), root) as HTMLElement; 100 | expect(element.getAttribute('role')).toBe('button'); 101 | }); 102 | 103 | it('should render aria attributes', () => { 104 | var root =
; 105 | let element = Cascade.render(document.createElement('div'), root) as HTMLElement; 106 | expect(element.getAttribute('aria-label')).toBe('test value'); 107 | }); 108 | 109 | it('should render style attribute objects', () => { 110 | var root =
; 111 | let element = Cascade.render(document.createElement('div'), root) as HTMLElement; 112 | expect(element.style.width).toBe('100%'); 113 | }); 114 | 115 | it('should ignore undefined and null properties of style attribute objects', () => { 116 | var root = ( 117 |
125 | ); 126 | let element = Cascade.render(document.createElement('div'), root) as HTMLElement; 127 | expect(element.style.getPropertyValue('--test-0')).toBe(''); 128 | expect(element.style.getPropertyValue('--test-1')).toBe(''); 129 | }); 130 | 131 | it('should render style attribute strings', () => { 132 | var root =
; 133 | let element = Cascade.render(document.createElement('div'), root) as HTMLElement; 134 | expect(element.style.width).toBe('100%'); 135 | }); 136 | 137 | it('should render event attributes with function references', () => { 138 | let count = 0; 139 | var root = ( 140 | 147 | ); 148 | let element = Cascade.render(document.createElement('div'), root) as HTMLElement; 149 | element.click(); 150 | expect(count).toBe(1); 151 | }); 152 | 153 | it('should render SVG elements', function () { 154 | if (typeof SVGElement === 'undefined') this.skip(); 155 | var root = ( 156 | 157 | 158 | Sorry, your browser does not support inline SVG. 159 | 160 | ); 161 | let element = Cascade.render(document.createElement('div'), root) as HTMLElement; 162 | let path = element.childNodes[0] as SVGElement; 163 | expect(path.getAttribute('d')).toBe('M 150 0 L 75 200 L 225 200 Z'); 164 | }); 165 | 166 | it('should render Components', function () { 167 | interface ICustomComponentProps { 168 | id: string; 169 | info: string; 170 | } 171 | 172 | class CustomComponent extends Component { 173 | render() { 174 | return
Custom Component - {this.props.info}
; 175 | } 176 | } 177 | 178 | var root = ( 179 |
180 | 181 | text 182 | 183 |
184 | ); 185 | 186 | let element = Cascade.render(document.createElement('div'), root) as HTMLElement; 187 | var child = element.querySelector('#child'); 188 | expect(child.textContent).toBe('Custom Component - test'); 189 | }); 190 | 191 | it('should render children before attributes', async () => { 192 | var root = ( 193 | 197 | ); 198 | let select = Cascade.render(document.createElement('div'), root) as HTMLSelectElement; 199 | expect(select.value).toBe('2'); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /src/dom/VirtualNode.ts: -------------------------------------------------------------------------------- 1 | import { IVirtualNode, IVirtualElementProps } from './IVirtualNode'; 2 | import Cascade from '../cascade/Cascade'; 3 | 4 | export default class VirtualNode implements IVirtualNode { 5 | type: string; 6 | props: T & IVirtualElementProps; 7 | children: any[]; 8 | key: string | number; 9 | element: Node; 10 | 11 | constructor(type: string, props?: T & IVirtualElementProps, children?: Array) { 12 | this.type = type; 13 | this.props = props || ({} as any); 14 | this.key = this.props.key; 15 | // TODO: Remove key and ref? 16 | // if (this.props.key) { 17 | // delete this.props.key; 18 | // } 19 | this.children = children; 20 | } 21 | 22 | toNode(namespace?: string) { 23 | let node: HTMLElement; 24 | namespace = namespace || this.props.xmlns; 25 | if (namespace) { 26 | // Casting potential Element to HtmlElement. 27 | node = document.createElementNS(namespace, this.type) as any; 28 | } else { 29 | node = document.createElement(this.type); 30 | } 31 | for (var index = 0, length = this.children.length; index < length; index++) { 32 | var child = this.children[index]; 33 | switch (typeof child) { 34 | case 'string': 35 | node.appendChild(document.createTextNode(child as string)); 36 | break; 37 | case 'object': 38 | if (child) { 39 | if ((child as IVirtualNode).toNode) { 40 | var renderedNode = (child as IVirtualNode).toNode(namespace); 41 | if (renderedNode instanceof Node) { 42 | node.appendChild(renderedNode); 43 | } 44 | } else { 45 | node.appendChild(document.createTextNode(child.toString())); 46 | } 47 | } 48 | break; 49 | case 'undefined': 50 | break; 51 | // case 'number': 52 | default: 53 | node.appendChild(document.createTextNode(child.toString())); 54 | break; 55 | } 56 | } 57 | for (var name in this.props) { 58 | if (this.props.hasOwnProperty(name)) { 59 | let value = this.props[name]; 60 | if (value !== undefined && value !== null) { 61 | VirtualNode.setAttribute(node, name, this.props[name], namespace); 62 | } 63 | } 64 | } 65 | if (this.props && this.props.ref) { 66 | if (typeof this.props.ref === 'function') { 67 | this.props.ref(node); 68 | } else { 69 | this.props.ref.current = node; 70 | } 71 | } 72 | this.element = node; 73 | return node; 74 | } 75 | 76 | toString() { 77 | var container = document.createElement('div') as HTMLElement; 78 | container.appendChild(this.toNode()); 79 | return container.innerHTML; 80 | } 81 | 82 | static fixChildrenArrays(children: Array, fixedChildren?: any[]) { 83 | fixedChildren = fixedChildren || []; 84 | if (children) { 85 | for (var index = 0, length = children.length; index < length; index++) { 86 | var child = children[index]; 87 | // Remove undefined and null elements 88 | if (typeof child !== 'undefined' && child !== null) { 89 | if (child instanceof Array) { 90 | VirtualNode.fixChildrenArrays(child, fixedChildren); 91 | } else { 92 | fixedChildren.push(child); 93 | } 94 | } 95 | } 96 | } 97 | return fixedChildren; 98 | } 99 | 100 | static createCssText(style: Partial) { 101 | let dest = []; 102 | for (let index in style) { 103 | if (style.hasOwnProperty(index)) { 104 | let name = index.replace(/$([a-z])$([A-Z])/, stringReplacer); 105 | let value = style[index]; 106 | if (value !== undefined && value !== null) { 107 | dest.push(name + ': ' + value); 108 | } 109 | } 110 | } 111 | return dest.join('; '); 112 | } 113 | 114 | static setAttribute(element: HTMLElement, property: string, value: any, namespace?: string) { 115 | if (!namespace) { 116 | if (property === 'style') { 117 | if (typeof value === 'string') { 118 | element.style.cssText = value; 119 | } else { 120 | element.style.cssText = this.createCssText(value); 121 | } 122 | } else if (property.indexOf('-') >= 0) { 123 | element.setAttribute(property, value); 124 | } else if (property === 'class') { 125 | element.setAttribute(property, value); 126 | } else if (property === 'role') { 127 | element.setAttribute(property, value); 128 | } else { 129 | try { 130 | element[property] = value; 131 | } catch (e) { 132 | element.setAttribute(property, value); 133 | } 134 | } 135 | } else { 136 | if (property === 'style') { 137 | element.style.cssText = value; 138 | } else if (property.indexOf('on') === 0) { 139 | element[property] = value; 140 | } else if (property === 'className') { 141 | element[property] = value; 142 | } else if (property === 'href') { 143 | // TODO: Remove once Safari fixes href 144 | if (Cascade.xlinkDeprecated) { 145 | element.setAttribute('href', value); 146 | } else { 147 | element.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', value); 148 | } 149 | } else { 150 | element.setAttribute(property, value); 151 | } 152 | } 153 | } 154 | 155 | // TODO: Should we both set to empty string and delete? 156 | static removeAttribute(element: HTMLElement, property: string, namespace?: string) { 157 | if (!namespace) { 158 | if (property === 'style') { 159 | element.style.cssText = undefined; 160 | } else if (property.indexOf('-') >= 0) { 161 | element.removeAttribute(property); 162 | } else if (property === 'class') { 163 | element.removeAttribute(property); 164 | } else if (property === 'role') { 165 | element.removeAttribute(property); 166 | } else { 167 | try { 168 | element[property] = ''; 169 | delete element[property]; 170 | } catch (e) { 171 | element.removeAttribute(property); 172 | } 173 | } 174 | } else { 175 | if (property === 'style') { 176 | element.style.cssText = undefined; 177 | } else if (property.indexOf('on') >= 0) { 178 | element[property] = ''; 179 | delete element[property]; 180 | } else if (property === 'className') { 181 | element[property] = ''; 182 | delete element[property]; 183 | } else if (property === 'xmlns') { 184 | // do nothing 185 | } else { 186 | element.removeAttribute(property); 187 | } 188 | } 189 | } 190 | } 191 | 192 | function stringReplacer(match: string, lowerLetter: string, upperLetter: string) { 193 | return lowerLetter + '-' + upperLetter.toLowerCase(); 194 | } -------------------------------------------------------------------------------- /src/graph/Computed.test.ts: -------------------------------------------------------------------------------- 1 | import Cascade from '../cascade/Cascade'; 2 | import { observable } from '../cascade/Decorators'; 3 | 4 | import { CascadeError } from '../util/CascadeError'; 5 | 6 | import Computed from './Computed'; 7 | import { IObservable } from './IObservable'; 8 | import Observable from './Observable'; 9 | 10 | describe('Computed', () => { 11 | it('should compute non-observable values', () => { 12 | var value = new Computed(() => { 13 | return 1; 14 | }); 15 | expect(value.getValue()).toBe(1); 16 | }); 17 | 18 | it('should compute observable values', () => { 19 | var obs = new Observable(1); 20 | var value = new Computed(() => { 21 | return obs.getValue(); 22 | }); 23 | expect(value.getValue()).toBe(1); 24 | }); 25 | 26 | it('should subscribe once per Observable', () => { 27 | var obs = new Observable(1); 28 | let readCount = 0; 29 | let referenceCount = 0; 30 | var value = new Computed(() => { 31 | obs.getValue(); 32 | let observableContext = window['$_cascade_observable_context']; 33 | let context: IObservable[] = observableContext.context; 34 | let output = obs.getValue(); 35 | readCount = context.length; 36 | return output; 37 | }); 38 | referenceCount = value.references.length; 39 | expect(readCount).toBe(2); 40 | expect(referenceCount).toBe(1); 41 | }); 42 | }); 43 | 44 | describe('Cascade.createComputed', () => { 45 | it('should compute non-observable values', () => { 46 | class ViewModel { 47 | value: number; 48 | constructor() { 49 | Cascade.createComputed(this, 'value', () => { 50 | return 1; 51 | }); 52 | } 53 | } 54 | var viewModel = new ViewModel(); 55 | expect(viewModel.value).toBe(1); 56 | }); 57 | 58 | it('should compute observable values', () => { 59 | class ViewModel { 60 | value: number; 61 | obs: number; 62 | constructor() { 63 | Cascade.createObservable(this, 'obs', 1); 64 | Cascade.createComputed(this, 'value', () => { 65 | return this.obs; 66 | }); 67 | } 68 | } 69 | var viewModel = new ViewModel(); 70 | expect(viewModel.value).toBe(1); 71 | }); 72 | }); 73 | 74 | describe('Computed @observable Decorator', () => { 75 | it('should compute non-observable values', () => { 76 | class ViewModel { 77 | @observable get value() { 78 | return 1; 79 | } 80 | } 81 | var viewModel = new ViewModel(); 82 | expect(viewModel.value).toBe(1); 83 | }); 84 | 85 | it('should compute observable values', () => { 86 | class ViewModel { 87 | @observable obs = 1; 88 | @observable get value() { 89 | return this.obs; 90 | } 91 | } 92 | var viewModel = new ViewModel(); 93 | expect(viewModel.value).toBe(1); 94 | }); 95 | }); 96 | 97 | describe('Cascade.waitToEqual', () => { 98 | it('should run on equal, and not run twice', async () => { 99 | class ViewModel { 100 | @observable a: boolean = false; 101 | } 102 | var viewModel = new ViewModel(); 103 | window.setTimeout(() => { 104 | viewModel.a = true; 105 | window.setTimeout(() => { 106 | viewModel.a = false; 107 | }, 10); 108 | }, 10); 109 | let result = await Cascade.waitToEqual(viewModel, 'a', true, 100); 110 | expect(result).toBe(true); 111 | }); 112 | 113 | it('should not run if not equal, and then throw an error if time elapses', async () => { 114 | class ViewModel { 115 | @observable a: boolean = false; 116 | } 117 | var viewModel = new ViewModel(); 118 | window.setTimeout(() => { 119 | viewModel.a = undefined; 120 | }, 10); 121 | try { 122 | var result = await Cascade.waitToEqual(viewModel, 'a', true, 100); 123 | } catch (e) { 124 | expect(e).toBeDefined(); 125 | expect((e as Error).message).toBe(CascadeError.TimeoutElapsed); 126 | } 127 | expect(result).not.toBe(true); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /src/graph/Computed.ts: -------------------------------------------------------------------------------- 1 | import { IObservable, ISubscriber } from './IObservable'; 2 | import Observable from './Observable'; 3 | import ComputedQueue from './ComputedQueue'; 4 | 5 | export default class Computed extends Observable implements ISubscriber { 6 | 7 | references: IObservable[]; 8 | definition: (n?: T) => T; 9 | setter: (n: T) => any; 10 | thisArg: any; 11 | dirty: boolean; 12 | disposed: boolean; 13 | error: Error; 14 | 15 | constructor(definition: (n?: T) => T, defer: boolean = false, thisArg?: any, setter?: (n: T) => any) { 16 | super(undefined); 17 | this.references = []; 18 | this.definition = definition; 19 | this.thisArg = thisArg; 20 | this.setter = setter; 21 | if (defer) { 22 | this.dirty = true; 23 | } else { 24 | this.value = this.runDefinition(definition); 25 | this.dirty = false; 26 | } 27 | } 28 | 29 | getValue() { 30 | super.getValue(); 31 | if (this.dirty) { 32 | this.runUpdate(); 33 | } 34 | return this.value; 35 | } 36 | 37 | update() { 38 | this.dirty = true; 39 | return this.getValue(); 40 | } 41 | 42 | peek() { 43 | if (this.dirty) { 44 | this.runUpdate(); 45 | } 46 | return this.value; 47 | } 48 | 49 | peekDirty() { 50 | return this.value; 51 | } 52 | 53 | setValue(value: T) { 54 | if (this.setter) { 55 | let newValue = this.setter(value); 56 | if (this.value !== newValue || this.alwaysNotify) { 57 | var oldValue = this.value; 58 | this.value = newValue; 59 | return this.publish(newValue, oldValue); 60 | } else { 61 | return Promise.resolve(); 62 | } 63 | } else { 64 | return Promise.resolve(); 65 | } 66 | } 67 | 68 | notify() { 69 | if (!this.disposed) { 70 | this.notifyDirty(); 71 | if (ComputedQueue.computedQueue.completed) { 72 | ComputedQueue.computedQueue = new ComputedQueue(); 73 | } 74 | return ComputedQueue.computedQueue.add(this); 75 | } else { 76 | return Promise.resolve(); 77 | } 78 | } 79 | 80 | notifyDirty() { 81 | if (!this.dirty) { 82 | this.dirty = true; 83 | for (var index = 0, length = this.subscribers.length; index < length; index++) { 84 | var subscriber = this.subscribers[index]; 85 | if (subscriber instanceof Computed) { 86 | subscriber.notifyDirty(); 87 | } 88 | } 89 | } 90 | } 91 | 92 | async runUpdate() { 93 | if (!this.disposed && this.dirty) { 94 | var value = this.value; 95 | this.value = this.runDefinition(this.definition); 96 | this.dirty = false; 97 | if (this.value !== value || this.alwaysNotify) { 98 | await this.publish(this.value, value); 99 | } 100 | } 101 | return this.value; 102 | } 103 | 104 | runOnly() { 105 | this.value = this.runDefinition(this.definition); 106 | this.dirty = false; 107 | return this.value; 108 | } 109 | 110 | runDefinition(definition: (n: T) => T) { 111 | //TODO: Reduce unsubscribe calls. 112 | for (var index = 0, length: number = this.references.length; index < length; index++) { 113 | var reference = this.references[index]; 114 | reference.unsubscribe(this); 115 | } 116 | 117 | Observable.pushContext(); 118 | this.error = undefined; 119 | try { 120 | var output: T; 121 | if (this.thisArg) { 122 | output = definition.call(this.thisArg, this.value); 123 | } else { 124 | output = definition(this.value); 125 | } 126 | } catch (e) { 127 | this.error = e; 128 | console.error(e); 129 | } 130 | var context = Observable.popContext(); 131 | if (!this.error) { 132 | //TODO: Use non-hash method to prevent redundant subscriptions. 133 | let hash: { 134 | [index: string]: IObservable; 135 | } = {}; 136 | let references: IObservable[] = []; 137 | for (var index = 0, length: number = context.length; index < length; index++) { 138 | var reference = context[index]; 139 | if (!hash[reference.id]) { 140 | hash[reference.id] = reference; 141 | references.push(reference); 142 | reference.subscribeOnly(this); 143 | } 144 | } 145 | this.references = references; 146 | } 147 | return output; 148 | } 149 | 150 | dispose(recursive?: boolean) { 151 | super.dispose(recursive); 152 | this.disposed = true; 153 | if (recursive) { 154 | for (var index = 0, length = this.references.length; index < length; index++) { 155 | var reference = this.references[index]; 156 | reference.dispose(true); 157 | } 158 | } else { 159 | for (var index = 0, length = this.references.length; index < length; index++) { 160 | var reference = this.references[index]; 161 | reference.unsubscribe(this); 162 | } 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/graph/ComputedQueue.ts: -------------------------------------------------------------------------------- 1 | import Computed from './Computed'; 2 | import { wait } from '../util/PromiseUtil'; 3 | 4 | export interface ComputedHash { 5 | [index: string]: Computed; 6 | } 7 | 8 | export default class ComputedQueue { 9 | items: Computed[]; 10 | hash: ComputedHash; 11 | scheduled: boolean; 12 | completed: boolean; 13 | promise: Promise; 14 | 15 | static computedQueue: ComputedQueue = new ComputedQueue(); 16 | 17 | constructor() { 18 | this.items = []; 19 | this.hash = {}; 20 | this.scheduled = false; 21 | this.completed = false; 22 | } 23 | 24 | add(computed: Computed) { 25 | if (!this.hash[computed.id]) { 26 | this.hash[computed.id] = computed; 27 | this.items.push(computed); 28 | if (!this.scheduled) { 29 | this.scheduled = true; 30 | this.promise = new Promise((resolve) => { 31 | window.setTimeout(async () => { 32 | this.completed = true; 33 | await Promise.all(this.items.map(computed => computed.runUpdate())); 34 | resolve(); 35 | }, 0); 36 | }); 37 | } 38 | return this.promise; 39 | } else { 40 | return this.promise; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/graph/Graph.ts: -------------------------------------------------------------------------------- 1 | import { IObservable, ISubscriber, ISubscriberFunction } from './IObservable'; 2 | import Computed from './Computed'; 3 | 4 | /** 5 | * 6 | */ 7 | export interface ObservableIndex { 8 | [index: string]: IObservable; 9 | } 10 | 11 | /** 12 | * 13 | */ 14 | export default class Graph { 15 | parent: any; 16 | observables: ObservableIndex = {}; 17 | 18 | constructor(parent?: any) { 19 | this.parent = parent; 20 | } 21 | 22 | /** 23 | * 24 | * @param property 25 | */ 26 | peek(property: U) { 27 | return this.observables[property as string].peek() as T[U]; 28 | } 29 | 30 | peekDirty(property: U) { 31 | return this.observables[property as string].peekDirty() as T[U]; 32 | } 33 | 34 | track(property: U) { 35 | return this.observables[property as string].track(); 36 | } 37 | 38 | trackAll() { 39 | return Promise.all(Object.values(this.observables).map(observable => observable.track())); 40 | } 41 | 42 | /** 43 | * 44 | * @param property 45 | */ 46 | getReferences(property: U) { 47 | var observable = this.observables[property as string] as IObservable; 48 | if (observable instanceof Computed) { 49 | return observable.references; 50 | } else { 51 | return []; 52 | } 53 | } 54 | 55 | /** 56 | * 57 | * @param property 58 | */ 59 | getSubscribers(property: U) { 60 | var observable = this.observables[property as string] as IObservable; 61 | if (observable) { 62 | return observable.subscribers; 63 | } else { 64 | return []; 65 | } 66 | } 67 | 68 | /** 69 | * 70 | */ 71 | dispose() { 72 | for (var index in this.observables) { 73 | if (this.observables.hasOwnProperty(index)) { 74 | this.observables[index].dispose(); 75 | } 76 | } 77 | } 78 | 79 | /** 80 | * 81 | */ 82 | disposeAll() { 83 | for (var index in this.observables) { 84 | if (this.observables.hasOwnProperty(index)) { 85 | var observable = this.observables[index]; 86 | var value = observable.peek(); 87 | if (value && value._graph) { 88 | value._graph.disposeAll(); 89 | } 90 | observable.dispose(); 91 | } 92 | } 93 | } 94 | 95 | /** 96 | * 97 | * @param property 98 | * @param subscriber 99 | */ 100 | subscribe(property: U, subscriber: ISubscriber | ISubscriberFunction) { 101 | if (!this.observables[property as string]) { 102 | // Force value to update. 103 | var value = this.parent[property]; 104 | } 105 | this.observables[property as string].subscribe(subscriber); 106 | return value; 107 | } 108 | 109 | /** 110 | * 111 | * @param property 112 | * @param subscriber 113 | */ 114 | subscribeOnly(property: U, subscriber: ISubscriber | ISubscriberFunction) { 115 | if (!this.observables[property as string]) { 116 | // Force value to update. 117 | var value = this.parent[property]; 118 | } 119 | this.observables[property as string].subscribeOnly(subscriber); 120 | return value; 121 | } 122 | 123 | /** 124 | * 125 | * @param property 126 | * @param subscriber 127 | */ 128 | unsubscribe(property: U, subscriber: ISubscriber | ISubscriberFunction) { 129 | if (this.observables[property as string]) { 130 | this.observables[property as string].unsubscribe(subscriber); 131 | } 132 | } 133 | 134 | /** 135 | * 136 | * @param property 137 | * @param alwaysNotify 138 | */ 139 | setAlwaysNotify(property: U, alwaysNotify: boolean) { 140 | if (!this.observables[property as string]) { 141 | // Force value to update. 142 | var value = this.parent[property]; 143 | } 144 | this.observables[property as string].alwaysNotify = alwaysNotify; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/graph/IObservable.ts: -------------------------------------------------------------------------------- 1 | export interface ISubscriber { 2 | notify(): Promise; 3 | dispose(recursive?: boolean): void; 4 | } 5 | 6 | export interface ISubscriberFunction { 7 | (n: T, o?: T): void; 8 | } 9 | 10 | export interface IObservable { 11 | id: number; 12 | subscribers: (ISubscriber | ISubscriberFunction)[]; 13 | value: T; 14 | alwaysNotify: boolean; 15 | getValue(): T; 16 | peek(): T; 17 | peekDirty(): T; 18 | track(): Promise; 19 | setValue(value: T): Promise; 20 | subscribeOnly(subscriber: ISubscriber | ISubscriberFunction): void; 21 | subscribe(subscriber: ISubscriber | ISubscriberFunction): void; 22 | unsubscribe(subscriber: ISubscriber | ISubscriberFunction): void; 23 | publish(value: T, oldValue?: T): Promise; 24 | dispose(recursive?: boolean): void; 25 | } 26 | 27 | export interface IArray extends Array { 28 | set?: (index: number, value: T) => void; 29 | } 30 | 31 | export interface IHash { 32 | [index: string]: T; 33 | } -------------------------------------------------------------------------------- /src/graph/Observable.alwaysNotify.test.ts: -------------------------------------------------------------------------------- 1 | import Cascade from '../cascade/Cascade'; 2 | import { array, hash, observable } from '../cascade/Decorators'; 3 | import { IHash } from './IObservable'; 4 | 5 | describe('Observable.alwaysNotify', () => { 6 | class State { 7 | @observable value: number = 0; 8 | @hash hash: IHash; 9 | @array array: number[]; 10 | } 11 | 12 | it('should notify for Observable', async () => { 13 | let state = new State(); 14 | let values = []; 15 | Cascade.setAlwaysNotify(state, 'value', true); 16 | Cascade.subscribe(state, 'value', (value) => { 17 | values.push(value); 18 | }); 19 | state.value = 1; 20 | await Cascade.track(state, 'value'); 21 | state.value = 1; 22 | await Cascade.track(state, 'value'); 23 | 24 | expect(values.length).toBe(3); 25 | expect(values[0]).toBe(0); 26 | expect(values[1]).toBe(1); 27 | expect(values[2]).toBe(1); 28 | }); 29 | 30 | it('should notify for ObservableArray', async () => { 31 | let state = new State(); 32 | let arrays = []; 33 | Cascade.setAlwaysNotify(state, 'array', true); 34 | Cascade.subscribe(state, 'array', (value) => { 35 | arrays.push(value); 36 | }); 37 | 38 | let array = [1]; 39 | 40 | state.array = array; 41 | await Cascade.track(state, 'array'); 42 | state.array = array; 43 | await Cascade.track(state, 'array'); 44 | 45 | expect(arrays.length).toBe(3); 46 | expect(arrays[0].length).toBe(0); 47 | expect(arrays[1][0]).toBe(1); 48 | expect(arrays[2][0]).toBe(1); 49 | }); 50 | 51 | it('should notify for ObservableHash', async () => { 52 | let state = new State(); 53 | let values = []; 54 | Cascade.setAlwaysNotify(state, 'hash', true); 55 | Cascade.subscribe(state, 'hash', (value) => { 56 | values.push(value['test']); 57 | }); 58 | 59 | let hash = { test: 1 }; 60 | 61 | state.hash = hash; 62 | await Cascade.track(state, 'hash'); 63 | state.hash = hash; 64 | await Cascade.track(state, 'hash'); 65 | 66 | expect(values.length).toBe(3); 67 | expect(values[0]).toBe(undefined); 68 | expect(values[1]).toBe(1); 69 | expect(values[2]).toBe(1); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/graph/Observable.test.ts: -------------------------------------------------------------------------------- 1 | import Observable from './Observable'; 2 | 3 | describe('Observable', () => { 4 | it('should initialize to undefined', () => { 5 | var value = new Observable(); 6 | expect(value.getValue()).toBe(undefined); 7 | }); 8 | 9 | it('should initialize in the constructor to a value', () => { 10 | var value = new Observable(1); 11 | expect(value.getValue()).toBe(1); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/graph/Observable.ts: -------------------------------------------------------------------------------- 1 | import { IObservable, ISubscriber, ISubscriberFunction } from './IObservable'; 2 | 3 | // Store ObservableContext in window to prevent multiple Cascade instance problem. 4 | export interface IObservableContext { 5 | computedContexts: IObservable[][]; 6 | context: IObservable[]; 7 | } 8 | var observableContext: IObservableContext = window['$_cascade_observable_context'] || {}; 9 | window['$_cascade_observable_context'] = observableContext; 10 | observableContext.computedContexts = observableContext.computedContexts || []; 11 | observableContext.context = observableContext.context || undefined; 12 | 13 | export default class Observable implements IObservable { 14 | id: number; 15 | value: T; 16 | subscribers: (ISubscriber | ISubscriberFunction)[]; 17 | alwaysNotify: boolean; 18 | protected promise: Promise; 19 | 20 | static id: number = 0; 21 | 22 | constructor(value?: T) { 23 | this.value = value; 24 | this.subscribers = []; 25 | 26 | this.id = Observable.id; 27 | Observable.id++; 28 | } 29 | 30 | getValue() { 31 | if (observableContext.context) { 32 | observableContext.context.push(this); 33 | } 34 | return this.value; 35 | } 36 | 37 | peek() { 38 | return this.value; 39 | } 40 | 41 | peekDirty() { 42 | return this.value; 43 | } 44 | 45 | track() { 46 | return this.promise || Promise.resolve(); 47 | } 48 | 49 | async setValue(value: T) { 50 | if (this.value !== value || this.alwaysNotify) { 51 | var oldValue = this.value; 52 | this.value = value; 53 | this.promise = this.publish(value, oldValue); 54 | await this.promise; 55 | } 56 | } 57 | 58 | subscribeOnly(subscriber: ISubscriber | ISubscriberFunction) { 59 | if (subscriber) { 60 | this.subscribers.push(subscriber); 61 | } 62 | } 63 | 64 | subscribe(subscriber: ISubscriber | ISubscriberFunction) { 65 | if (subscriber) { 66 | this.subscribers.push(subscriber); 67 | if (typeof subscriber === 'function') { 68 | subscriber(this.value); 69 | } else { 70 | subscriber.notify(); 71 | } 72 | } 73 | } 74 | 75 | unsubscribe(subscriber: ISubscriber | ISubscriberFunction) { 76 | if (subscriber) { 77 | var index = this.subscribers.indexOf(subscriber); 78 | if (index >= 0) { 79 | this.subscribers.splice(index, 1); 80 | } 81 | } 82 | } 83 | 84 | async publish(value: T, oldValue?: T) { 85 | if (this.subscribers.length) { 86 | let subscribers = this.subscribers.filter((subscriber) => { 87 | if (typeof subscriber === 'function') { 88 | subscriber(this.value, oldValue); 89 | return false; 90 | } else { 91 | return true; 92 | } 93 | }).map((subscriber: ISubscriber) => subscriber.notify()) 94 | let result = await Promise.all(subscribers); 95 | } 96 | } 97 | 98 | dispose(recursive?: boolean) { 99 | this.subscribers.length = 0; 100 | } 101 | 102 | static getContext() { 103 | return observableContext.context; 104 | } 105 | 106 | static pushContext() { 107 | observableContext.context = []; 108 | observableContext.computedContexts.unshift(observableContext.context); 109 | return observableContext.context; 110 | } 111 | 112 | static popContext() { 113 | var oldContext = observableContext.computedContexts.shift(); 114 | observableContext.context = observableContext.computedContexts[0]; 115 | return oldContext; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/graph/ObservableArray.test.ts: -------------------------------------------------------------------------------- 1 | import { wait } from '../util/PromiseUtil'; 2 | 3 | import ObservableArray from './ObservableArray'; 4 | 5 | describe('ObservableArray', function () { 6 | it('should initialize to an emtpy Array', function () { 7 | var value = new ObservableArray(); 8 | expect(value.getValue().length).toBe(0); 9 | }); 10 | 11 | it('should initialize in the constructor to an Array', function () { 12 | var value = new ObservableArray([1]); 13 | expect(value.getValue().length).toBe(1); 14 | }); 15 | 16 | it('should notify subscribers on setter', function (done) { 17 | var value = new ObservableArray(); 18 | value.subscribeOnly((currentValue) => { 19 | expect(currentValue.length).toBe(1); 20 | expect(currentValue[0]).toBe(0); 21 | done(); 22 | }); 23 | value.setValue([0]); 24 | }); 25 | 26 | it('should notify subscribers on index setter', function (done) { 27 | var value = new ObservableArray(); 28 | value.subscribeOnly((currentValue) => { 29 | expect(currentValue.length).toBe(1); 30 | done(); 31 | }); 32 | value.peek()[0] = 10; 33 | }); 34 | 35 | it('should not notify subscribers on identical setter', async () => { 36 | var value = new ObservableArray(); 37 | 38 | let arrays = []; 39 | value.subscribeOnly((array) => { 40 | arrays.push(array); 41 | }); 42 | 43 | let array0 = [0]; 44 | let array1 = [1]; 45 | 46 | value.setValue(array0); 47 | await value.track(); 48 | 49 | value.setValue(array1); 50 | await value.track(); 51 | 52 | value.setValue(array1); 53 | await value.track(); 54 | 55 | expect(arrays.length).toBe(2); 56 | expect(arrays[0][0]).toBe(0); 57 | expect(arrays[1][0]).toBe(1); 58 | }); 59 | 60 | it('should not notify subscribers on identical index setter', async () => { 61 | var value = new ObservableArray(); 62 | 63 | let values = []; 64 | value.subscribeOnly((array) => { 65 | values.push(array[0]); 66 | }); 67 | 68 | value.peek()[0] = 0; 69 | await value.track(); 70 | 71 | value.peek()[0] = 1; 72 | await value.track(); 73 | 74 | value.peek()[0] = 1; 75 | await value.track(); 76 | 77 | expect(values.length).toBe(2); 78 | expect(values[0]).toBe(0); 79 | expect(values[1]).toBe(1); 80 | }); 81 | 82 | it('should notify subscribers on setting length', function (done) { 83 | var value = new ObservableArray([1]); 84 | value.subscribeOnly((currentValue) => { 85 | expect(currentValue.length).toBe(0); 86 | done(); 87 | }); 88 | value.peek().length = 0; 89 | }); 90 | 91 | it('should not notify subscribers on identical index setter', async () => { 92 | var value = new ObservableArray([1]); 93 | 94 | let values = []; 95 | value.subscribeOnly((array) => { 96 | values.push(array[0]); 97 | }); 98 | 99 | value.peek().length = 0; 100 | await value.track(); 101 | 102 | value.peek().length = 0; 103 | await value.track(); 104 | 105 | expect(values.length).toBe(1); 106 | expect(values[0]).toBe(undefined); 107 | }); 108 | 109 | // TODO: Figure out why done calls multiple times. 110 | it('should notify subscribers on push method', function () { 111 | var value = new ObservableArray(); 112 | value.subscribeOnly((currentValue) => { 113 | expect(currentValue.length).toBe(1); 114 | }); 115 | value.peek().push(10); 116 | }); 117 | 118 | it('should notify subscribers on delete', function (done) { 119 | var value = new ObservableArray([1]); 120 | value.subscribeOnly((currentValue) => { 121 | expect(currentValue[0]).toBe(undefined); 122 | done(); 123 | }); 124 | delete value.peek()[0]; 125 | }); 126 | 127 | it('should not notify subscribers on delete length', async function () { 128 | var value = new ObservableArray([1]); 129 | var count = 0; 130 | value.subscribeOnly((currentValue) => { 131 | count++; 132 | }); 133 | try { 134 | delete value.peek().length; 135 | } catch (e) { 136 | } finally { 137 | await wait(100); 138 | 139 | expect(count).toBe(0); 140 | } 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /src/graph/ObservableArray.ts: -------------------------------------------------------------------------------- 1 | declare var Proxy: any; 2 | 3 | import Observable from './Observable'; 4 | import { IArray } from './IObservable'; 5 | 6 | export default class ObservableArray extends Observable> { 7 | private _innerValue: Array; 8 | 9 | constructor(value?: Array) { 10 | super(); 11 | this._innerValue = value; 12 | this.value = this.wrapArray((value instanceof Array) ? value : []); 13 | } 14 | 15 | wrapArray(value: Array) { 16 | return new ProxyArray( 17 | (value instanceof ProxyArray) ? 18 | value.slice(0) : 19 | value, 20 | this 21 | ); 22 | } 23 | 24 | async setValue(value?: Array) { 25 | if (this._innerValue !== value || this.alwaysNotify) { 26 | this._innerValue = value; 27 | var oldValue = this.value; 28 | value = this.wrapArray((value instanceof Array) ? value : []); 29 | this.value = value; 30 | this.promise = this.publish(value, oldValue); 31 | await this.promise; 32 | } 33 | } 34 | } 35 | 36 | export class ProxyArray extends Array implements IArray { 37 | constructor(value?: Array, containingObservable?: Observable>) { 38 | super(); 39 | let inner = new Proxy(value, { 40 | set: (target: Array, property: string, value: T, receiver: ProxyArray) => { 41 | let result = true; 42 | let oldValue = target[property]; 43 | if (oldValue !== value || containingObservable.alwaysNotify) { 44 | result = (target[property] = value) === value; 45 | if (result && isFinite(Number(property)) || property === 'length') { 46 | containingObservable.publish(target, target); 47 | } 48 | } 49 | return result; 50 | }, 51 | deleteProperty: (target: Array, property: string) => { 52 | let result = delete target[property]; 53 | if (result && isFinite(Number(property))) { 54 | containingObservable.publish(target, target); 55 | } 56 | return result; 57 | } 58 | }) as ProxyArray; 59 | return inner; 60 | } 61 | } -------------------------------------------------------------------------------- /src/graph/ObservableHash.test.ts: -------------------------------------------------------------------------------- 1 | import ObservableHash from './ObservableHash'; 2 | 3 | describe('ObservableHash', function () { 4 | it('should initialize to an emtpy Hash', function () { 5 | var value = new ObservableHash(); 6 | expect(value.getValue()).toBeInstanceOf(Object); 7 | }); 8 | 9 | it('should initialize non Object parameters in the constructor to an Object', function () { 10 | var value = new ObservableHash('test' as any); 11 | expect(value.getValue()).toBeInstanceOf(Object); 12 | }); 13 | 14 | it('should initialize in the constructor to an Object', function () { 15 | var value = new ObservableHash({ 16 | property: 1, 17 | }); 18 | expect(value.getValue().property).toBe(1); 19 | }); 20 | 21 | it('should notify subscribers on setter', function (done) { 22 | var value = new ObservableHash(); 23 | value.subscribeOnly((currentValue) => { 24 | expect(currentValue['test']).toBe(1); 25 | done(); 26 | }); 27 | value.setValue({ test: 1 }); 28 | }); 29 | 30 | it('should notify subscribers on property setter', function (done) { 31 | var value = new ObservableHash(); 32 | value.subscribeOnly((currentValue) => { 33 | expect(currentValue['property']).toBe(10); 34 | done(); 35 | }); 36 | value.peek()['property'] = 10; 37 | }); 38 | 39 | it('should not notify subscribers on identical setter', async () => { 40 | var value = new ObservableHash(); 41 | 42 | let hashes = []; 43 | value.subscribeOnly((array) => { 44 | hashes.push(array); 45 | }); 46 | 47 | let hash0 = { test: 0 }; 48 | let hash1 = { test: 1 }; 49 | 50 | value.setValue(hash0); 51 | await value.track(); 52 | 53 | value.setValue(hash1); 54 | await value.track(); 55 | 56 | value.setValue(hash1); 57 | await value.track(); 58 | 59 | expect(hashes.length).toBe(2); 60 | expect(hashes[0]['test']).toBe(0); 61 | expect(hashes[1]['test']).toBe(1); 62 | }); 63 | 64 | it('should not notify subscribers on identical index setter', async () => { 65 | var value = new ObservableHash(); 66 | 67 | let values = []; 68 | value.subscribeOnly((hash) => { 69 | values.push(hash['test']); 70 | }); 71 | 72 | value.peek()['test'] = 0; 73 | await value.track(); 74 | 75 | value.peek()['test'] = 1; 76 | await value.track(); 77 | 78 | value.peek()['test'] = 1; 79 | await value.track(); 80 | 81 | expect(values.length).toBe(2); 82 | expect(values[0]).toBe(0); 83 | expect(values[1]).toBe(1); 84 | }); 85 | 86 | it('should notify subscribers on delete', function (done) { 87 | var value = new ObservableHash({ 88 | property: 10, 89 | }); 90 | value.subscribeOnly((currentValue) => { 91 | expect(currentValue['property']).toBe(undefined); 92 | done(); 93 | }); 94 | delete value.peek()['property']; 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /src/graph/ObservableHash.ts: -------------------------------------------------------------------------------- 1 | declare var Proxy: any; 2 | 3 | import Observable from './Observable'; 4 | import { IHash } from './IObservable'; 5 | 6 | export default class ObservableHash extends Observable> { 7 | private _innerValue: IHash; 8 | 9 | constructor(value?: IHash) { 10 | super(); 11 | this._innerValue = value; 12 | this.value = this.wrapHash((value instanceof Object) ? value : {}); 13 | } 14 | 15 | wrapHash(value: IHash) { 16 | return new ProxyHash( 17 | (value instanceof ProxyHash) ? 18 | Object.assign({}, value) : 19 | value, 20 | this 21 | ); 22 | } 23 | 24 | async setValue(value?: IHash) { 25 | if (this._innerValue !== value || this.alwaysNotify) { 26 | this._innerValue = value; 27 | var oldValue = this.value; 28 | value = this.wrapHash((value instanceof Object) ? value : {}); 29 | this.value = value; 30 | this.promise = this.publish(value, oldValue); 31 | await this.promise; 32 | } 33 | } 34 | } 35 | 36 | export class ProxyHash implements IHash { 37 | [index: string]: T; 38 | 39 | constructor(value?: IHash, containingObservable?: Observable>) { 40 | let inner = new Proxy(value, { 41 | set: (target: IHash, property: string, value: T, receiver: ProxyHash) => { 42 | let result = true; 43 | let oldValue = target[property]; 44 | if (oldValue !== value || containingObservable.alwaysNotify) { 45 | result = (target[property] = value) === value; 46 | if (result) { 47 | containingObservable.publish(target, target); 48 | } 49 | } 50 | return result; 51 | }, 52 | deleteProperty: (target: IHash, property: string) => { 53 | let result = delete target[property]; 54 | if (result) { 55 | containingObservable.publish(target, target); 56 | } 57 | return result; 58 | } 59 | }) as ProxyHash; 60 | return inner; 61 | } 62 | } -------------------------------------------------------------------------------- /src/graph/Subscribe.test.ts: -------------------------------------------------------------------------------- 1 | import Cascade from '../cascade/Cascade'; 2 | import { observable } from '../cascade/Decorators'; 3 | 4 | import { wait } from '../util/PromiseUtil'; 5 | 6 | describe('Cascade.subscribe', () => { 7 | it('should call a function on change', async () => { 8 | class State { 9 | @observable value = 1; 10 | } 11 | let state = new State(); 12 | let run = 0; 13 | let finalValue = undefined; 14 | Cascade.subscribe(state, 'value', (value) => { 15 | run++; 16 | finalValue = value; 17 | }); 18 | state.value = 10; 19 | 20 | await wait(20); 21 | 22 | expect(run).toBe(2); 23 | expect(finalValue).toBe(10); 24 | }); 25 | 26 | it.skip('should not call a function when value has not changed', async () => { 27 | class State { 28 | @observable value = 1; 29 | } 30 | let state = new State(); 31 | let run = 0; 32 | let finalValue = undefined; 33 | Cascade.subscribe(state, 'value', (value) => { 34 | run++; 35 | finalValue = value; 36 | }); 37 | state.value = 0; 38 | 39 | await wait(20); 40 | 41 | expect(run).toBe(1); 42 | expect(finalValue).toBe(0); 43 | }); 44 | 45 | it.skip('should return a disposer function', () => {}); 46 | 47 | it('should initialize an Observable', async () => { 48 | class State { 49 | @observable value: number; 50 | } 51 | 52 | let state = new State(); 53 | let values: number[] = []; 54 | 55 | Cascade.subscribe(state, 'value', (value) => { 56 | values.push(value); 57 | }); 58 | 59 | state.value = 1; 60 | await Cascade.track(state, 'value'); 61 | 62 | expect(values.length).toBe(2); 63 | expect(values[0]).toBe(undefined); 64 | expect(values[1]).toBe(1); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/graph/graph_Test0.test.ts: -------------------------------------------------------------------------------- 1 | import Cascade from '../cascade/Cascade'; 2 | import { observable } from '../cascade/Decorators'; 3 | import { wait } from '../util/PromiseUtil'; 4 | 5 | class ViewModel { 6 | runs: number = 0; 7 | @observable a: number = 1; 8 | @observable b: number = 2; 9 | @observable c: number = 3; 10 | @observable d: number = 4; 11 | @observable get ab() { 12 | return this.a + this.b; 13 | } 14 | @observable get ac() { 15 | return this.a + this.c; 16 | } 17 | @observable get ad() { 18 | return this.a + this.d; 19 | } 20 | @observable get bc() { 21 | return this.b + this.c; 22 | } 23 | @observable get bd() { 24 | return this.b + this.d; 25 | } 26 | @observable get cd() { 27 | return this.c + this.d; 28 | } 29 | @observable get abcd() { 30 | return this.ab + this.ac + this.ad + this.bc + this.bd + this.cd; 31 | } 32 | } 33 | 34 | describe('Graph', function () { 35 | it('should have minimal computed updates', function () { 36 | var viewModel: any = new ViewModel(); 37 | var complete = false; 38 | viewModel._graph.subscribe('abcd', function (value: number) { 39 | viewModel.runs++; 40 | if (complete) { 41 | expect(value).toBe(150); 42 | expect(viewModel.runs).toBe(2); 43 | } 44 | }); 45 | viewModel.a = 11; 46 | viewModel.b = 12; 47 | viewModel.c = 13; 48 | viewModel.d = 14; 49 | complete = true; 50 | }); 51 | }); 52 | 53 | describe('Cascade.track', () => { 54 | it('should emit a Promise which resolves when push is complete', async () => { 55 | var viewModel: any = new ViewModel(); 56 | await wait(20); 57 | var abcd = undefined; 58 | viewModel._graph.subscribe('abcd', function (value: number) {}); 59 | viewModel.a = 11; 60 | viewModel.b = 12; 61 | viewModel.c = 13; 62 | viewModel.d = 14; 63 | await Cascade.track(viewModel, 'd'); 64 | abcd = Cascade.peekDirty(viewModel, 'abcd'); 65 | expect(abcd).toBe(150); 66 | }); 67 | }); 68 | 69 | describe('Cascade.trackAll', () => { 70 | it('should emit a Promise which resolves when push is complete', async () => { 71 | var viewModel: any = new ViewModel(); 72 | await wait(20); 73 | var abcd = undefined; 74 | viewModel._graph.subscribe('abcd', function (value: number) {}); 75 | viewModel.a = 11; 76 | viewModel.b = 12; 77 | viewModel.c = 13; 78 | viewModel.d = 14; 79 | await Cascade.trackAll(viewModel); 80 | abcd = Cascade.peekDirty(viewModel, 'abcd'); 81 | expect(abcd).toBe(150); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/graph/graph_Test1.test.ts: -------------------------------------------------------------------------------- 1 | import { observable } from '../cascade/Decorators'; 2 | 3 | class ViewModel { 4 | runs: number = 0; 5 | @observable a: number = 1; 6 | @observable b: number = 2; 7 | @observable c: number = 3; 8 | @observable get ab() { 9 | return this.a + this.b; 10 | } 11 | @observable get bc() { 12 | return this.b + this.c; 13 | } 14 | @observable get aab() { 15 | return this.a + this.ab; 16 | } 17 | } 18 | 19 | describe('Graph', function () { 20 | it('should have minimal updates to mixed level Computed props', function () { 21 | var viewModel: any = new ViewModel(); 22 | var complete = false; 23 | viewModel._graph.subscribe('aab', function (value: number) { 24 | viewModel.runs++; 25 | if (complete) { 26 | expect(value).toBe(24); 27 | expect(viewModel.runs).toBe(2); 28 | } 29 | }); 30 | viewModel.a = 11; 31 | complete = true; 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/graph/graph_Test2.test.ts: -------------------------------------------------------------------------------- 1 | import { observable } from '../cascade/Decorators'; 2 | 3 | class ViewModel { 4 | runs: number = 0; 5 | @observable a: number[] = [1, 2, 3, 4]; 6 | @observable get loop() { 7 | var a = this.a; 8 | var total = 0; 9 | for (var index = 0, length = a.length; index < length; index++) { 10 | total += a[index]; 11 | } 12 | return total; 13 | } 14 | } 15 | 16 | describe('Graph', function () { 17 | it('should observe changes to Arrays', function () { 18 | var viewModel: any = new ViewModel(); 19 | var complete = false; 20 | viewModel._graph.subscribe('loop', function (value) { 21 | viewModel.runs++; 22 | if (complete) { 23 | expect(value).toBe(120); 24 | expect(viewModel.runs).toBe(2); 25 | } 26 | }); 27 | viewModel.a.push(10); 28 | viewModel.a.push(100); 29 | complete = true; 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/graph/graph_Test3.test.ts: -------------------------------------------------------------------------------- 1 | import { observable } from '../cascade/Decorators'; 2 | 3 | class ViewModel { 4 | runs = 0; 5 | @observable a = 1; 6 | @observable b = 2; 7 | @observable get ab() { 8 | this.runs++; 9 | return this.a + this.b; 10 | } 11 | } 12 | 13 | describe('Graph', function () { 14 | it('should pull changes to dirty computed values', function () { 15 | var model: any = new ViewModel(); 16 | model.a = 11; 17 | expect(model.ab).toBe(13); 18 | expect(model.runs).toBe(1); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/graph/graph_Test4.test.ts: -------------------------------------------------------------------------------- 1 | import { observable } from '../cascade/Decorators'; 2 | 3 | class ViewModel { 4 | runsAB = 0; 5 | runsABC = 0; 6 | @observable a = 1; 7 | @observable b = 2; 8 | @observable c = 3; 9 | @observable get ab() { 10 | this.runsAB++; 11 | return this.a + this.b; 12 | } 13 | @observable get abc() { 14 | this.runsABC++; 15 | return this.ab + this.c; 16 | } 17 | } 18 | 19 | describe('Graph.pull', function () { 20 | it('should push changes after pull', function () { 21 | var model: any = new ViewModel(); 22 | model._graph.subscribe('abc', function () { 23 | if (complete) { 24 | expect(ab).toBe(13); 25 | expect(model.runsAB).toBe(2); 26 | expect(model.runsABC).toBe(2); 27 | } 28 | }); 29 | var complete = false; 30 | model.a = 11; 31 | var ab = model.ab; 32 | var complete = true; 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/graph/graph_Test5.test.ts: -------------------------------------------------------------------------------- 1 | import { observable } from '../cascade/Decorators'; 2 | 3 | class ViewModel { 4 | runs = 0; 5 | @observable a = 1; 6 | @observable b = 2; 7 | @observable get ab() { 8 | this.runs++; 9 | return this.a + this.b; 10 | } 11 | } 12 | 13 | describe('Graph.dispose', function () { 14 | it('should dispose subscriptions to props', function () { 15 | var model: any = new ViewModel(); 16 | var ab = model.ab; 17 | model._graph.dispose(); 18 | expect(model._graph.observables.a.subscribers.length).toBe(0); 19 | expect(model._graph.observables.b.subscribers.length).toBe(0); 20 | expect(model.runs).toBe(1); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/graph/graph_Test6.test.ts: -------------------------------------------------------------------------------- 1 | import Cascade from '../cascade/Cascade'; 2 | import { observable } from '../cascade/Decorators'; 3 | 4 | var runsParent = 0; 5 | var runsChild = 0; 6 | var childObservable = undefined; 7 | var childStatic = undefined; 8 | 9 | class Child { 10 | parent: Parent; 11 | @observable c = 1; 12 | @observable d = 2; 13 | @observable get abcd() { 14 | runsChild++; 15 | return this.parent.a + this.parent.b + this.c + this.d; 16 | } 17 | constructor(parent: Parent) { 18 | Object.defineProperty(this, 'parent', { 19 | writable: true, 20 | configurable: true, 21 | enumerable: false, 22 | }); 23 | this.parent = parent; 24 | var abcd = this.abcd; 25 | } 26 | } 27 | 28 | class Parent { 29 | @observable a = 1; 30 | @observable b = 2; 31 | @observable get ab() { 32 | runsParent++; 33 | return this.a + this.b; 34 | } 35 | @observable childObservable: Child; 36 | childStatic: Child; 37 | constructor() { 38 | var ab = this.ab; 39 | childObservable = new Child(this); 40 | childStatic = new Child(this); 41 | this.childObservable = childObservable; 42 | this.childStatic = childStatic; 43 | } 44 | } 45 | 46 | describe('Graph.dispose', function () { 47 | it('should dispose observables recursively', function () { 48 | var model: any = new Parent(); 49 | Cascade.disposeAll(model); 50 | expect(model._graph.observables.a.subscribers.length).toBe(0); 51 | expect(model._graph.observables.b.subscribers.length).toBe(0); 52 | expect(runsParent).toBe(1); 53 | expect(runsChild).toBe(2); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/graph/graph_Test7.test.ts: -------------------------------------------------------------------------------- 1 | import { observable } from '../cascade/Decorators'; 2 | 3 | class ViewModel { 4 | runsB = 0; 5 | runsC = 0; 6 | runsD = 0; 7 | runsE = 0; 8 | @observable a = 1; 9 | @observable get b() { 10 | this.runsB++; 11 | return this.a; 12 | } 13 | @observable get c() { 14 | this.runsC++; 15 | return this.b; 16 | } 17 | @observable get d() { 18 | this.runsD++; 19 | return this.c; 20 | } 21 | @observable get e() { 22 | this.runsE++; 23 | return this.d; 24 | } 25 | } 26 | 27 | describe('Graph.pull', function () { 28 | it('should pull changes to deep layers', function () { 29 | var model: any = new ViewModel(); 30 | model._graph.subscribe('e', function (value: number) { 31 | if (result) { 32 | result.finalE = value; 33 | result.finalRunsE = model.runsE; 34 | 35 | expect(result.a).toBe(11); 36 | expect(result.b).toBe(11); 37 | expect(result.c).toBe(11); 38 | expect(result.d).toBe(11); 39 | expect(result.e).toBe(1); 40 | expect(result.finalE).toBe(11); 41 | expect(result.runsB).toBe(2); 42 | expect(result.runsC).toBe(2); 43 | expect(result.runsD).toBe(2); 44 | expect(result.runsE).toBe(1); 45 | expect(result.finalRunsE).toBe(2); 46 | } 47 | }); 48 | model.a = 11; 49 | var d = model.d; 50 | var result: any = { 51 | a: model._graph.observables.a.value, 52 | b: model._graph.observables.b.value, 53 | c: model._graph.observables.c.value, 54 | d: d, 55 | e: model._graph.observables.e.value, 56 | runsB: model.runsB, 57 | runsC: model.runsC, 58 | runsD: model.runsD, 59 | runsE: model.runsE, 60 | }; 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/graph/graph_Test8.test.ts: -------------------------------------------------------------------------------- 1 | import { observable } from '../cascade/Decorators'; 2 | 3 | class ViewModel { 4 | runsB = 0; 5 | runsC = 0; 6 | runsD = 0; 7 | @observable a = 1; 8 | @observable get b() { 9 | this.runsB++; 10 | return this.a; 11 | } 12 | @observable get c() { 13 | this.runsC++; 14 | return this.b; 15 | } 16 | @observable get d() { 17 | this.runsD++; 18 | return this.c; 19 | } 20 | } 21 | 22 | describe('Graph.pull', function () { 23 | it('should pull changes to multiple layers - lower first', function () { 24 | var model: any = new ViewModel(); 25 | model.a = 11; 26 | var b = model.b; 27 | var c = model.c; 28 | expect(b).toBe(11); 29 | expect(c).toBe(11); 30 | expect(model.runsB).toBe(1); 31 | expect(model.runsC).toBe(1); 32 | expect(model.runsD).toBe(0); 33 | }); 34 | 35 | it('should pull changes to multiple layers - higher first', function () { 36 | var model: any = new ViewModel(); 37 | model.a = 11; 38 | var c = model.c; 39 | var b = model.b; 40 | expect(b).toBe(11); 41 | expect(c).toBe(11); 42 | expect(model.runsB).toBe(1); 43 | expect(model.runsC).toBe(1); 44 | expect(model.runsD).toBe(0); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/jsx/HTMLElements.ts: -------------------------------------------------------------------------------- 1 | import { IVirtualNodeProps } from '../modules/Cascade'; 2 | 3 | export type JSXElement = IVirtualNodeProps & Partial; 4 | 5 | export interface HTMLElements { 6 | a: JSXElement; 7 | abbr: JSXElement; 8 | address: JSXElement; 9 | area: JSXElement; 10 | article: JSXElement; 11 | aside: JSXElement; 12 | audio: JSXElement; 13 | b: JSXElement; 14 | base: JSXElement; 15 | bdi: JSXElement; 16 | bdo: JSXElement; 17 | big: JSXElement; 18 | blockquote: JSXElement; 19 | body: JSXElement; 20 | br: JSXElement; 21 | button: JSXElement; 22 | canvas: JSXElement; 23 | caption: JSXElement; 24 | cite: JSXElement; 25 | code: JSXElement; 26 | col: JSXElement; 27 | colgroup: JSXElement; 28 | data: JSXElement; 29 | datalist: JSXElement; 30 | dd: JSXElement; 31 | del: JSXElement; 32 | details: JSXElement; 33 | dfn: JSXElement; 34 | dialog: JSXElement; 35 | div: JSXElement; 36 | dl: JSXElement; 37 | dt: JSXElement; 38 | em: JSXElement; 39 | embed: JSXElement; 40 | fieldset: JSXElement; 41 | figcaption: JSXElement; 42 | figure: JSXElement; 43 | footer: JSXElement; 44 | form: JSXElement; 45 | h1: JSXElement; 46 | h2: JSXElement; 47 | h3: JSXElement; 48 | h4: JSXElement; 49 | h5: JSXElement; 50 | h6: JSXElement; 51 | head: JSXElement; 52 | header: JSXElement; 53 | hr: JSXElement; 54 | html: JSXElement; 55 | i: JSXElement; 56 | iframe: JSXElement; 57 | img: JSXElement; 58 | input: JSXElement; 59 | ins: JSXElement; 60 | kbd: JSXElement; 61 | label: JSXElement; 62 | legend: JSXElement; 63 | li: JSXElement; 64 | link: JSXElement; 65 | main: JSXElement; 66 | map: JSXElement; 67 | mark: JSXElement; 68 | menu: JSXElement; 69 | menuitem: JSXElement; 70 | meta: JSXElement; 71 | meter: JSXElement; 72 | nav: JSXElement; 73 | noscript: JSXElement; 74 | object: JSXElement; 75 | ol: JSXElement; 76 | optgroup: JSXElement; 77 | option: JSXElement; 78 | output: JSXElement; 79 | p: JSXElement; 80 | param: JSXElement; 81 | picture: JSXElement; 82 | pre: JSXElement; 83 | progress: JSXElement; 84 | q: JSXElement; 85 | rp: JSXElement; 86 | rt: JSXElement; 87 | ruby: JSXElement; 88 | s: JSXElement; 89 | samp: JSXElement; 90 | script: JSXElement; 91 | section: JSXElement; 92 | select: JSXElement; 93 | small: JSXElement; 94 | source: JSXElement; 95 | span: JSXElement; 96 | strong: JSXElement; 97 | style: JSXElement; 98 | sub: JSXElement; 99 | summary: JSXElement; 100 | sup: JSXElement; 101 | table: JSXElement; 102 | tbody: JSXElement; 103 | td: JSXElement; 104 | textarea: JSXElement; 105 | tfoot: JSXElement; 106 | th: JSXElement; 107 | thead: JSXElement; 108 | time: JSXElement; 109 | title: JSXElement; 110 | tr: JSXElement; 111 | track: JSXElement; 112 | u: JSXElement; 113 | ul: JSXElement; 114 | "var": JSXElement; 115 | video: JSXElement; 116 | wbr: JSXElement; 117 | } -------------------------------------------------------------------------------- /src/jsx/JSX.ts: -------------------------------------------------------------------------------- 1 | import { IVirtualNode, IVirtualNodeProps } from '../dom/IVirtualNode'; 2 | import { Component } from '../dom/Component'; 3 | import { Elements } from './Elements'; 4 | 5 | declare global { 6 | namespace JSX { 7 | export interface Element extends IVirtualNode { } 8 | 9 | export interface ElementClass extends Component<{}> { 10 | render(): any; 11 | } 12 | 13 | export interface ElementAttributesProperty { 14 | props: IVirtualNodeProps; 15 | } 16 | 17 | export interface IntrinsicAttributes extends IVirtualNodeProps { } 18 | 19 | export interface IntrinsicClassAttributes extends IVirtualNodeProps { } 20 | 21 | export interface IntrinsicElements { 22 | a: Elements.JSXAnchorElement; 23 | abbr: Elements.JSXElement; 24 | address: Elements.JSXElement; 25 | area: Elements.JSXElement; 26 | article: Elements.JSXElement; 27 | aside: Elements.JSXElement; 28 | audio: Elements.JSXAudioElement; 29 | b: Elements.JSXElement; 30 | base: Elements.JSXBaseElement; 31 | bdi: Elements.JSXElement; 32 | bdo: Elements.JSXElement; 33 | big: Elements.JSXElement; 34 | blockquote: Elements.JSXQuoteElement; 35 | body: Elements.JSXBodyElement; 36 | br: Elements.JSXBRElement; 37 | button: Elements.JSXButtonElement; 38 | canvas: Elements.JSXCanvasElement; 39 | caption: Elements.JSXTableCaptionElement; 40 | cite: Elements.JSXElement; 41 | code: Elements.JSXElement; 42 | col: Elements.JSXTableColElement; 43 | colgroup: Elements.JSXTableColElement; 44 | data: Elements.JSXDataElement; 45 | datalist: Elements.JSXDataListElement; 46 | dd: Elements.JSXElement; 47 | del: Elements.JSXModElement; 48 | details: Elements.JSXDetailsElement; 49 | dfn: Elements.JSXElement; 50 | dialog: Elements.JSXElement; 51 | div: Elements.JSXDivElement; 52 | dl: Elements.JSXDListElement; 53 | dt: Elements.JSXElement; 54 | em: Elements.JSXElement; 55 | embed: Elements.JSXEmbedElement; 56 | fieldset: Elements.JSXFieldSetElement; 57 | figcaption: Elements.JSXElement; 58 | figure: Elements.JSXElement; 59 | footer: Elements.JSXElement; 60 | form: Elements.JSXFormElement; 61 | h1: Elements.JSXHeadingElement; 62 | h2: Elements.JSXHeadingElement; 63 | h3: Elements.JSXHeadingElement; 64 | h4: Elements.JSXHeadingElement; 65 | h5: Elements.JSXHeadingElement; 66 | h6: Elements.JSXHeadingElement; 67 | head: Elements.JSXHeadElement; 68 | header: Elements.JSXElement; 69 | hr: Elements.JSXHRElement; 70 | html: Elements.JSXHtmlElement; 71 | i: Elements.JSXElement; 72 | iframe: Elements.JSXIFrameElement; 73 | img: Elements.JSXImageElement; 74 | input: Elements.JSXInputElement; 75 | ins: Elements.JSXModElement; 76 | kbd: Elements.JSXElement; 77 | keygen: Elements.JSXKeygenElement; 78 | label: Elements.JSXLabelElement; 79 | legend: Elements.JSXLegendElement; 80 | li: Elements.JSXLIElement; 81 | link: Elements.JSXLinkElement; 82 | main: Elements.JSXElement; 83 | map: Elements.JSXMapElement; 84 | mark: Elements.JSXElement; 85 | menu: Elements.JSXMenuElement; 86 | menuitem: Elements.JSXElement; 87 | meta: Elements.JSXMetaElement; 88 | meter: Elements.JSXMeterElement; 89 | nav: Elements.JSXElement; 90 | noscript: Elements.JSXElement; 91 | object: Elements.JSXObjectElement; 92 | ol: Elements.JSXOListElement; 93 | optgroup: Elements.JSXOptGroupElement; 94 | option: Elements.JSXOptionElement; 95 | output: Elements.JSXOutputElement; 96 | p: Elements.JSXParagraphElement; 97 | param: Elements.JSXParamElement; 98 | picture: Elements.JSXElement; 99 | pre: Elements.JSXPreElement; 100 | progress: Elements.JSXProgressElement; 101 | q: Elements.JSXQuoteElement; 102 | rp: Elements.JSXElement; 103 | rt: Elements.JSXElement; 104 | ruby: Elements.JSXElement; 105 | s: Elements.JSXElement; 106 | samp: Elements.JSXElement; 107 | script: Elements.JSXScriptElement; 108 | section: Elements.JSXElement; 109 | select: Elements.JSXSelectElement; 110 | small: Elements.JSXElement; 111 | source: Elements.JSXSourceElement; 112 | span: Elements.JSXSpanElement; 113 | strong: Elements.JSXElement; 114 | style: Elements.JSXStyleElement; 115 | sub: Elements.JSXElement; 116 | summary: Elements.JSXElement; 117 | sup: Elements.JSXElement; 118 | table: Elements.JSXTableElement; 119 | tbody: Elements.JSXTableSectionElement; 120 | td: Elements.JSXTableDataCellElement; 121 | textarea: Elements.JSXTextAreaElement; 122 | tfoot: Elements.JSXTableSectionElement; 123 | th: Elements.JSXTableHeaderCellElement; 124 | thead: Elements.JSXTableSectionElement; 125 | time: Elements.JSXTimeElement; 126 | title: Elements.JSXTitleElement; 127 | tr: Elements.JSXTableRowElement; 128 | track: Elements.JSXTrackElement; 129 | u: Elements.JSXElement; 130 | ul: Elements.JSXUListElement; 131 | "var": Elements.JSXElement; 132 | video: Elements.JSXVideoElement; 133 | wbr: Elements.JSXElement; 134 | 135 | // SVG 136 | svg: Elements.JSXSVGElement; 137 | 138 | circle: Elements.JSXSVGElement; 139 | defs: Elements.JSXSVGElement; 140 | ellipse: Elements.JSXSVGElement; 141 | g: Elements.JSXSVGElement; 142 | line: Elements.JSXSVGElement; 143 | linearGradient: Elements.JSXSVGElement; 144 | mask: Elements.JSXSVGElement; 145 | path: Elements.JSXSVGElement; 146 | pattern: Elements.JSXSVGElement; 147 | polygon: Elements.JSXSVGElement; 148 | polyline: Elements.JSXSVGElement; 149 | radialGradient: Elements.JSXSVGElement; 150 | rect: Elements.JSXSVGElement; 151 | stop: Elements.JSXSVGElement; 152 | text: Elements.JSXSVGElement; 153 | tspan: Elements.JSXSVGElement; 154 | 155 | [elemName: string]: Elements.JSXElement; 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/jsx/SVGElements.ts: -------------------------------------------------------------------------------- 1 | import { IVirtualNodeProps } from '../modules/Cascade'; 2 | 3 | export type JSXElement = IVirtualNodeProps & Partial; 4 | 5 | export interface SVGElements { 6 | animate: JSXElement;// Missing definition 7 | animateColor: JSXElement;// Missing definition 8 | animateMotion: JSXElement;// Missing definition 9 | animateTransform: JSXElement;// Missing definition 10 | circle: JSXElement; 11 | clipPath: JSXElement; 12 | "colorprofile": JSXElement;// Missing definition 13 | cursor: JSXElement;// Missing definition 14 | defs: JSXElement; 15 | desc: JSXElement; 16 | discard: JSXElement;// Missing definition 17 | ellipse: JSXElement; 18 | feBlend: JSXElement; 19 | feColorMatrix: JSXElement; 20 | feComponentTransfer: JSXElement; 21 | feComposite: JSXElement; 22 | feConvolveMatrix: JSXElement; 23 | feDiffuseLighting: JSXElement; 24 | feDisplacementMap: JSXElement; 25 | feDistantLight: JSXElement; 26 | feDropShadow: JSXElement;// Missing definition 27 | feFlood: JSXElement; 28 | feFuncA: JSXElement; 29 | feFuncB: JSXElement; 30 | feFuncG: JSXElement; 31 | feFuncR: JSXElement; 32 | feGaussianBlur: JSXElement; 33 | feImage: JSXElement; 34 | feMerge: JSXElement; 35 | feMergeNode: JSXElement; 36 | feMorphology: JSXElement; 37 | feOffset: JSXElement; 38 | fePointLight: JSXElement; 39 | feSpecularLighting: JSXElement; 40 | feSpotLight: JSXElement; 41 | feTile: JSXElement; 42 | feTurbulence: JSXElement; 43 | filter: JSXElement; 44 | font: JSXElement;// Missing definition 45 | "font-face": JSXElement;// Missing definition 46 | "font-face-format": JSXElement;// Missing definition 47 | "font-face-name": JSXElement;// Missing definition 48 | "font-face-src": JSXElement;// Missing definition 49 | "font-face-uri": JSXElement;// Missing definition 50 | foreignObject: JSXElement; 51 | g: JSXElement; 52 | glyph: JSXElement;// Missing definition 53 | glyphRef: JSXElement;// Missing definition 54 | hatch: JSXElement;// Missing definition 55 | hatchpath: JSXElement;// Missing definition 56 | hkern: JSXElement;// Missing definition 57 | image: JSXElement; 58 | line: JSXElement; 59 | linearGradient: JSXElement; 60 | marker: JSXElement; 61 | mask: JSXElement; 62 | mesh: JSXElement;// Missing definition 63 | meshgradient: JSXElement;// Missing definition 64 | meshpatch: JSXElement;// Missing definition 65 | meshrow: JSXElement;// Missing definition 66 | metadata: JSXElement; 67 | "missing-glyph": JSXElement;// Missing definition 68 | mpath: JSXElement;// Missing definition 69 | path: JSXElement; 70 | pattern: JSXElement; 71 | polygon: JSXElement; 72 | polyline: JSXElement; 73 | radialGradient: JSXElement; 74 | rect: JSXElement; 75 | //script: JSXElement;// Duplicate of HTMLElements 76 | set: JSXElement; 77 | solidcolor: JSXElement;// Missing definition 78 | stop: JSXElement; 79 | //style: JSXElement;// Duplicate of HTMLElements 80 | svg: JSXElement; 81 | switch: JSXElement; 82 | symbol: JSXElement; 83 | text: JSXElement; 84 | textPath: JSXElement; 85 | //title: JSXElement;// Duplicate of HTMLElements 86 | tref: JSXElement;// Missing definition 87 | tspan: JSXElement; 88 | unknown: JSXElement;// Missing definition 89 | use: JSXElement; 90 | view: JSXElement; 91 | vkern: JSXElement;// Missing definition 92 | } -------------------------------------------------------------------------------- /src/modules/Cascade.ts: -------------------------------------------------------------------------------- 1 | import '../jsx/JSX'; 2 | 3 | export { Elements } from '../jsx/Elements'; 4 | 5 | export { IObservable, ISubscriber, ISubscriberFunction, IArray, IHash } from '../graph/IObservable'; 6 | export { default as Observable } from '../graph/Observable'; 7 | export { default as Computed } from '../graph/Computed'; 8 | export { default as ObservableArray } from '../graph/ObservableArray'; 9 | export { default as ObservableHash } from '../graph/ObservableHash'; 10 | export { default as Graph } from '../graph/Graph'; 11 | 12 | export { IVirtualNode, IVirtualNodeProps } from '../dom/IVirtualNode'; 13 | export { default as VirtualNode } from '../dom/VirtualNode'; 14 | export { default as Fragment } from '../dom/Fragment'; 15 | export { default as ComponentNode } from '../dom/ComponentNode'; 16 | export { Component } from '../dom/Component'; 17 | export { default as Ref } from '../dom/Ref'; 18 | export { default as Portal } from '../dom/Portal'; 19 | 20 | export { default as DecoratorUtil, ObservableFactory } from '../util/DecoratorUtil'; 21 | export { observable, computed, array, hash } from '../cascade/Decorators'; 22 | 23 | import { default as Cascade } from '../cascade/Cascade'; 24 | export default Cascade; -------------------------------------------------------------------------------- /src/util/CascadeError.ts: -------------------------------------------------------------------------------- 1 | export enum CascadeError { 2 | NoRootNode = 'Could not find root node', 3 | InvalidRootRender = 'Root render is not a Node. Nothing was rendered, and nothing will be updated', 4 | NoObservable = 'No observable attached to Object: ', 5 | NoOldComponent = 'Old Component has never been rendered', 6 | TimeoutElapsed = 'Timeout elapsed' 7 | } -------------------------------------------------------------------------------- /src/util/DecoratorUtil.test.ts: -------------------------------------------------------------------------------- 1 | import { observable } from '../cascade/Decorators'; 2 | import Computed from '../graph/Computed'; 3 | 4 | import DecoratorUtil from './DecoratorUtil'; 5 | 6 | describe('DecoratorUtil', () => { 7 | function minLength(length: number = 0): any { 8 | return function ( 9 | target: any, 10 | propertyKey: string, 11 | descriptor?: TypedPropertyDescriptor, 12 | ): any { 13 | DecoratorUtil.attachObservable( 14 | target, 15 | propertyKey + '_minLength', 16 | (value: boolean, thisArg: any) => { 17 | return new Computed( 18 | function () { 19 | let value = thisArg[propertyKey]; 20 | return typeof value === 'string' && value.length >= length; 21 | }, 22 | false, 23 | thisArg, 24 | ); 25 | }, 26 | true, 27 | ); 28 | }; 29 | } 30 | 31 | it('should provide secondary Decorators', () => { 32 | class ViewModel { 33 | @minLength(1) @observable value: string = 'abcd'; 34 | } 35 | 36 | let viewModel = new ViewModel(); 37 | expect(viewModel['value_minLength']).toBe(true); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/util/DecoratorUtil.ts: -------------------------------------------------------------------------------- 1 | import Cascade, { IObservable, Computed } from '../modules/Cascade'; 2 | 3 | export type ObservableFactory = { 4 | /** 5 | * @param value 6 | * @param thisArg 7 | */ 8 | (value: T, thisArg: any): IObservable; 9 | } 10 | 11 | export default class DecoratorUtil { 12 | /** 13 | * Creates a new `IObservable` or returns one if it already exists 14 | * @param obj 15 | * @param property 16 | * @param factory 17 | * @param value 18 | * @param set 19 | * @param thisArg 20 | */ 21 | static createObservableIfNotExists(obj: any, property: string, factory: ObservableFactory, value?: T, set?: boolean, thisArg?: any): IObservable { 22 | Cascade.attachGraph(obj); 23 | if (!obj._graph.observables[property]) { 24 | obj._graph.observables[property] = factory(value, thisArg); 25 | } else if (set) { 26 | obj._graph.observables[property].setValue(value); 27 | } 28 | return obj._graph.observables[property]; 29 | } 30 | 31 | /** 32 | * Attaches a new `IObservable` to the getter and setter of an object. 33 | * @param target 34 | * @param propertyKey 35 | * @param factory 36 | * @param readOnly 37 | */ 38 | static attachObservable(target: any, propertyKey: string, factory: ObservableFactory, readOnly: boolean = false) { 39 | Object.defineProperty(target, propertyKey, { 40 | enumerable: true, 41 | configurable: true, 42 | get: function () { 43 | return DecoratorUtil.createObservableIfNotExists(this, propertyKey, factory, undefined, false, this).getValue(); 44 | }, 45 | set: readOnly ? undefined : function (value: T) { 46 | DecoratorUtil.createObservableIfNotExists(this, propertyKey, factory, value, true, this); 47 | } 48 | }); 49 | } 50 | } -------------------------------------------------------------------------------- /src/util/Diff.test.ts: -------------------------------------------------------------------------------- 1 | import Diff, { IDiffItem } from './Diff'; 2 | 3 | describe('Diff', function () { 4 | it('should be able to diff empty and a value', function () { 5 | var lcs = createLCS(Diff.compare('', 'a')); 6 | expect(lcs).toBe(''); 7 | }); 8 | 9 | it('should be able to diff a value and empty', function () { 10 | var lcs = createLCS(Diff.compare('a', '')); 11 | expect(lcs).toBe(''); 12 | }); 13 | 14 | it('should be able to diff an added character', function () { 15 | var lcs = createLCS(Diff.compare('a', 'ab')); 16 | expect(lcs).toBe('a'); 17 | }); 18 | 19 | it('should be able to diff same first character', function () { 20 | var lcs = createLCS(Diff.compare('ab', 'ac')); 21 | expect(lcs).toBe('a'); 22 | }); 23 | 24 | it('should be able to diff same last character', function () { 25 | var lcs = createLCS(Diff.compare('ac', 'bc')); 26 | expect(lcs).toBe('c'); 27 | }); 28 | 29 | it('should be able to diff different length differences', function () { 30 | var lcs = createLCS(Diff.compare('acd', 'bd')); 31 | expect(lcs).toBe('d'); 32 | }); 33 | 34 | it('should be able to diff a complex change', function () { 35 | var lcs = createLCS(Diff.compare('abcde', 'abdfe')); 36 | expect(lcs).toBe('abde'); 37 | }); 38 | }); 39 | 40 | function createLCS(diff: IDiffItem[]) { 41 | diff.reverse(); 42 | var lcs = []; 43 | for (var index = 0, length = diff.length; index < length; index++) { 44 | var diffItem = diff[index]; 45 | if (diffItem.operation === 0) { 46 | lcs.push(diffItem.item); 47 | } 48 | } 49 | return lcs.join(''); 50 | } 51 | -------------------------------------------------------------------------------- /src/util/Diff.ts: -------------------------------------------------------------------------------- 1 | export default class Diff { 2 | static createTable(m: number, n: number) { 3 | var table: T[][] = new Array(m); 4 | for (var i = 0; i < m; i++) { 5 | table[i] = new Array(n); 6 | } 7 | return table; 8 | } 9 | 10 | // TODO: Optimize for empty cases and identical beginnings and endings. 11 | static compare(x: T[], y: T[], comparison?: (x: T, y: T) => boolean): IDiffItem[]; 12 | static compare(x: string, y: string, comparison?: (x: string, y: string) => boolean): IDiffItem[]; 13 | static compare(x: T[] | string, y: T[] | string, comparison?: (x: T | string, y: T | string) => boolean): IDiffItem[] { 14 | var m = x.length; 15 | var n = y.length; 16 | var table: number[][] = Diff.createTable(m + 1, n + 1); 17 | 18 | /* 19 | * Following steps build table[m+1][n+1] in bottom up fashion. 20 | * Note that table[i][j] contains length of LCS of x[0..i-1] and y[0..j-1] 21 | */ 22 | for (var i = 0; i <= m; i++) { 23 | for (var j = 0; j <= n; j++) { 24 | if (i == 0 || j == 0) { 25 | table[i][j] = 0; 26 | } else if (comparison ? comparison(x[i - 1], y[j - 1]) : x[i - 1] == y[j - 1]) { 27 | table[i][j] = table[i - 1][j - 1] + 1; 28 | } else { 29 | table[i][j] = Diff.max(table[i - 1][j], table[i][j - 1]); 30 | } 31 | } 32 | } 33 | 34 | // Create a diff array 35 | var diff: IDiffItem[] = []; 36 | 37 | // Start from the right-most-bottom-most corner and 38 | // one by one store characters in diff[] 39 | i = m; 40 | j = n; 41 | while (i > 0 || j > 0) { 42 | if (i === 0) { 43 | while (j > 0) { 44 | diff.push({ 45 | operation: DiffOperation.ADD, 46 | item: y[j - 1] 47 | }); 48 | j--; 49 | } 50 | } else if (j === 0) { 51 | while (i > 0) { 52 | diff.push({ 53 | operation: DiffOperation.REMOVE, 54 | item: x[i - 1] 55 | }); 56 | i--; 57 | } 58 | } else { 59 | // If current character in X[] and Y are same, then 60 | // current character is part of LCS 61 | if (comparison ? comparison(x[i - 1], y[j - 1]) : x[i - 1] == y[j - 1]) { 62 | // Put current character in result 63 | diff.push({ 64 | operation: DiffOperation.NONE, 65 | item: x[i - 1], 66 | itemB: y[j - 1] 67 | }); 68 | // reduce values of i and j 69 | i--; j--; 70 | } else { 71 | // If not same, then find the larger of two and 72 | // go in the direction of larger value 73 | if (table[i - 1][j] > table[i][j - 1]) { 74 | diff.push({ 75 | operation: DiffOperation.REMOVE, 76 | item: x[i - 1] 77 | }); 78 | i--; 79 | } else { 80 | diff.push({ 81 | operation: DiffOperation.ADD, 82 | item: y[j - 1] 83 | }); 84 | j--; 85 | } 86 | } 87 | } 88 | } 89 | //diff.reverse(); 90 | return diff; 91 | } 92 | 93 | /* Utility function to get max of 2 integers */ 94 | static max(a: number, b: number) { 95 | return (a > b) ? a : b; 96 | } 97 | 98 | // TODO: Optimize reads 99 | // TODO: Decide if truthy or non-null and non-undefined 100 | static compareHash(x: Object, y: Object) { 101 | var result = {}; 102 | 103 | for (var name in x) { 104 | if (x.hasOwnProperty(name)) { 105 | var xValue = x[name]; 106 | var yValue = y[name]; 107 | if (xValue !== yValue) { 108 | // Should this be falsy? 109 | if (yValue == undefined) { 110 | result[name] = null; 111 | } else { 112 | result[name] = yValue; 113 | } 114 | } 115 | } 116 | } 117 | 118 | for (var name in y) { 119 | if (y.hasOwnProperty(name)) { 120 | var xValue = x[name]; 121 | var yValue = y[name]; 122 | if (xValue !== yValue) { 123 | // Should this be falsy? 124 | if (yValue == undefined) { 125 | result[name] = null; 126 | } else { 127 | result[name] = yValue; 128 | } 129 | } 130 | } 131 | } 132 | 133 | return result; 134 | } 135 | } 136 | 137 | export enum DiffOperation { 138 | REMOVE = -1, 139 | NONE = 0, 140 | ADD = 1 141 | } 142 | 143 | export interface IDiffItem { 144 | item: T; 145 | itemB?: T; 146 | operation: DiffOperation; 147 | } 148 | -------------------------------------------------------------------------------- /src/util/PromiseUtil.ts: -------------------------------------------------------------------------------- 1 | export function wait(time: number) { 2 | return new Promise((resolve) => { 3 | window.setTimeout(resolve, time); 4 | }); 5 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "target": "es2017", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "isolatedModules": false, 8 | "jsx": "react", 9 | "experimentalDecorators": true, 10 | "emitDecoratorMetadata": true, 11 | "declaration": true, 12 | "removeComments": true, 13 | "noLib": false, 14 | "preserveConstEnums": true, 15 | "suppressImplicitAnyIndexErrors": true, 16 | "sourceMap": true, 17 | "declarationMap": true, 18 | "reactNamespace": "Cascade", 19 | "noImplicitAny": false, 20 | "noImplicitUseStrict": true, 21 | "noImplicitReturns": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noImplicitThis": false, 26 | "lib": [ 27 | "dom", 28 | "es2017" 29 | ], 30 | "types": [ 31 | "jest", 32 | "reflect-metadata" 33 | ], 34 | }, 35 | "include": [ 36 | "src" 37 | ], 38 | "compileOnSave": true, 39 | "buildOnSave": false 40 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: 'production', 3 | entry: { 4 | 'cascade': ['./src/scripts/modules/Cascade.ts'] 5 | }, 6 | output: { 7 | filename: './bundle/[name].min.js', 8 | libraryTarget: 'var', 9 | library: '[name]' 10 | }, 11 | resolve: { 12 | extensions: ['.webpack.js', '.web.js', '.ts', '.tsx', '.js'] 13 | }, 14 | module: { 15 | rules: [{ 16 | test: /\.tsx?$/, 17 | use: ['ts-loader'] 18 | }] 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: 'development', 3 | entry: { 4 | 'cascade': ['./src/scripts/modules/Cascade.ts'], 5 | 'mochaRunner': './src/mocha/BrowserRunner.ts' 6 | }, 7 | output: { 8 | filename: './bundle/[name].js', 9 | libraryTarget: 'var', 10 | library: '[name]' 11 | }, 12 | devtool: 'source-map', 13 | resolve: { 14 | extensions: ['.webpack.js', '.web.js', '.ts', '.tsx', '.js'] 15 | }, 16 | externals: { 17 | mocha: 'mocha', 18 | chai: 'chai' 19 | }, 20 | module: { 21 | rules: [{ 22 | test: /\.tsx?$/, 23 | use: ['ts-loader'] 24 | }] 25 | } 26 | }; 27 | --------------------------------------------------------------------------------