From f0d529139bdfbaee659ef6898ab67ec652740373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Wed, 16 Jan 2019 13:19:58 +0100 Subject: [PATCH] basic project infrastructure --- README.md | 13 + demo/almond.js | 434 ++++++++++++++++++++++++++++++ demo/index.html | 15 ++ demo/src/Counter.ts | 26 ++ demo/src/env.ts | 11 + demo/src/main.ts | 12 + package.json | 12 +- src/bundles/core.ts | 10 + src/{ => core}/qweb.ts | 4 +- src/{ => core}/qweb_directives.ts | 0 src/{ => core}/utils.ts | 0 src/core/widget.ts | 79 ++++++ tests/qweb.test.ts | 4 +- tests/utils.test.ts | 2 +- tests/widget.test.ts | 56 ++++ tsconfig.json | 2 +- 16 files changed, 671 insertions(+), 9 deletions(-) create mode 100644 demo/almond.js create mode 100644 demo/index.html create mode 100644 demo/src/Counter.ts create mode 100644 demo/src/env.ts create mode 100644 demo/src/main.ts create mode 100644 src/bundles/core.ts rename src/{ => core}/qweb.ts (98%) rename src/{ => core}/qweb_directives.ts (100%) rename src/{ => core}/utils.ts (100%) create mode 100644 src/core/widget.ts create mode 100644 tests/widget.test.ts diff --git a/README.md b/README.md index 02b5eb3a..3db42a9f 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,16 @@ This is a POC, not at all production ready code!!! +## Tests + +npm run test +npm run test:watch + +## Build code bundles + +npm run bundle:core +npm run bundle:core:watch + +## Demo page + +npm run demo \ No newline at end of file diff --git a/demo/almond.js b/demo/almond.js new file mode 100644 index 00000000..1c7c0eb1 --- /dev/null +++ b/demo/almond.js @@ -0,0 +1,434 @@ +/** + * @license almond 0.3.3 Copyright jQuery Foundation and other contributors. + * Released under MIT license, http://github.com/requirejs/almond/LICENSE + */ +//Going sloppy to avoid 'use strict' string cost, but strict practices should +//be followed. +/*global setTimeout: false */ + +var requirejs, require, define; +(function (undef) { + var main, req, makeMap, handlers, + defined = {}, + waiting = {}, + config = {}, + defining = {}, + hasOwn = Object.prototype.hasOwnProperty, + aps = [].slice, + jsSuffixRegExp = /\.js$/; + + function hasProp(obj, prop) { + return hasOwn.call(obj, prop); + } + + /** + * Given a relative module name, like ./something, normalize it to + * a real name that can be mapped to a path. + * @param {String} name the relative name + * @param {String} baseName a real name that the name arg is relative + * to. + * @returns {String} normalized name + */ + function normalize(name, baseName) { + var nameParts, nameSegment, mapValue, foundMap, lastIndex, + foundI, foundStarMap, starI, i, j, part, normalizedBaseParts, + baseParts = baseName && baseName.split("/"), + map = config.map, + starMap = (map && map['*']) || {}; + + //Adjust any relative paths. + if (name) { + name = name.split('/'); + lastIndex = name.length - 1; + + // If wanting node ID compatibility, strip .js from end + // of IDs. Have to do this here, and not in nameToUrl + // because node allows either .js or non .js to map + // to same file. + if (config.nodeIdCompat && jsSuffixRegExp.test(name[lastIndex])) { + name[lastIndex] = name[lastIndex].replace(jsSuffixRegExp, ''); + } + + // Starts with a '.' so need the baseName + if (name[0].charAt(0) === '.' && baseParts) { + //Convert baseName to array, and lop off the last part, + //so that . matches that 'directory' and not name of the baseName's + //module. For instance, baseName of 'one/two/three', maps to + //'one/two/three.js', but we want the directory, 'one/two' for + //this normalization. + normalizedBaseParts = baseParts.slice(0, baseParts.length - 1); + name = normalizedBaseParts.concat(name); + } + + //start trimDots + for (i = 0; i < name.length; i++) { + part = name[i]; + if (part === '.') { + name.splice(i, 1); + i -= 1; + } else if (part === '..') { + // If at the start, or previous value is still .., + // keep them so that when converted to a path it may + // still work when converted to a path, even though + // as an ID it is less than ideal. In larger point + // releases, may be better to just kick out an error. + if (i === 0 || (i === 1 && name[2] === '..') || name[i - 1] === '..') { + continue; + } else if (i > 0) { + name.splice(i - 1, 2); + i -= 2; + } + } + } + //end trimDots + + name = name.join('/'); + } + + //Apply map config if available. + if ((baseParts || starMap) && map) { + nameParts = name.split('/'); + + for (i = nameParts.length; i > 0; i -= 1) { + nameSegment = nameParts.slice(0, i).join("/"); + + if (baseParts) { + //Find the longest baseName segment match in the config. + //So, do joins on the biggest to smallest lengths of baseParts. + for (j = baseParts.length; j > 0; j -= 1) { + mapValue = map[baseParts.slice(0, j).join('/')]; + + //baseName segment has config, find if it has one for + //this name. + if (mapValue) { + mapValue = mapValue[nameSegment]; + if (mapValue) { + //Match, update name to the new value. + foundMap = mapValue; + foundI = i; + break; + } + } + } + } + + if (foundMap) { + break; + } + + //Check for a star map match, but just hold on to it, + //if there is a shorter segment match later in a matching + //config, then favor over this star map. + if (!foundStarMap && starMap && starMap[nameSegment]) { + foundStarMap = starMap[nameSegment]; + starI = i; + } + } + + if (!foundMap && foundStarMap) { + foundMap = foundStarMap; + foundI = starI; + } + + if (foundMap) { + nameParts.splice(0, foundI, foundMap); + name = nameParts.join('/'); + } + } + + return name; + } + + function makeRequire(relName, forceSync) { + return function () { + //A version of a require function that passes a moduleName + //value for items that may need to + //look up paths relative to the moduleName + var args = aps.call(arguments, 0); + + //If first arg is not require('string'), and there is only + //one arg, it is the array form without a callback. Insert + //a null so that the following concat is correct. + if (typeof args[0] !== 'string' && args.length === 1) { + args.push(null); + } + return req.apply(undef, args.concat([relName, forceSync])); + }; + } + + function makeNormalize(relName) { + return function (name) { + return normalize(name, relName); + }; + } + + function makeLoad(depName) { + return function (value) { + defined[depName] = value; + }; + } + + function callDep(name) { + if (hasProp(waiting, name)) { + var args = waiting[name]; + delete waiting[name]; + defining[name] = true; + main.apply(undef, args); + } + + if (!hasProp(defined, name) && !hasProp(defining, name)) { + throw new Error('No ' + name); + } + return defined[name]; + } + + //Turns a plugin!resource to [plugin, resource] + //with the plugin being undefined if the name + //did not have a plugin prefix. + function splitPrefix(name) { + var prefix, + index = name ? name.indexOf('!') : -1; + if (index > -1) { + prefix = name.substring(0, index); + name = name.substring(index + 1, name.length); + } + return [prefix, name]; + } + + //Creates a parts array for a relName where first part is plugin ID, + //second part is resource ID. Assumes relName has already been normalized. + function makeRelParts(relName) { + return relName ? splitPrefix(relName) : []; + } + + /** + * Makes a name map, normalizing the name, and using a plugin + * for normalization if necessary. Grabs a ref to plugin + * too, as an optimization. + */ + makeMap = function (name, relParts) { + var plugin, + parts = splitPrefix(name), + prefix = parts[0], + relResourceName = relParts[1]; + + name = parts[1]; + + if (prefix) { + prefix = normalize(prefix, relResourceName); + plugin = callDep(prefix); + } + + //Normalize according + if (prefix) { + if (plugin && plugin.normalize) { + name = plugin.normalize(name, makeNormalize(relResourceName)); + } else { + name = normalize(name, relResourceName); + } + } else { + name = normalize(name, relResourceName); + parts = splitPrefix(name); + prefix = parts[0]; + name = parts[1]; + if (prefix) { + plugin = callDep(prefix); + } + } + + //Using ridiculous property names for space reasons + return { + f: prefix ? prefix + '!' + name : name, //fullName + n: name, + pr: prefix, + p: plugin + }; + }; + + function makeConfig(name) { + return function () { + return (config && config.config && config.config[name]) || {}; + }; + } + + handlers = { + require: function (name) { + return makeRequire(name); + }, + exports: function (name) { + var e = defined[name]; + if (typeof e !== 'undefined') { + return e; + } else { + return (defined[name] = {}); + } + }, + module: function (name) { + return { + id: name, + uri: '', + exports: defined[name], + config: makeConfig(name) + }; + } + }; + + main = function (name, deps, callback, relName) { + var cjsModule, depName, ret, map, i, relParts, + args = [], + callbackType = typeof callback, + usingExports; + + //Use name if no relName + relName = relName || name; + relParts = makeRelParts(relName); + + //Call the callback to define the module, if necessary. + if (callbackType === 'undefined' || callbackType === 'function') { + //Pull out the defined dependencies and pass the ordered + //values to the callback. + //Default to [require, exports, module] if no deps + deps = !deps.length && callback.length ? ['require', 'exports', 'module'] : deps; + for (i = 0; i < deps.length; i += 1) { + map = makeMap(deps[i], relParts); + depName = map.f; + + //Fast path CommonJS standard dependencies. + if (depName === "require") { + args[i] = handlers.require(name); + } else if (depName === "exports") { + //CommonJS module spec 1.1 + args[i] = handlers.exports(name); + usingExports = true; + } else if (depName === "module") { + //CommonJS module spec 1.1 + cjsModule = args[i] = handlers.module(name); + } else if (hasProp(defined, depName) || + hasProp(waiting, depName) || + hasProp(defining, depName)) { + args[i] = callDep(depName); + } else if (map.p) { + map.p.load(map.n, makeRequire(relName, true), makeLoad(depName), {}); + args[i] = defined[depName]; + } else { + throw new Error(name + ' missing ' + depName); + } + } + + ret = callback ? callback.apply(defined[name], args) : undefined; + + if (name) { + //If setting exports via "module" is in play, + //favor that over return value and exports. After that, + //favor a non-undefined return value over exports use. + if (cjsModule && cjsModule.exports !== undef && + cjsModule.exports !== defined[name]) { + defined[name] = cjsModule.exports; + } else if (ret !== undef || !usingExports) { + //Use the return value from the function. + defined[name] = ret; + } + } + } else if (name) { + //May just be an object definition for the module. Only + //worry about defining if have a module name. + defined[name] = callback; + } + }; + + requirejs = require = req = function (deps, callback, relName, forceSync, alt) { + if (typeof deps === "string") { + if (handlers[deps]) { + //callback in this case is really relName + return handlers[deps](callback); + } + //Just return the module wanted. In this scenario, the + //deps arg is the module name, and second arg (if passed) + //is just the relName. + //Normalize module name, if it contains . or .. + return callDep(makeMap(deps, makeRelParts(callback)).f); + } else if (!deps.splice) { + //deps is a config object, not an array. + config = deps; + if (config.deps) { + req(config.deps, config.callback); + } + if (!callback) { + return; + } + + if (callback.splice) { + //callback is an array, which means it is a dependency list. + //Adjust args if there are dependencies + deps = callback; + callback = relName; + relName = null; + } else { + deps = undef; + } + } + + //Support require(['a']) + callback = callback || function () {}; + + //If relName is a function, it is an errback handler, + //so remove it. + if (typeof relName === 'function') { + relName = forceSync; + forceSync = alt; + } + + //Simulate async callback; + if (forceSync) { + main(undef, deps, callback, relName); + } else { + //Using a non-zero value because of concern for what old browsers + //do, and latest browsers "upgrade" to 4 if lower value is used: + //http://www.whatwg.org/specs/web-apps/current-work/multipage/timers.html#dom-windowtimers-settimeout: + //If want a value immediately, use require('id') instead -- something + //that works in almond on the global level, but not guaranteed and + //unlikely to work in other AMD implementations. + setTimeout(function () { + main(undef, deps, callback, relName); + }, 4); + } + + return req; + }; + + /** + * Just drops the config on the floor, but returns req in case + * the config return value is used. + */ + req.config = function (cfg) { + return req(cfg); + }; + + /** + * Expose module registry for debugging and tooling + */ + requirejs._defined = defined; + + define = function (name, deps, callback) { + if (typeof name !== 'string') { + throw new Error('See almond README: incorrect module build, no module name'); + } + + //This module may not have dependencies + if (!deps.splice) { + //deps is not an array, so probably means + //an object literal or factory function for + //the value. Adjust args. + callback = deps; + deps = []; + } + + if (!hasProp(defined, name) && !hasProp(waiting, name)) { + waiting[name] = [name, deps, callback]; + } + }; + + define.amd = { + jQuery: true + }; +}()); diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 00000000..5c8be9c6 --- /dev/null +++ b/demo/index.html @@ -0,0 +1,15 @@ + + + + + Odoo Web Core Demo + + + + + +
+ + \ No newline at end of file diff --git a/demo/src/Counter.ts b/demo/src/Counter.ts new file mode 100644 index 00000000..42cfeae3 --- /dev/null +++ b/demo/src/Counter.ts @@ -0,0 +1,26 @@ +import Widget from "../../src/core/widget"; + +const template = ` +
+ + Value: + +
+`; + +export default class Counter extends Widget { + name = "counter"; + template = template; + state = { + counter: 0 + }; + + constructor(parent: Widget, initialState?: number) { + super(parent); + this.state.counter = initialState || 0; + } + + increment(delta: number) { + this.updateState({ counter: this.state.counter + delta }); + } +} diff --git a/demo/src/env.ts b/demo/src/env.ts new file mode 100644 index 00000000..9702a495 --- /dev/null +++ b/demo/src/env.ts @@ -0,0 +1,11 @@ +import { Env } from "../../src/core/widget"; +import QWeb from "../../src/core/qweb"; + +const qweb = new QWeb(); + +const env: Env = { + qweb: qweb, + services: {} +}; + +export default env; diff --git a/demo/src/main.ts b/demo/src/main.ts new file mode 100644 index 00000000..da0509d8 --- /dev/null +++ b/demo/src/main.ts @@ -0,0 +1,12 @@ +/// + +// import RootWidget from "./RootWidget"; +import Counter from "./Counter"; +import env from "./env"; + +document.addEventListener("DOMContentLoaded", async function() { + const rootWidget = new Counter(null); + rootWidget.setEnvironment(env); + const mainDiv = document.getElementById("app")!; + await rootWidget.mount(mainDiv); +}); diff --git a/package.json b/package.json index 82cb869c..60ac2832 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,12 @@ "description": "Core Utils for Odoo Web Client", "main": "index.js", "scripts": { - "build": "tsc -m amd --lib es2017,dom --target esnext --outfile dist/bundle.js", - "build:watch": "tsc --watch -m amd --lib es2017,dom --target esnext --outfile dist/bundle.js", "test": "jest", - "test:watch": "jest --watch" + "test:watch": "jest --watch", + "bundle:core": "tsc -m es6 --lib es2017,dom --target esnext --outdir dist src/bundles/core.ts && rollup dist/bundles/core.js --file dist/bundle-core.js --format iife", + "bundle:core:watch": "nodemon -w src -e ts -x 'npm run bundle:core'", + "demo": "cp -r demo/ dist/demo && tsc -m amd --lib es2017,dom --target esnext --outfile dist/demo/main.js demo/src/main.ts", + "postdemo": "open dist/demo/index.html" }, "repository": { "type": "git", @@ -22,6 +24,10 @@ "devDependencies": { "@types/jest": "^23.3.12", "jest": "^23.6.0", + "nodemon": "^1.18.9", + "rollup": "^1.1.0", + "rollup-plugin-typescript2": "^0.19.0", + "source-map-support": "^0.5.10", "ts-jest": "^23.10.5", "typescript": "^3.2.2" } diff --git a/src/bundles/core.ts b/src/bundles/core.ts new file mode 100644 index 00000000..c3159c73 --- /dev/null +++ b/src/bundles/core.ts @@ -0,0 +1,10 @@ +import QWeb from "../core/qweb"; +import * as utils from "../core/utils"; +import Widget from "../core/widget"; + + +(window).core = { + QWeb, + utils, + Widget +} \ No newline at end of file diff --git a/src/qweb.ts b/src/core/qweb.ts similarity index 98% rename from src/qweb.ts rename to src/core/qweb.ts index 056b71bf..b096aa46 100644 --- a/src/qweb.ts +++ b/src/core/qweb.ts @@ -75,9 +75,9 @@ export class Context { } } addLine(line: string) { - const prefix = new Array(this.indentLevel).join('\t'); + const prefix = new Array(this.indentLevel).join("\t"); const lastChar = line[line.length - 1]; - const suffix = lastChar !=='}' && lastChar !== '{' ? ';' : ''; + const suffix = lastChar !== "}" && lastChar !== "{" ? ";" : ""; this.code.push(prefix + line + suffix); } } diff --git a/src/qweb_directives.ts b/src/core/qweb_directives.ts similarity index 100% rename from src/qweb_directives.ts rename to src/core/qweb_directives.ts diff --git a/src/utils.ts b/src/core/utils.ts similarity index 100% rename from src/utils.ts rename to src/core/utils.ts diff --git a/src/core/widget.ts b/src/core/widget.ts new file mode 100644 index 00000000..24f66fb5 --- /dev/null +++ b/src/core/widget.ts @@ -0,0 +1,79 @@ +import QWeb from "./qweb"; + +export interface Env { + qweb: QWeb; + services: { [key: string]: any }; + [key: string]: any; +} + +export default class Widget { + name: string = "widget"; + template: string = "
"; + + parent: Widget | null; + children: Widget[] = []; + env: Env | null = null; + el: ChildNode | null = null; + state: Object = {}; + refs: { [key: string]: Widget } = {}; + + //-------------------------------------------------------------------------- + // Lifecycle + //-------------------------------------------------------------------------- + + constructor(parent: Widget | null, props?: any) { + this.parent = parent; + if (parent) { + parent.children.push(this); + this.setEnvironment(parent.env); + } + } + + async willStart() {} + + mounted() {} + + willUnmount() {} + + destroyed() {} + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + async mount(target: HTMLElement) { + await this.willStart(); + await this.render(); + target.appendChild(this.el!); + } + + destroy() {} + + setEnvironment(env: Env | null) { + this.env = env ? Object.create(env) : null; + if (this.env) { + this.env.qweb.addTemplate(this.name, this.template); + delete this.template; + } + } + + async updateState(newState: Object) { + Object.assign(this.state, newState); + await this.render(); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + async render() { + const fragment = await this.env!.qweb.render(this.name, this); + this._setElement(fragment.firstChild!); + } + + private _setElement(el: ChildNode) { + if (this.el) { + this.el.replaceWith(el); + } + this.el = el; + } +} diff --git a/tests/qweb.test.ts b/tests/qweb.test.ts index c2bbfbb3..8fa64620 100644 --- a/tests/qweb.test.ts +++ b/tests/qweb.test.ts @@ -1,5 +1,5 @@ -import QWeb from "../src/qweb"; -import { EvalContext } from "../src/qweb"; +import QWeb from "../src/core/qweb"; +import { EvalContext } from "../src/core/qweb"; function renderToDOM(t: string, context: EvalContext = {}): DocumentFragment { diff --git a/tests/utils.test.ts b/tests/utils.test.ts index bbbee6ee..012e96c3 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -1,4 +1,4 @@ -import { escape, htmlTrim } from "../src/utils"; +import { escape, htmlTrim } from "../src/core/utils"; describe("escape", () => { test("normal strings", () => { diff --git a/tests/widget.test.ts b/tests/widget.test.ts new file mode 100644 index 00000000..88e2e3c7 --- /dev/null +++ b/tests/widget.test.ts @@ -0,0 +1,56 @@ +import Widget from "../src/core/widget"; +import QWeb from "../src/core/qweb"; + +function makeWidget(W: typeof Widget): Widget { + const env = { + qweb: new QWeb(), + services: {} + }; + const w = new W(null); + w.setEnvironment(env); + return w; +} + +async function click(el: HTMLElement) { + el.click(); + return Promise.resolve(); +} + +const template = ` +
+`; + +export default class Counter extends Widget { + name = "counter"; + template = template; + state = { + counter: 0 + }; + + inc() { + this.updateState({ counter: this.state.counter + 1 }); + } +} + +describe("basic widget properties", () => { + test("has no el after creation", async () => { + const widget = makeWidget(Widget); + expect(widget.el).toBe(null); + }); + + test("can be mounted", async () => { + const widget = makeWidget(Widget); + const target = document.createElement("div"); + await widget.mount(target); + expect(target.innerHTML).toBe("
"); + }); + + test("can be clicked on and updated", async () => { + const counter = makeWidget(Counter); + const target = document.createElement("div"); + await counter.mount(target); + expect(target.innerHTML).toBe("
0
"); + await click((counter.el).getElementsByTagName("button")[0]); + expect(target.innerHTML).toBe("
1
"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 9dae633e..8a6f9631 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,5 @@ "strictPropertyInitialization": true, "strictNullChecks": true }, - "include": ["src/**/*.ts", "tests/**/*.ts", "examples/**/*.ts"] + "include": ["src/**/*.ts", "tests/**/*.ts", "demo/**/*.ts"] }