basic project infrastructure

This commit is contained in:
Géry Debongnie
2019-01-16 13:19:58 +01:00
parent 0f3b2d10da
commit f0d529139b
16 changed files with 671 additions and 9 deletions
+13
View File
@@ -3,3 +3,16 @@
This is a POC, not at all production ready code!!! 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
+434
View File
@@ -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
};
}());
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Odoo Web Core Demo</title>
<script src="almond.js"></script>
<script src="main.js"></script>
<script>
require('main');
</script>
</head>
<body>
<div id="app"></div>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
import Widget from "../../src/core/widget";
const template = `
<div t-debug="1">
<button t-on-click="increment(-1)">-</button>
<span>Value: <t t-esc="state.counter"/></span>
<button t-on-click="increment(1)">+</button>
</div>
`;
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 });
}
}
+11
View File
@@ -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;
+12
View File
@@ -0,0 +1,12 @@
///<amd-module name="main" />
// 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);
});
+9 -3
View File
@@ -4,10 +4,12 @@
"description": "Core Utils for Odoo Web Client", "description": "Core Utils for Odoo Web Client",
"main": "index.js", "main": "index.js",
"scripts": { "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": "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": { "repository": {
"type": "git", "type": "git",
@@ -22,6 +24,10 @@
"devDependencies": { "devDependencies": {
"@types/jest": "^23.3.12", "@types/jest": "^23.3.12",
"jest": "^23.6.0", "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", "ts-jest": "^23.10.5",
"typescript": "^3.2.2" "typescript": "^3.2.2"
} }
+10
View File
@@ -0,0 +1,10 @@
import QWeb from "../core/qweb";
import * as utils from "../core/utils";
import Widget from "../core/widget";
(<any>window).core = {
QWeb,
utils,
Widget
}
+2 -2
View File
@@ -75,9 +75,9 @@ export class Context {
} }
} }
addLine(line: string) { 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 lastChar = line[line.length - 1];
const suffix = lastChar !=='}' && lastChar !== '{' ? ';' : ''; const suffix = lastChar !== "}" && lastChar !== "{" ? ";" : "";
this.code.push(prefix + line + suffix); this.code.push(prefix + line + suffix);
} }
} }
View File
+79
View File
@@ -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 = "<div></div>";
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;
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import QWeb from "../src/qweb"; import QWeb from "../src/core/qweb";
import { EvalContext } from "../src/qweb"; import { EvalContext } from "../src/core/qweb";
function renderToDOM(t: string, context: EvalContext = {}): DocumentFragment { function renderToDOM(t: string, context: EvalContext = {}): DocumentFragment {
+1 -1
View File
@@ -1,4 +1,4 @@
import { escape, htmlTrim } from "../src/utils"; import { escape, htmlTrim } from "../src/core/utils";
describe("escape", () => { describe("escape", () => {
test("normal strings", () => { test("normal strings", () => {
+56
View File
@@ -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 = `
<div><t t-esc="state.counter"/><button t-on-click="inc">Inc</button></div>
`;
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("<div></div>");
});
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("<div>0<button>Inc</button></div>");
await click((<HTMLElement>counter.el).getElementsByTagName("button")[0]);
expect(target.innerHTML).toBe("<div>1<button>Inc</button></div>");
});
});
+1 -1
View File
@@ -17,5 +17,5 @@
"strictPropertyInitialization": true, "strictPropertyInitialization": true,
"strictNullChecks": true "strictNullChecks": true
}, },
"include": ["src/**/*.ts", "tests/**/*.ts", "examples/**/*.ts"] "include": ["src/**/*.ts", "tests/**/*.ts", "demo/**/*.ts"]
} }