Compare commits

..

252 Commits

Author SHA1 Message Date
Géry Debongnie c1269288f5 [REL] v1.0.0alpha5 2019-11-29 16:11:34 +01:00
Géry Debongnie 8c17bb0411 [FIX] component: reorganize fiber update algorithm
Closes #473
Closes #484
Closes #489
Closes #492
Closes #512

Co-authored-by: Aaron Bohy <aab@odoo.com>
2019-11-29 15:13:37 +01:00
Lucas Perais (lpe) 7f6782d009 [FIX] qweb: cascading t-call and t-raw="0"
Have a t-call having a t-raw="0"
that call is made to a template of the same form
i.e. itself as a t-call t-raw="0" structure

Before this commit, there was recursion crash
This was because the caller to which a t-raw="0" referred to
was incorrect. The caller here is the node which makes the t-call.
In particular, the caller was always set to last caller of the context

After this commit, there is no crash and this imbrication
of t-call and t-raw="0" is well rendered.
To sum up, we count the recursive calls to t-raw="0" and fetch
the caller in the context accordingly

closes #510
2019-11-29 13:17:40 +01:00
Aaron Bohy 6e5d6aa226 [FIX] slots: use correct scope and vars at update
Part of #473
2019-11-28 14:42:46 +01:00
Aaron Bohy 9400c0adad [FIX] playground: stop using class fields
as Edge doesn't support them.

Closes #509
2019-11-28 10:21:45 +01:00
Aaron Bohy 85d4393242 [FIX] t-slot: correct parented relationship with nested slots
Closes #506
2019-11-28 10:15:57 +01:00
Aaron Bohy 6c8b401092 [FIX] component: slots: multiple slots with components
Closes #508
2019-11-27 15:47:55 +01:00
Aaron Bohy 12be815342 [REF] qweb: use global nextID
and reset it to 1 before each test s.t. snapshots are deterministic.
2019-11-27 15:47:55 +01:00
Sébastien Theys 65344dbf1f [FIX] store: fix useStore update when mixing state and props changes
This commit also fixes an issue where updates would not be triggered when the
new and old revNumber where the same but concerned a different object.
2019-11-27 15:09:47 +01:00
Sébastien Theys a6f9b26057 [FIX] store: ensure useStore proper clean up on destroy 2019-11-27 15:09:47 +01:00
Géry Debongnie 6c753bb49f [FIX] qweb: scope t-key directive to node and subnodes
not to siblings

closes #503
2019-11-27 13:31:40 +01:00
Géry Debongnie 1f079b883e [IMP] store: prevent changes to store state in useStore return value
closes #500
2019-11-27 08:18:44 +01:00
Géry Debongnie 8fb35ed969 [FIX] owl: add prettier to devDependencies
also, run prettier on codebase

closes #498
2019-11-27 08:06:17 +01:00
Géry Debongnie a3f2d07b40 [DOC] add link to useSubEnv in environment doc 2019-11-26 22:32:25 +01:00
Sébastien Theys c993278c80 [IMP] store: allow useStore to return primitive types
Related to #404
2019-11-26 22:17:05 +01:00
Géry Debongnie 3aa586db43 [IMP] component: allow custom validator functions for props
closes #375
2019-11-22 15:43:22 +01:00
Géry Debongnie 14db513f3b [FIX] component: allow fragments in method mount
It is now possible to mount components in fragments

closes #494
2019-11-22 15:00:37 +01:00
Géry Debongnie 7bb04185c7 [FIX] playground: fix wms sample
It was crashing when we close a window, because there are two event
handlers for the click event on Window.  But when we close a window, we
actually do not want to update the z index.
2019-11-22 13:41:44 +01:00
Géry Debongnie 48744cfa87 [DOC] add hooks explanation, and testing/debug page
closes #372
closes #419
2019-11-22 13:41:44 +01:00
Géry Debongnie ff76747a05 [REL] v1.0.0-alpha4 2019-11-22 13:03:02 +01:00
Aaron Bohy 7e6b1a28a0 [IMP] qweb: throw error if t-component not used on a node t
Before this rev, using t-component on a div (for example) node
would silently ignore the div and replace it by the root node of
the component. A way to improve this was to create an extra div
node and to put the component inside it. However, it raised
questions: what do we do with other attributes set on this tag?
Do we apply them on the div, or on the component? In addition,
some of them only make sense on the component (e.g. props), so we
have to detect them. Whatever we would have decided, it wouldn't
have been obvious from the user point of view, so we chose not to
support it, and we thus now raise an error in this case.

Closes #487.
2019-11-21 17:06:35 +01:00
Aaron Bohy 58f6724194 [FIX] component: reset isRendered when reusing fiber
It is important to reset the isRendered flag to false to ensure
that other (subsequent) simultaneous calls to render will be
skipped, as the currentFiber is actually no yet (re-)rendered.

Closes #483
2019-11-21 15:35:30 +01:00
Aaron Bohy 3c0f8ac76f [IMP] component: introduce OwlEvent
Closes #485
2019-11-21 13:12:22 +01:00
Géry Debongnie cb11c0118c [FIX] context tests: make them deterministic
We learn something new every day, and today is no exception. Promise.all
of a pending promise does not actually resolve as fast as possible after
the initial promise is completed, but a small delay after, because it is
put on the macrotask queue or something like that (I assume)

closes #480
2019-11-20 15:44:12 +01:00
Géry Debongnie 27b4eece66 [FIX] component: display correct error in some case
It could happen that a component would crash. But then, the __render
code tried to copy the class properties into the vnode, which does not
exist, creating a new error.

So, the solution is to process the classObj only in the successful part
of the render call, which then will not crash, so the normal error
handling will occur.
2019-11-20 14:35:25 +01:00
Aaron Bohy 2e3e8cd603 [FIX] component: destroy not yet mounted components (2)
Following 2922cee6ea

Previous commit was not correct: used children with a cancelled
fiber (for instance, with a new currentFiber) could be destroyed.
This commit fixes the issue differently, by directly detecting
children that are not used anymore (and not mounted), and destroy
them directly (no need to wait for willStart promise to be resolved
anymore).
2019-11-20 14:31:03 +01:00
Aaron Bohy 28ee790b3e [FIX] context: always remove subscription when destroyed
Before this rev., components destroyed before being mounted didn't
stop listening to the context changes.

Closes #476
2019-11-20 11:43:14 +01:00
Aaron Bohy 2922cee6ea [FIX] component: can destroy not yet mounted components
Sub-issue of #476
2019-11-20 11:43:14 +01:00
Géry Debongnie 7749fd3b96 [FIX] component: remove stale fiber in scheduler
Owl takes care of not rendering a component that will be unmounted
immediately after, and it works.  However, it also should clean
up the newly added fiber.
2019-11-20 11:24:32 +01:00
Géry Debongnie f44b9a38ae [IMP] component: better coordination for errors in rendering
part of #410
2019-11-20 11:24:32 +01:00
Géry Debongnie c7773bfd2a [FIX] qweb/component: fix scoping issue with t-model and t-foreach
Without this fix, the handler set by t-model did not capture properly
the expression that needs to be updated.

closes #474
2019-11-19 13:15:34 +01:00
Géry Debongnie bd39797f17 [FIX] component: propagate errors to caller
Errors from mounted/willPatch/patched should be returned to caller
(either mount or render functions)

part of #410
2019-11-18 14:29:54 +01:00
Géry Debongnie 94a595ef5b [FIX] qweb: add support for typeof expression
closes #469
2019-11-18 07:57:43 +01:00
Lucas Perais (lpe) f83846e054 [FIX] package, vdom: condition always true, TS 3.7.2
Before this commit, a condition deemed always true made
the building crash. The happened with TypeScript == 3.7.2

After this commit, the required typescript version has been
changed, as well as the incriminated true condition
2019-11-15 13:10:02 +01:00
Géry Debongnie a97144366d [FIX] playground: fix downloaded app code
The generated downloaded code from the playground did not work because
it did not load templates into QWeb anymore.

closes #461
2019-11-15 11:19:27 +01:00
Aaron Bohy 79983de4ed [FIX] context: notify changes even if owner not mounted
Closes #444
2019-11-15 07:34:15 +01:00
Géry Debongnie 54e1734f2f [IMP] component: display better error message if render is empty
closes #446
2019-11-14 16:40:07 +01:00
Géry Debongnie 4aea093ed9 [FIX] tools: fix benchmark for v0.24.0
It was not using the correct owl version
2019-11-14 15:51:53 +01:00
Géry Debongnie 56087b95cd [REF] observer: notify subscribers directly
This rev. moves the logic of batching the notifications of changes
occurring in the same microtask tick, from the observer, to the
listeners (the context, and the render function of components).
By doing so in render, we ensure that similar renderings are
batched in a single one, wherever they come from (state change,
store change, direct call...).
2019-11-14 15:12:31 +01:00
Aaron Bohy e5940b4b6b [FIX] component: concurrent calls to mount and render
Closes #450
2019-11-14 15:12:31 +01:00
Géry Debongnie bbdc9d90d7 [FIX] qweb: allow mixing body and expression variables
closes #445
2019-11-14 14:54:27 +01:00
Géry Debongnie f2b3ebd1ec [FIX] component: properly set currentFiber to null in all cases
Whenever a rendering is completed, we need to reset the currentFiber
to null to make sure all subsequent renderings will not be
confused.

However, the way it was done before this commit was wrong in a
specific situation: we resetted the currentFiber to null in the
patch method.  The idea was that this method was called every time
the component completes a rendering. But this is not true: it
can happen that a component is unmounted and remounted without
changes.  Then the component will be patched, but if there is no
change, it will not call recursively the patched methods of its
children.

So, we need to choose a better place to reset it to null.

closes #454
2019-11-14 11:56:13 +01:00
Géry Debongnie 55bb09ba1a [FIX] component: fix invalid prop validation situations
closes #453
2019-11-14 09:27:09 +01:00
Géry Debongnie d249f50d09 [REL] v1.0.0-alpha3 2019-11-13 15:43:47 +01:00
Géry Debongnie 749f0063ea [FIX] component: properly validate optional types in objects
closes #440
2019-11-13 15:36:46 +01:00
Géry Debongnie 263f31fea9 [DOC] miscellaneous improvements
including:

- add a link to the tutorial in the main readme page
- remove learning page on 'env', move content on reference page
- add dynamic sub components section in component page
- add reference page on props
- split qweb page into engine/language pages
- move t-key information into qweb language page
2019-11-13 08:49:25 +01:00
Géry Debongnie b891ae7de4 [REM] tools: remove error handling in playground
There was some code to attempt to display the error in the right
pane, whenever it occured in the app early phase.

However, it made it much harder to properly handle all cases. It could
silently swallow some errors (if more than one was done), And it was
not behaving the same way in Firefox and in Chrome.
2019-11-13 08:49:25 +01:00
Géry Debongnie c5a80497ac [FIX] tooling: properly set playground in dev mode
It was not in dev mode since changes to config system
2019-11-13 08:49:25 +01:00
Aaron Bohy e47f604449 [FIX] component: concurrent rendering issue
Resolved rendering with cancelled fiber for (not yet) destroyed
component -> Cannot read property 'sel' of null

Closes #421
2019-11-12 10:01:12 +01:00
Aaron Bohy 7e721a96b5 [IMP] hooks: useRef on component: set el anyway
Closes #437
2019-11-06 17:06:37 +01:00
Aaron Bohy af4f372506 [REF] component: mount: remove renderBeforeRemount option
Re-render the component all the time before remounting it, which
seems safer anyway.

In the test, we had to add a nextTick to wait for the changes to
be notified (before, we waited thanks to the additional call to
mount). If we don't do the nextTick, mount is called, and render
is called right after (before the promise returned by mount is
resolved). This scenario doesn't work right now (see #441).

Closes #381
2019-11-06 16:26:12 +01:00
Aaron Bohy 5731358607 [IMP] component: expose scheduler instance
Closes #433
2019-11-05 15:11:13 +01:00
Géry Debongnie 1226f015d7 [DOC] add a tutorial (TodoApp) 2019-11-04 16:50:16 +01:00
Géry Debongnie b211e75140 [IMP] store: display error if no store found 2019-11-04 08:41:29 +01:00
Géry Debongnie 8d1dd06340 [IMP] component: error when mounted on invalid target 2019-11-04 08:41:29 +01:00
Géry Debongnie bd3c1265d3 [REL] v1.0.0-alpha2 2019-11-01 09:12:33 +01:00
Géry Debongnie 2413c98f50 [REF] component: small optimization in rare cases
When we have default props, we do not need to allocate a new
props object and copy it. This commit simply modify the props
object in place, which is fine, since the props object is always
a new one any way, generated by the directive.
2019-11-01 09:03:30 +01:00
Géry Debongnie d74b5a03db [IMP] component: add props on root components
This is a partial revert of commit 7d249d6f09.

The reason is that this changes made it much harder to unit test
components.  Before, we could simply instantiate a component
like this:
const my|Comp = new MyComponent(null, props);

and then simply test it.  This commit reestablish that possibility.
2019-11-01 09:03:27 +01:00
Géry Debongnie 83532db48f [DOC] adapt documentation to env changes
part of #430
2019-11-01 09:03:27 +01:00
Géry Debongnie 534152eff7 [REF] component: small improvement
and run prettier
2019-11-01 08:39:35 +01:00
Aaron Bohy 05a678c039 [IMP] component: get env from constructor
closes #430
2019-11-01 08:39:35 +01:00
Géry Debongnie 8fbf2172c5 [DOC] slightly reorganize main documentation page 2019-10-31 12:32:26 +01:00
Géry Debongnie ea1376d0ca [FIX] tools: make benchmarks work on firefox
Firefox does not support the static keyword yet, so the benchmark code should be modified to make it work.
2019-10-31 12:32:26 +01:00
Géry Debongnie ba483b6e2c [FIX] component tests: make them work on windows
For an unknown reason, it looks like the way ticks, micro task ticks or other subtle scheduling issues are different on a windows machine (it may be because of other difference, such as a specific version of nodejs).

Anyway, I could not find the cause of the issue, but
simply waiting an extra microtask tick seems to work.
2019-10-31 12:32:26 +01:00
Géry Debongnie 2fc71cfb62 [REL] v1.0.0-alpha
v1.0alpha

The Alpha release!

Owl is finally getting stable. This relase is all about cleaning Owl
API.  We
are pretty happy with the current state, and hopefully, we won't have to
make
any non trivial change for a while.

QWeb

- add an option to setup a translate function
- implement `t-key` with a directive (it now works on `t` tags)

Component

- properly handle errors in `mount` and `render`
- fix: make sure props are validated in all cases
- fix: make sure default props are applied at the proper time
- fix: better error handling with sub components
- imp: simplify constructor API: does not take an `env`
- imp: do not set `props` in root components
- remove `t-keepalive` directive

Context

- update components concurrently, instead of sequentially

Observer

- remove `revNumber` (and rename `deepRevNumber` into `revNumber`)

Router

- fix: preserve pathname in hash mode

Config

- create new config object, with `mode` and `env` keys

Playground

- log git commit hash in console
2019-10-30 16:35:45 +01:00
Aaron Bohy 08cb83149e [REM] component: remove t-keepalive directive
We don't see any usecase for it, it makes the code more complex,
and there were still potential unresolved concurrency issues with
it.

Part of task #295
2019-10-30 16:35:45 +01:00
Aaron Bohy 7d249d6f09 [IMP] component: do not set props in root components
Also remove props from Fiber, as it was not useful anymore.
2019-10-30 15:09:10 +01:00
Géry Debongnie 0addca63a0 [DOC] update the documentation to new config and env
part of #306
2019-10-30 15:09:10 +01:00
Géry Debongnie 5ba73cc09d [DOC] move quick_start into learning/ sub folder 2019-10-30 15:09:10 +01:00
Aaron Bohy 9106c19066 [IMP] component: simplify constructor API
It now takes two arguments: parent (optional, only for non-root
components) and props (optional). In the case of the root component,
the env is taken from the config (config.defaultEnv). If it doesn't
exists, the default env is created on the fly.

Closes #306
2019-10-30 15:09:10 +01:00
Aaron Bohy 9f93da4765 [REF] config: move mode from owl.__info__ to owl.config
and move it to its own file.
2019-10-30 15:09:10 +01:00
Aaron Bohy 9e37b968e8 [FIX] component: error handling: rendering with sub components
Closes #425
2019-10-30 13:27:48 +01:00
Aaron Bohy fa6801b523 [REF] observer: remove revNumber
Closes #257
2019-10-30 11:13:52 +01:00
Géry Debongnie 9edf29a3a1 [IMP] context: group components by depth
closes #399
2019-10-29 16:02:51 +01:00
Géry Debongnie 6a434310ee [FIX] *: run prettier 2019-10-29 16:02:51 +01:00
Géry Debongnie e7967d0779 [FIX] package.json: improve prettier task
to take into account files at more than one level
2019-10-29 16:02:51 +01:00
Géry Debongnie b0e2ef82f7 [REL] bump to v0.24.1 2019-10-28 14:58:52 +01:00
Aaron Bohy c9c2b3fa6d [IMP] tests: add concurrency test 2019-10-28 14:46:21 +01:00
Géry Debongnie 62608cbb0d [DOC] add architecture notes on rendering pipeline 2019-10-28 14:23:02 +01:00
Géry Debongnie 3035a9f009 [DOC] move reference doc in subfolder 2019-10-28 14:23:02 +01:00
Géry Debongnie c2adb429bd [FIX] package.json: update node and typescript version
closes #414
2019-10-28 13:58:44 +01:00
Géry Debongnie da9dda5eca [DOC] component: add more information on t-key
closes #353
2019-10-28 11:45:51 +01:00
Géry Debongnie 5614c85b04 [IMP] qweb: implement t-key with a directive
closes #331
2019-10-28 11:45:51 +01:00
Aaron Bohy 5d57a7bf13 [FIX] component: updateProps: validation and default values
Before this rev., default values weren't taken into account when
validating props whenever a component was updated. Moreover, there
was no test attesting that props were validated at update.
2019-10-28 11:32:43 +01:00
Géry Debongnie 4ebe419c56 [FIX] component: make sure props are validated in all cases
closes #379
2019-10-28 11:32:43 +01:00
Géry Debongnie 9779cd196c [REF] fiber: small cleanups 2019-10-28 11:32:43 +01:00
Géry Debongnie 9cee12d7b4 [IMP] component: propagate error to mount and render 2019-10-28 11:32:43 +01:00
Géry Debongnie 2c563ee380 [REF] component: move error handling into the fiber 2019-10-28 11:32:43 +01:00
Géry Debongnie 2aa705f5c8 [FIX] router: preserve pathname in hash mode
closes #397
2019-10-28 11:32:43 +01:00
Géry Debongnie 5911c8e3f6 [DOC] store,hooks: useState/useStore should return object
or array

closes #404
2019-10-28 11:32:43 +01:00
Géry Debongnie 2279dafbec [IMP] qweb: add an option to setup a translate function
closes #393
2019-10-28 11:32:41 +01:00
Géry Debongnie f07ec21a07 [FIX] playground: add missing ;
Because of that missing ";", the playground application tried to inject
this:

window.TEMPLATES = `...`

instead of this:

window.TEMPLATES = `...`;

So, when the following line started with a (, then the javascript code
was interpreted as a function call. And it could happen, because some
people like to use the IIFE syntax to have some contained code.
2019-10-26 09:34:51 +02:00
Géry Debongnie b5b6b7342e [DOC] improve main readme.md 2019-10-26 09:27:35 +02:00
Géry Debongnie 97b914b9cd [IMP] playground: log url for current owl commit
closes #405
2019-10-26 09:21:04 +02:00
Géry Debongnie 9dfcfda365 [DOC] add more info on xml tag and single file component
closes #401
2019-10-26 09:10:50 +02:00
Géry Debongnie e026f537ae [ADD] tools: add benchmarks for v0.24.0 2019-10-25 17:17:20 +02:00
Géry Debongnie f0b5a55ad1 [REL] bump to v0.24.0 2019-10-25 17:07:51 +02:00
Géry Debongnie 690d8edf67 [FIX] context: solve tricky concurrency issue
part of #330
2019-10-25 16:01:52 +02:00
Géry Debongnie 3c38bbc076 [REF] component: large cleanup of concurrency branch
We remove here old comments, add some tests and documentation, and in
general, make sure the state of the code is in a good shape

part of #330
2019-10-25 16:01:52 +02:00
Aaron Bohy 9c5cad15c1 [IMP] component: refactor rendering pipeline
This commit introduces a brand new rendering system based on a fiber
class and a scheduler.

closes #330
2019-10-25 16:01:52 +02:00
Géry Debongnie 2bed1cfbd1 [REF] asyncroot: create asyncroot component
This component replaces the t-asyncroot directive.
2019-10-25 16:01:52 +02:00
Géry Debongnie caa9ac5cb2 [ADD] project: add prettier task to package.json 2019-10-25 16:01:52 +02:00
Géry Debongnie 67349eb0f0 [IMP] vdom: remove handling of combined selectors
this is done by qweb anyway
2019-10-25 16:01:52 +02:00
Géry Debongnie 1ca401811f [DOC] add example of inline statement in main doc 2019-10-25 14:56:09 +02:00
Aaron Bohy 2beb12678e [IMP] t-on directive: allow empty handler
Closes #377
2019-10-25 14:11:44 +02:00
Aaron Bohy 0ea1091692 [IMP] t-on directive: handle inline statements
Closes #265
2019-10-25 14:11:44 +02:00
Nicolas Bayet 71827c3ba8 [DOC] explain router history and hash mode 2019-10-25 13:36:18 +02:00
Géry Debongnie c2f42d3b82 [FIX] qweb: do not format expression twice in t-if
closes #392
2019-10-24 14:57:48 +02:00
Géry Debongnie a3317ab997 [FIX] qweb: handle variable expressions in t-if
closes #390
closes #362
2019-10-24 08:55:30 +02:00
Géry Debongnie 403935a41e [DOC] qweb: document t-att variant
closes #378
2019-10-22 22:03:10 +02:00
Géry Debongnie 39af9ec938 [DOC] improve store documentation on useGetters
closes #386
2019-10-22 22:00:41 +02:00
Géry Debongnie 13128ed425 [FIX] component: do not validate props twice
it is not useful to do it in the component directive, especially since
it is done in the constructor, and the default props are not applied.

closes #379
2019-10-22 21:58:51 +02:00
Géry Debongnie be556a970e [FIX] qweb: properly calls directive finalizers
closes #382
2019-10-22 21:56:29 +02:00
Aaron Bohy da6c24bbca [FIX] test: do not check external links in doc 2019-10-21 09:47:20 +02:00
Géry Debongnie f6d6da8393 [FIX] component: fix issues with component internal template key
closes #298
2019-10-21 09:41:27 +02:00
Géry Debongnie 918945c11e [DOC] improve the comparison documentation page
closes #365
closes #366
2019-10-20 14:46:01 +02:00
Géry Debongnie 29c2c5b9c9 [DOC] improve the documentation
closes #340
closes #344
closes #352
closes #355
2019-10-20 09:17:52 +02:00
Aaron Bohy 1bb4577ec1 [DOC] improve hooks and qweb doc
Closes #358
Closes #359
2019-10-18 09:42:03 +02:00
Géry Debongnie 0aeebd7b6e [REF] utils: rename loadTemplates into loadFile
closes #351
2019-10-18 08:05:26 +02:00
Géry Debongnie 4bc49e7241 [DOC] add a roadmap
closes #347
2019-10-17 22:36:44 +02:00
Géry Debongnie 2b5783d9bd [FIX] playground: update samples to make them work on firefox
Sadly, Firefox does not support yet static class fields, so we need to
use an equivalent, but slightly not as nice, syntax.

closes #335
2019-10-17 16:57:40 +02:00
Géry Debongnie 1f40f113aa [DOC] update the documentation
closes #334
closes #336
closes #338
closes #339
closes #341
closes #342
closes #343
closes #346
closes #348
2019-10-17 12:08:24 +02:00
Géry Debongnie 9e65b0686f [REL] bump to v0.23.0 2019-10-16 22:31:04 +02:00
Géry Debongnie 1d72164015 [FIX] component: support component reduced to a slot 2019-10-16 21:48:49 +02:00
Géry Debongnie 2825a5a55d [FIX] qweb: properly handle xml comments 2019-10-15 15:47:50 +02:00
Géry Debongnie d40ff53f5a [DOC] doc: add more information in store section 2019-10-14 15:00:31 +02:00
Aaron Bohy 23246c42aa [REF] *: snakecase filenames 2019-10-14 11:06:59 +02:00
Géry Debongnie 895fe7c60f [REF] store: replace ConnectedComponent by useStore hook
Closes #304
2019-10-14 11:06:59 +02:00
Géry Debongnie 19b10f7c1d [IMP] playground: update todoapp sample 2019-10-14 11:06:59 +02:00
Géry Debongnie 0788d9fe75 [REF] qweb: rename Context into CompilationContext 2019-10-14 11:06:59 +02:00
Géry Debongnie 4a346d0615 [FIX] qweb: properly remove t-ref reference
when the node is removed from the dom
2019-10-14 11:06:59 +02:00
Géry Debongnie 1337aa0107 [IMP] component: add current key for easier hook creation
closes #319
2019-10-14 10:25:34 +02:00
Géry Debongnie 3e8f60cefa [FIX] qweb: properly handle t-raw in various situations
closes #325
closes #327
2019-10-14 09:59:26 +02:00
Géry Debongnie 41b0618c93 [FIX] qweb: improve generated compiled code 2019-10-11 14:10:44 +02:00
Aaron Bohy 0928c189f4 [FIX] qweb: allow to combine t-esc with other directives
With this rev., when a t-esc is not set on a <t> tag, we create
it inside the node on which it is set, to ensure that the t-esc
directive is always set on <t> tags.

Same applies for t-raw.

Closes #324
2019-10-11 14:10:44 +02:00
Aaron Bohy 2f27768be2 [FIX] qweb: event modifier with arg in t-foreach
Closes #266
2019-10-11 14:01:07 +02:00
Aaron Bohy aa1b0f502d [FIX] tests: fix import 2019-10-11 14:01:07 +02:00
Géry Debongnie da1824f4dd [DOC] doc: improve navigation to main doc page
also, fixes a small error
2019-10-10 12:28:36 +02:00
Géry Debongnie 07dac970f7 [IMP[ component/hooks: add onWillStart and onWillUpdateProps 2019-10-09 15:09:26 +02:00
Géry Debongnie 5f6f081ae2 [IMP] playground: save changes to local storage
closes #316
closes #101
2019-10-09 10:15:09 +02:00
Géry Debongnie 1c3c2e5c51 [IMP] hooks/Context: add Context and useContext
Closes #310
2019-10-08 15:56:46 +02:00
Aaron Bohy 17ce73e6a9 [REF] hooks: improve makeLifecycleHook 2019-10-08 15:52:44 +02:00
Géry Debongnie cedb1f411f [REF] component: move template check in constructor 2019-10-08 13:51:55 +02:00
Géry Debongnie e7516be95c [DOC] component: remove doc for removed updateEnv method 2019-10-07 16:07:56 +02:00
Géry Debongnie 683993dbc4 [FIX] component/hooks: onWillUnmount was not properly called 2019-10-07 16:01:28 +02:00
Géry Debongnie 50d569411c [DOC] hooks: fixing broken link 2019-10-07 15:51:45 +02:00
Géry Debongnie c3453d35b9 [IMP] hooks/component: remove updateEnv, add useSubEnv
closes #182
2019-10-04 22:54:27 +02:00
Géry Debongnie b0dd0252a4 [REF] component: move method at proper place 2019-10-04 22:54:27 +02:00
Géry Debongnie e2b2e54e29 [DOC] tooling: add missing link 2019-10-04 22:12:56 +02:00
Géry Debongnie 766c378ac9 [DOC] hooks: add missing doc for onWillPatch/onPatched 2019-10-03 11:57:58 +02:00
Géry Debongnie 022b29b6a0 [IMP] component/qweb: remove t-mounted directive
The t-mounted directive main goals can be achieved with hooks, in a
better and more intuitive way.

closes #308
2019-10-03 10:57:24 +02:00
Géry Debongnie 367725d194 [IMP] hooks: add onWillPatch and onPatched
This commit also changes the behaviour of willPatch and patched: they no
longer transfer data from one to the other, because it can be done
cleanly by hooks and a closure.

closes #307
2019-10-03 10:20:28 +02:00
Géry Debongnie e82ed7dc8c [DOC] component: add information on svg components 2019-10-02 16:40:13 +02:00
Géry Debongnie 63c3d4f6b0 [IMP] playground: slightly improve default text 2019-10-01 21:37:36 +02:00
Géry Debongnie b859fcb8f0 [REL] bump to v0.22.0 2019-10-01 21:05:04 +02:00
Géry Debongnie 3005756d89 [LEGAL] add copyright file 2019-10-01 20:44:39 +02:00
Géry Debongnie f6820bb8ac [REF] hooks: better implementation of onMounted/onWillUnmount 2019-09-28 21:14:40 +02:00
Géry Debongnie 090880f478 [DOC] readme: add quick overview section 2019-09-27 17:34:27 +02:00
Géry Debongnie 5cdaa7b473 [IMP] hooks: implement useRef hook
closes #194
2019-09-27 11:20:08 +02:00
Géry Debongnie 91b9e78182 [DOC] update component, tooling doc 2019-09-27 11:20:08 +02:00
Géry Debongnie 5335fb8fba [IMP] components: add hooks mechanism
part of #194
2019-09-27 11:20:08 +02:00
Géry Debongnie 4513e3f31c [FIX] qweb: improved svg support for components
If we want to generate dynamically svg components, it is useful to be
able to define components with a <g> tag as root, and then getting the
correct namespace.

closes #302
2019-09-26 15:46:42 +02:00
Géry Debongnie ecf12f6eee [FIX] qweb: properly supports svg
Snabbdom supports svg, but it adds the namespace at the creation of the
virtual node.  However, owl works slightly differently: it adds children
after creating the parent virtual node.

So, we need to actually call the addNS method after the children nodes
have been created.

As a bonus, this is slightly faster than snabbdom: we only check at
template compilation time once if a node is a svg.
2019-09-24 21:40:46 +02:00
Géry Debongnie 82a6961b3a [FIX] component: properly handle wrapper component and slots
closes #297
2019-09-23 07:13:51 +02:00
Géry Debongnie 78a4550ebb [IMP] component tags: allow injecting values in xml tag string
With this commit, we can now do this:

      const SUBTEMPLATE = xml`<span><t t-esc="state.n"/></span>`
      class Parent extends Widget {
          static template = xml`<div><t t-call="${SUBTEMPLATE}"/></div>`
          state = {n: 42};
      }
2019-09-21 23:05:44 +02:00
Géry Debongnie cc82b3ffcd [IMP] component: support dynamic t-component 2019-09-21 21:58:19 +02:00
Géry Debongnie bbbaf95c8e Merge pull request #296 from odoo/issue-293-owl-fibers
[MERGE] component: introduce owl fiber
2019-09-20 13:54:59 +02:00
Géry Debongnie caa63628dc [REF] component: refactor fiber creation methods 2019-09-20 11:14:50 +02:00
Géry Debongnie 7a49b9d94e [REF] component: move render props and promise to fiber
part of #293
2019-09-20 10:48:10 +02:00
Géry Debongnie 01eb338e69 [REF] component: move patchqueue management to fiber
part of #293
2019-09-20 10:47:58 +02:00
Géry Debongnie 3048c6f961 [REF] component: introduce fiber
part of #293
2019-09-20 10:47:55 +02:00
Géry Debongnie 8edc637033 [FIX] qweb: remove comments in xml strings before parsing
It was done in loadTemplates, but this does not work if we want to add
templates from another source.
2019-09-19 15:18:59 +02:00
Géry Debongnie c9910077a4 [FIX] qweb: do not capture current qweb instance in closure
it is not needed, and has a bad effect on globally registered templates.
2019-09-12 23:23:55 +02:00
Géry Debongnie 6bf70f016c [DOC] doc: update readme.md 2019-09-12 23:23:39 +02:00
Géry Debongnie 905619f80f [ADD] tools: add benchmark for 0.21.0 2019-09-12 14:50:56 +02:00
Géry Debongnie 14ceb382ef [REL] bump to v0.21.0 2019-09-12 14:21:36 +02:00
Géry Debongnie 9846b2e997 [IMP] tags: introduce xml tag
Big change! This commit introduces an xml function tag to easily define
inline templates.

This is a pretty big change toward single file owl components

Part of #284
2019-09-12 14:17:21 +02:00
Géry Debongnie cfc2fb2ba2 [REF] qweb: rename register method to registerComponent 2019-09-12 14:17:21 +02:00
Géry Debongnie d57017b6ed [IMP] qweb: add registerTemplate method 2019-09-12 14:17:21 +02:00
Géry Debongnie 5bf7a5c671 [IMP] component: make template key static
closes #288
2019-09-12 14:17:21 +02:00
Géry Debongnie 9b589af75a [REF] qweb: improve generated code in some cases 2019-09-12 09:43:09 +02:00
Géry Debongnie 471fd49063 [IMP] router: replace t-routecomponent directive by RouteComponent
closes #287
2019-09-11 14:58:36 +02:00
Géry Debongnie 4fc3423682 [IMP] component: support dynamic t-props
It is useful in some rare situations.

closes #144
2019-09-11 14:12:52 +02:00
Géry Debongnie ffb3263c79 [IMP] component: allow dynamic root nodes
closes #283
2019-09-11 13:12:57 +02:00
Géry Debongnie e1acf66143 [IMP] component: components key is now static
This is a breaking change!

closes #279
2019-09-11 11:18:49 +02:00
Géry Debongnie 2a40907e35 [FIX] qweb: do not crash in some rare t-call situation
Before this commit, QWeb crashed when it had to deal with a t-call with
multiple sub nodes on the same line, and one text node:

<t t-call="SomeTemplate">
  <span>hey</span> <span>hey</span>
</t>

This was because we need to compile the content to be able to extract
all possible t-set t-value statements. But doing so means that we had a
multiple root templates, which caused a crash in this case.

Solving this issue is simple: the sub template is compiled with the flag
allowMultipleRoots set to true.
2019-09-09 11:51:33 +02:00
Géry Debongnie 1c0dd79955 [MISC] update package.json 2019-09-05 21:44:29 +02:00
Géry Debongnie 9fad164c79 [REL] bump to v0.20.0 2019-09-05 14:41:12 +02:00
Géry Debongnie 53a8841914 [TEST] component: snapshots slot functions 2019-09-05 14:33:43 +02:00
Géry Debongnie 19f0863aa3 [IMP] qweb: allow recursive templates t-calls
closes #267
2019-09-05 14:33:43 +02:00
Géry Debongnie 28f7b44f9e [IMP] component: make t-model works with sub state keys
closes #273
2019-09-05 08:47:37 +02:00
Géry Debongnie fe46a6ccee [CLEANUP] run prettier on some code 2019-09-04 22:48:37 +02:00
Géry Debongnie 3c23a7b246 [FIX] component: t-model properly works with select
The initial value was not set on the select tag.

closes #271
2019-09-04 22:45:21 +02:00
Géry Debongnie c52799de2c [FIX] ConnectedComponent: keep props uptodate
This is a tricky bug. The problem is that a ConnectedComponent can
trigger a rendering before it is aware that there is a state change in
the store, and just before the store update event comes in.

What we do in this commit is to update the storeProps whenever a
rendering is scheduled.  However, we need to keep the rendering
information as a promise to give it in some case to the __checkUpdate
method (see the note in the render method)

closes #268
2019-09-04 15:36:10 +02:00
Géry Debongnie 4edb90f44b [FIX] router: properly handle popstate
It was not correct, the url was changed multiple times and back
navigation was broken.

Also, note that there are no tests because the history api is not
available in jsdom.
2019-09-04 13:59:54 +02:00
Géry Debongnie 0c1a7aabae [FIX] component: better templateId for multiple subcomponents
Of course, the templateId computation is kind of tricky.  Here, an issue
occurred because the templateId did not take the componentId into
account when we were in a loop, but with no keyed parent.

This meant that multiple sub components shared the same templateId,
confusing the vdom algorithm.
2019-09-03 16:38:24 +02:00
Géry Debongnie cfdce29ce7 [FIX] component: avoid key collisions in sub components
This is a difficult part of owl: we need to be able to reconcile the
vdom generated (this is done with our virtual dom algorithm), but also
to be able to reconcile the proper components.

The issue here is that when we are in a list, with a t-key attribute on all
list nodes, and those list nodes contains sub component, then the
component system used the index of the list, and did not take into
account the key from the parent.  The fix is to keep track of the
current key in the context, and uses that as a part of the templateId.
2019-09-03 12:13:57 +02:00
Géry Debongnie b344d73e07 [FIX] component: prevent collision between template ids and keys 2019-09-02 15:27:37 +02:00
Géry Debongnie 4cd08d25c8 [FIX] component: solve tricky concurrency issue
In some specific situation, a component could crash, because it was
destroyed before being mounted, but reused anyway for another rendering.
2019-09-01 21:24:54 +02:00
Géry Debongnie f9861040c8 [FIX] router: properly react on hashchange
Before this commit, hashchanges were not taken into account by the
router, if used in history mode.

Also, it's stupid, but i ran prettier on the codebase
2019-09-01 11:45:13 +02:00
Géry Debongnie 40d8090232 [FIX] router: update app if parameterized route changes 2019-08-31 15:03:17 +02:00
Géry Debongnie 55be6437f1 [FIX] router: better support for hash mode 2019-08-31 09:55:45 +02:00
Géry Debongnie 6ff4dd74bf [DOC] readme: improve main example 2019-08-28 12:22:32 +02:00
Géry Debongnie 3628b49c37 [DOC] update readme (swap some sections around) 2019-08-28 12:05:53 +02:00
Géry Debongnie f289cb17ec [REL] bump to v0.19.0 2019-08-28 11:55:36 +02:00
Géry Debongnie e6a3ede4a6 [FIX] component: improve unmount/remount behaviour 2019-08-28 11:41:10 +02:00
Alexandre Kühn 10cfe0b740 [IMP] store: dispatch method returns data from action 2019-08-27 22:26:16 +02:00
Géry Debongnie a3d563ebb4 [FIX] connected_component: some misc issues
- did not properly stop listening to the store after being destroyed
- did not properly synchronize store updates in some async cases
2019-08-27 17:11:54 +02:00
Géry Debongnie 3b1247d476 [REF] vdom: small cleanup 2019-08-27 16:01:01 +02:00
Géry Debongnie beb6c2bc82 [DOC] improve readme and comparison
closes #244
2019-08-27 14:48:02 +02:00
Géry Debongnie f0cd6f61cd [FIX] playground: fix crash in todo app example 2019-08-27 14:09:20 +02:00
Géry Debongnie c950b385a0 [ADD] playground: add window management system example
closes #231
2019-08-27 14:09:20 +02:00
Géry Debongnie c172b31481 [DOC] router: add documentation 2019-08-26 15:45:55 +02:00
Géry Debongnie 9b3a97e732 [DOC] component: improve internal documentation 2019-08-26 12:19:11 +02:00
Géry Debongnie 670c98916c [DOC] store: update documentation and add examples 2019-08-26 10:31:11 +02:00
Géry Debongnie c59abef374 [IMP] connected component: add dispatch method 2019-08-26 10:31:11 +02:00
Géry Debongnie bb4c2506c0 [REF] store: merge actions and mutations
closes #256
2019-08-26 10:31:11 +02:00
Géry Debongnie 9843d16585 [REF] component: improve render method 2019-08-26 10:31:11 +02:00
Géry Debongnie 34e463ad0a [REF] connected component: separate storeprops from props
closes #259
2019-08-26 10:31:11 +02:00
Géry Debongnie 2294c1967f [IMP] qweb: allow xml documents as template definition
Before this commit, the addTemplates method only accepts a string.  But
whenever we perform a $.get call, we get a parsed xml document. So, to
make Owl more convenient to use in that situation, we simply accept xml
documents in addition to strings.
2019-08-23 15:17:28 +02:00
Géry Debongnie 4b53e48e91 [ADD] tools: add benchmarks for 0.18.0 2019-08-22 15:26:17 +02:00
Géry Debongnie 70a70161e7 [REL] bump to v0.18.0 2019-08-22 14:56:39 +02:00
Géry Debongnie e690991a6a [FIX] component: better heuristic to determine if was mounted 2019-08-22 14:55:31 +02:00
Géry Debongnie a621a14028 [MERGE] router: add owl router
First implementation of a declarative router component/directive/plugin
2019-08-22 14:49:11 +02:00
Géry Debongnie 9754cc7521 [REF] small cleanup 2019-08-22 14:26:34 +02:00
Géry Debongnie aa268937a7 [IMP] router: navigate now returns more information 2019-08-22 14:26:34 +02:00
Géry Debongnie 3db6f0a643 [IMP] owl: add commonjs build script 2019-08-22 14:26:34 +02:00
Géry Debongnie 635a7fc72c [FIX] router: do not prevent default click on <a> in some cases 2019-08-22 14:26:34 +02:00
Géry Debongnie 6cd5ec10f0 [FIX] router: properly redirect if necessary when started 2019-08-22 14:26:34 +02:00
Géry Debongnie 0a276fbf1d [IMP] router: basic implementation of navigation guards 2019-08-22 14:26:34 +02:00
Géry Debongnie d593131cc1 [REF] router: remove useless check 2019-08-22 14:26:34 +02:00
Géry Debongnie 3c3dcb44a9 [IMP] router: add test for Link 2019-08-22 14:26:34 +02:00
Géry Debongnie 6ecd1d6ea2 [IMP] router: add validation for destinations 2019-08-22 14:26:34 +02:00
Géry Debongnie 1a931a2a08 [IMP] router: implement redirect feature 2019-08-22 14:26:34 +02:00
Géry Debongnie bf705fcd99 [REF] router: add TestRouter and improve tests 2019-08-22 14:26:34 +02:00
Géry Debongnie bb46346767 [REF] router: change Destination shape 2019-08-22 14:26:34 +02:00
Géry Debongnie cd0762b436 [REF] rework router into a class 2019-08-22 14:26:34 +02:00
Géry Debongnie ca920a0091 [ADD] router: initial implementation 2019-08-22 14:26:34 +02:00
Géry Debongnie e5b1ba24d8 [IMP] component: allow multiple mount/unmount
closes #258
2019-08-22 14:23:08 +02:00
Géry Debongnie eaf912bed7 [FIX] observer: properly handle dates 2019-08-22 14:22:20 +02:00
Géry Debongnie fd0fb5be00 [REF] observer: refactor observer using proxy
closes #253
2019-08-22 14:22:20 +02:00
Géry Debongnie f9f730ab7b [DOC] add some info about return value from actions 2019-08-22 13:05:54 +02:00
Géry Debongnie b30b72fc36 [FIX] update benchmark application 2019-08-22 13:05:37 +02:00
Alexandre Kühn ab08e84bff [IMP] store: return data from dispatching an action 2019-08-22 13:04:32 +02:00
Géry Debongnie cd9cac1246 [REF] qweb: add forceUpdate method
and make sure it only triggers an update once for multiple calls in same
call stack
2019-07-30 15:00:51 +02:00
Géry Debongnie f6369f9750 [REF] qweb: put directives in static QWeb prop
so they can be altered by outside code.  This is mostly useful for tests.
2019-07-30 14:37:39 +02:00
Géry Debongnie f18c73e59f [FIX] component: solve a tricky concurrency rendering issue
When components were initially rendered more than once, in consecutive
microtask ticks, the order of two actions was reversed: the assignation
of the vnode to the __owl__.vnode property, and the assignation of
__owl__.vnode to the final rendered vdom of the component.

As a result, the widget was rendered as null (so, not present).

The reason for this issue was that the renderPromise key was reassigned
at some point.
2019-07-30 08:54:55 +02:00
Géry Debongnie 93d0a7dd46 [FIX] store: properly handle defaultProps in connected components
Thanks to aku for issue + tests + first solution

closes #245
2019-07-16 20:51:57 +02:00
Géry Debongnie b1d022b583 [FIX] qweb: allow t-if with t-call directives
closes #246
2019-07-16 13:19:21 +02:00
Géry Debongnie 5382e824b1 [REF] owl: reorganize src code in sub files 2019-07-16 13:12:08 +02:00
Géry Debongnie 15a3f59744 [REF] owl: move some exported values around
This is a breaking change. Code using Observer, EventBus, Store or
ConnectedComponent need to update the way they import theses classes.

- Observer is now in owl.misc (so you can import it like this:
    const Observer = owl.misc.Observer;)
- EventBus is also now in owl.misc
- Store and ConnectedComponent are now together in store
2019-07-14 09:02:02 +02:00
Géry Debongnie 4a90ec7ff0 [ADD] tools: add v0.17.0 benchmark 2019-07-13 17:13:18 +02:00
120 changed files with 40169 additions and 11889 deletions
+14
View File
@@ -0,0 +1,14 @@
Most of the files are
Copyright (c) 2004-2015 Odoo S.A.
Many files also contain contributions from third
parties. In this case the original copyright of
the contributions can be traced through the
history of the source version control system.
When that is not the case, the files contain a prominent
notice stating the original copyright and applicable
license, or come with their own dedicated COPYRIGHT
and/or LICENSE file.
+209 -52
View File
@@ -1,19 +1,70 @@
<h1 align="center">🦉 <a href="https://odoo.github.io/owl/">Odoo Web Library</a> 🦉</h1>
_A web framework for structured, dynamic and maintainable applications_
_Class based components with hooks, reactive state and concurrent mode_
## Project Overview
The Odoo Web Library (OWL) is a small
UI framework intended to be the basis for the [Odoo](https://www.odoo.com/) Web Client, and hopefully many
other Odoo related projects. OWL's main feature is a _declarative component system_, with QWeb as a template engine, asynchronous rendering, and an underlying virtual dom.
The Odoo Web Library (OWL) is a smallish (~<20kb gzipped) UI framework intended to
be the basis for the [Odoo](https://www.odoo.com/) Web Client. Owl is a modern
framework, written in Typescript, taking the best ideas from React and Vue in a
simple and consistent way. Owl's main features are:
**Try it online!** An online playground is available at [https://odoo.github.io/owl/playground](https://odoo.github.io/owl/playground) to let you experiment with the OWL framework.
- a declarative component system,
- a reactivity system based on hooks,
- a store implementation (for state management),
- a small frontend router
## OWL's Design Principles
Owl components are defined with ES6 classes, they use QWeb templates, an underlying
virtual dom, integrates beautifully with hooks, and the rendering is asynchronous.
**Try it online!** An online playground is available at [https://odoo.github.io/owl/playground](https://odoo.github.io/owl/playground) to let you experiment with the Owl framework. There
are some code examples to showcase some interesting features.
Owl is currently mostly stable. Possible future changes are explained in the
[roadmap](roadmap.md).
## Example
Here is a short example to illustrate interactive components:
```javascript
const { Component, useState } = owl;
const { xml } = owl.tags;
class Counter extends Component {
static template = xml`
<button t-on-click="state.value++">
Click Me! [<t t-esc="state.value"/>]
</button>`;
state = useState({ value: 0 });
}
class App extends Component {
static template = xml`
<div>
<span>Hello Owl</span>
<Counter />
</div>`;
static components = { Counter };
}
const app = new App();
app.mount(document.body);
```
Note that the counter component is made reactive with the [`useState` hook](doc/reference/hooks.md#usestate).
Also, all examples here uses the [`xml` helper](doc/reference/tags.md#xml-tag) to define inline templates.
But this is not mandatory, many applications will load templates separately.
More interesting examples can be found on the
[playground](https://odoo.github.io/owl/playground) application.
## Design Principles
OWL is designed to be used in highly dynamic applications where changing
requirements are common, code needs to be maintained by large teams.
requirements are common and code needs to be maintained by large teams.
- **XML based**: templates are based on the XML format, which allows interesting
applications. For example, they could be stored in a database and modified
@@ -26,70 +77,176 @@ requirements are common, code needs to be maintained by large teams.
for various reasons (security/deployment/dynamic modules/specific assets tools),
it is not ok to use standard web tools based on `npm`.
Owl is not designed to be fast or small (even though it is quite good on those
two topics). If you are interested in a comparison with React or Vue, you will
Owl is not designed to be fast nor small (even though it is quite good on those
two topics). It is a no nonsense framework to build applications. There is only
one way to define components (with classes).
If you are interested in a comparison with React or Vue, you will
find some more information [here](doc/comparison.md).
## Example
## Documentation
Here is a short example to illustrate interactive components:
A complete documentation for Owl can be found here:
```xml
<templates>
<button t-name="Counter" t-on-click="increment">
Click Me! [<t t-esc="state.value"/>]
</button>
</templates>
```
- [Main documentation page](doc/readme.md).
```javascript
class Counter extends owl.Component {
state = { value: 0 };
The most important sections are:
increment() {
this.state.value++;
}
}
- [Tutorial: TodoList application](doc/learning/tutorial_todoapp.md)
- [QWeb templating language](doc/reference/qweb_templating_language.md)
- [Component](doc/reference/component.md)
- [Hooks](doc/reference/hooks.md)
const qweb = new owl.QWeb(TEMPLATES);
const counter = new Counter({ qweb });
counter.mount(document.body);
```
Note that we assume here that the xml templates are available in the `TEMPLATES`
string. More interesting examples can be found on the
[playground](https://odoo.github.io/owl/playground) application.
Found an issue in the documentation? A broken link? Some outdated information?
Submit a PR!
## Installing/Building
If you want to use a simple `<script>` tag, the last release can be downloaded here:
- [owl-0.17.0.js](https://github.com/odoo/owl/releases/download/v0.17.0/owl.js)
- [owl-0.17.0.min.js](https://github.com/odoo/owl/releases/download/v0.17.0/owl.min.js)
- [owl-1.0.0-alpha5.js](https://github.com/odoo/owl/releases/download/v1.0.0-alpha5/owl.js)
- [owl-1.0.0-alpha5.min.js](https://github.com/odoo/owl/releases/download/v1.0.0-alpha5/owl.min.js)
Some npm scripts are available:
| Command | Description |
| --------------------- | ---------------------------------------------------------------------------- |
| `npm install` | install every dependency required for this project |
| `npm run build` | build a bundle of _owl_ in the _/dist/_ folder |
| `npm run minify` | minify the prebuilt owl.js file |
| `npm run test` | run all tests |
| `npm run test:watch` | run all tests, and keep a watcher |
| `npm run tools` | build tools applications, start a static server (see [here](doc/tooling.md)) |
| `npm run tools:watch` | same as `tools`, but with a watcher to rebuild owl |
| Command | Description |
| ---------------- | -------------------------------------------------- |
| `npm install` | install every dependency required for this project |
| `npm run build` | build a bundle of _owl_ in the _/dist/_ folder |
| `npm run minify` | minify the prebuilt owl.js file |
| `npm run test` | run all (owl) tests |
## Documentation
## Quick Overview
The complete documentation can be found [here](doc/readme.md). The most important sections are:
Owl components in an application are used to define a (dynamic) tree of components.
- [Quick Start](doc/quick_start.md)
- [Component](doc/component.md)
- [QWeb](doc/qweb.md)
```
Root
/ \
A B
/ \
C D
```
Found an issue in the documentation? A broken link? Some outdated information?
Submit a PR!
**State:** each component can manage its own local state. It is a simple ES6
class, there are no special rules:
```js
class Counter extends Component {
static template = xml`
<button t-on-click="increment">
Click Me! [<t t-esc="state.value"/>]
</button>`;
state = { value: 0 };
increment() {
this.state.value++;
this.render();
}
}
```
The example above shows a component with a local state. Note that since there
is nothing magical to the `state` object, we need to manually call the `render`
function whenever we update it. This can quickly become annoying (and not
efficient if we do it too much). There is a better way: using the `useState`
hook, which transforms an object into a reactive version of itself:
```js
const { useState } = owl.hooks;
class Counter extends Component {
static template = xml`
<button t-on-click="increment">
Click Me! [<t t-esc="state.value"/>]
</button>`;
state = useState({ value: 0 });
increment() {
this.state.value++;
}
}
```
Note that the `t-on-click` handler can even be replaced by an inline statement:
```xml
<button t-on-click="state.value++">
```
**Props:** sub components often needs some information from their parents. This
is done by adding the required information to the template. This will then be
accessible by the sub component in the `props` object. Note that there is an
important rule here: the information contained in the `props` object is not
owned by the sub component, and should never be modified.
```js
class Child extends Component {
static template = xml`<div>Hello <t t-esc="props.name"/></div>`;
}
class Parent extends Component {
static template = xml`
<div>
<Child name="'Owl'" />
<Child name="'Framework'" />
</div>`;
static components = { Child };
}
```
**Communication:** there are multiple ways to communicate information between
components. However, the two most important ways are the following:
- from parent to children: by using `props`,
- from a children to one of its parent: by triggering events.
The following example illustrate both mechanisms:
```js
class OrderLine extends Component {
static template = xml`
<div t-on-click="add">
<div><t t-esc="props.line.name"/></div>
<div>Quantity: <t t-esc="props.line.quantity"/></div>
</div>`;
add() {
this.trigger("add-to-order", { line: props.line });
}
}
class Parent extends Component {
static template = xml`
<div t-on-add-to-order="addToOrder">
<OrderLine
t-foreach="orders"
t-as="line"
line="line" />
</div>`;
static components = { OrderLine };
orders = useState([{ id: 1, name: "Coffee", quantity: 0 }, { id: 2, name: "Tea", quantity: 0 }]);
addToOrder(event) {
const line = event.detail.line;
line.quantity++;
}
}
```
In this example, the `OrderLine` component trigger a `add-to-order` event. This
will generate a DOM event which will bubble along the DOM tree. It will then be
intercepted by the parent component, which will then get the line (from the
`detail` key) and then increment its quantity. See the section on [event handling](doc/reference/component.md#event-handling)
for more details on how events work.
Note that this example would have also worked if the `OrderLine` component
directly modifies the `line` object. However, this is not a good practice: this
only works because the `props` object received by the child component is reactive,
so the child component is then coupled to the parents implementation.
## License
OWL is [GPL licensed](./LICENSE).
OWL is [LGPL licensed](./LICENSE).
+32
View File
@@ -0,0 +1,32 @@
# 🦉 Rendering Pipeline 🦉
We explain here how Owl is designed, from the perspective of its rendering
pipeline.
Warning: these notes are technical by nature, and intended for people working
on Owl (or interested in understanding its design).
## Overview
A rendering occurs in two phases:
- virtual rendering: this generates the virtual dom in memory, asynchronously
- patch: applies a virtual tree to the screen (synchronously)
There are several classes involved in a rendering:
- components
- a scheduler
- fibers: small objects containing some metadata, associated with a rendering of
a specific component
Components are organized in a dynamic component tree, visible in the user
interface. Whenever a rendering is initiated in a component `C`:
- a fiber is created on `C` with the rendering props information
- the virtual rendering phase starts on C (will asynchronously render all the
child components)
- the fiber is added to the scheduler, which will poll continuously, every
animation frame, if the fiber is done
- once it is done, the scheduler will call the task callback, which will apply
the patch (if it was not cancelled in the meantime).
+169 -20
View File
@@ -3,30 +3,59 @@
OWL, React and Vue have the same main feature: they allow developers to build
declarative user interfaces. To do that, all these frameworks uses a virtual dom. However, there are still obviously many differences.
In this page, we try to highlight some of these differences. Obviously, some
In this page, we try to highlight some of these differences. Obviously, a lot of
effort was done to be fair. However, if you disagree with some of the points
discussed, feel free to open an issue/submit a PR to correct this text.
## Content
- [Size](#size)
- [Class Based](#class-based)
- [Tooling/Build Step](#toolingbuild-step)
- [Templating](#templating)
- [Asynchronous rendering](#asynchronous-rendering)
- [Reactiveness](#reactiveness)
- [State Management](#state-management)
- [Hooks](#hooks)
## Size
OWL is intended to be small and to work at a slightly lower level of abstraction
than React and Vue. Also, jQuery is not the same kind of framework, but it is interesting to compare.
| Framework | Size (minified) | Size (minified, gzipped) |
| ------------------------ | --------------- | ------------------------ |
| OWL | 32kb | 11kb |
| Vue + VueX | | 30kb |
| React + ReactDOM + Redux | | 40kb |
| jQuery | 86kb | 30kb |
| Framework | Size (minified, gzipped) |
| ------------------------ | ------------------------ |
| OWL | 18kb |
| Vue + VueX | 30kb |
| Vue + VueX + Vue Router | 39kb |
| React + ReactDOM + Redux | 40kb |
| jQuery | 30kb |
Note that those comparisons are not entirely fair, because we do not compare
the same exact set of features. For example, VueX and Vue Router support more
advanced use cases.
## Class Based
Both React and Vue moved away from defining components with classes. They prefer
a more functional approach, in particular, with the new `hooks` mechanisms.
This has some advantages and disadvantages. But the end result is that React
and Vue both offers multiple different ways of defining new components. In
contrast, Owl has only one mechanism: class-based components. We believe that Owl
components are fast enough for all our usecases, and making it as simple as
possible for developers is more valuable (for us).
Also, functions or class based components are more than just syntax. Functions
comes with a mindset of composition and class are about inheritance. Clearly,
both of these are important mechanisms for reusing code. Also, one does not
exclude the other.
It certainly looks like the world of UI frameworks is moving toward composition,
for many very good reasons. Owl is still good at composition (for example,
Owl supports slots, which is the primary mechanism to make generic reusable
components). But it can also use inheritance (and this is very important since
templates can also be inherited with `xpaths` transformations).
## Tooling/Build step
@@ -38,14 +67,24 @@ be used by simply adding a script tag to a page.
<script src="owl.min.js" />
```
In comparison, React encourages using JSX,
which necessitate a build step, and most Vue applications uses single file
components, which also necessitate a build step.
In comparison, React encourages using JSX, which necessitate a build step, and
most Vue applications uses single file components, which also necessitate a build step.
On the flipside, external tooling may make it harder to use in some case, but it
also brings a lot of benefits. And React/Vue have both a large ecosystem.
### Templating
Note that since Owl is not dependant on any external tool nor libraries, it is
very easy to integrate into any build toolchain. Also, since we cannot rely on
additional tools, we made a lot of effort to make the most of the web platform.
For example, Owl uses the standard `xml` parser that comes with every browser.
Because of that, Owl did not have to write its own template parser. Another
example is the [`xml`](reference/tags.md#xml-tag) tag helper function, which makes use of
native template literals to allow in a natural way to write `xml` templates
directly in the javascript code. This can be easily integrated with editor
plugins to have autocompletion inside the template.
## Templating
OWL uses its own QWeb engine, which compiles templates on the
frontend, as they are needed. This is extremely convenient for our use case, in
@@ -67,7 +106,8 @@ into javascript functions. Note that Vue has a separate build which includes the
template compiler.
In contrast, most React applications do not use a templating language, but write
some JSX code, which is precompiled into plain JavaScript by a build step.
some JSX code, which is precompiled into plain JavaScript by a build step. This
example is done with the (kind of outdated) React class system:
```jsx
class Clock extends React.Component {
@@ -86,6 +126,20 @@ This has the advantage of having the full power of Javascript, but is less
structured than a template language. Note that the tooling is quite impressive:
there is a syntax highlighter for jsx here on github!
By comparison, here is the equivalent Owl component, written with the
[`xml`](reference/tags.md#xml-tag) tag helper:
```js
class Clock extends Component {
static template = xml`
<div>
<h1>Hello, world!</h1>
<h2>It is {props.date.toLocaleTimeString()}.</h2>
</div>
`;
}
```
## Asynchronous Rendering
This is actually a big difference between OWL and React/Vue: components in OWL
@@ -115,12 +169,14 @@ This may be dangerous (to stop the rendering waiting for the network), but it is
extremely powerful as well, as demonstrated by the Odoo Web Client.
Lazy loading static libraries can obviously be done with React/Vue, but it is
more convoluted.
more convoluted. For example, in Vue, you need to use a dynamic import keyword
that needs to be transpiled at build time in order for the component to be loaded
asynchronously (see [the documentation](https://vuejs.org/v2/guide/components-dynamic-async.html#Async-Components)).
## Reactiveness
React has a simple model: whenever the state changes, it is
replaced with a new state (via the setState method). Then, the DOM is patched.
replaced with a new state (via the `setState` method). Then, the DOM is patched.
This is simple, efficient, and a little bit awkward to write.
Vue is a little bit different: it replace magically the properties in the state
@@ -128,8 +184,10 @@ by getters/setters. With that, it can notify components whenever the state that
they read was changed.
Owl is closer to vue: it also tracks magically the state properties, but it does
only increment a counter whenever it changes (and a _deep_ counter for each of
its parents). This assumes that the state is actually a tree.
only increment an internal counter whenever it changes. Note that it is done
with a `Proxy`, which means that it is totally transparent to the developers.
Adding new keys is supported. Once any part of the state has been changed, a
rendering is scheduled in the next microtask tick (promise queue).
## State Management
@@ -190,7 +248,98 @@ keeps track of who get data, and retrigger a render when it was changed.
**Owl**
Owl store is a little bit like a mix of redux and vuex: it has mutations and
actions, like VueX, it keeps track of the state changes, but it does not notify
a component when the state changes. Instead, components need to connect to the
store like in redux, with a function that will listen to the relevant state.
Owl store is a little bit like a mix of redux and vuex: it has actions (but not
mutations), and like VueX, it keeps track of the state changes. However, it does
not notify a component when the state changes. Instead, components need to connect
to the store like in redux, with the `useStore` hook (see the [store documentation](reference/store.md#connecting-a-component)).
```javascript
const actions = {
increment({ state }, val) {
state.counter.value += val;
}
};
const state = {
counter: { value: 0 }
};
const store = new owl.Store({ state, actions });
class Counter extends Component {
static template = xml`
<button t-name="Counter" t-on-click="dispatch('increment')">
Click Me! [<t t-esc="counter.value"/>]
</button>`;
counter = useStore(state => state.counter);
dispatch = useDispatch();
}
Counter.env.store = store;
const counter = new Counter();
```
## Hooks
[Hooks](https://reactjs.org/docs/hooks-intro.html#motivation) recently took over
the React world. They solve a lot of seemingly unconnected problems: attach
reusable behavior to a component, in a composable way, extract stateful logic
from a component or reuse stateful logic between component, without changing your
component hierarchy.
Here is an example of the React `useState` hook:
```js
import React, { useState } from "react";
function Example() {
// Declare a new state variable, which we'll call "count"
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}
```
Because of the way React designed the hooks API, they only work for functional
components. But in that case, they really are powerful. Every major React library
is in the process of redesigning their API with hooks (for example,
[Redux](https://react-redux.js.org/next/api/hooks)).
Vue 2 does not have hooks, but the Vue project is working on its next version,
which will feature its new [composition API](https://vue-composition-api-rfc.netlify.com/).
This work is based on the new ideas introduced by React hooks.
From the way React and Vue introduce their hooks, it may look like hooks are not
compatible with class components. However, this is not the case, as shown by
Owl [hooks](reference/hooks.md). They are inspired by both React and Vue. For example,
the `useState` hook is named after React, but its API is closer to the `reactive`
Vue hook.
Here is what the `Counter` example above look like in Owl:
```js
import { Component, Owl } from "owl";
import { xml } from "owl/tags";
class Example extends Component {
static template = xml`
<div>
<p>You clicked {count.value} times</p>
<button t-on-click="increment">Click me</button>
</div>`;
count = useState({ value: 0 });
increment() {
this.state.value++;
}
}
```
Since the Owl framework had hooks from early in its life, its main APIs
are designed to be interacted with hooks from the start. For example, the
`Context` and `Store` abstractions.
@@ -2,8 +2,17 @@
## Static Server
Let us assume that we have a static server running somewhere. We could then
simply add an html page with a few extra files.
Let us assume that we have a static server running somewhere. Let us start by
adding an html page with a few extra files:
```
my-app/
index.html
app.css
app.js
owl-X.Y.Z.js
templates.xml
```
### HTML and CSS
@@ -55,7 +64,7 @@ To build an application (or a sub-part of an application), we need two things:
- an environment: it is the global context in which we are working. It needs to
contain a QWeb instance (preloaded with templates), and anything else that we
need. In practice, it could context some user session information, some
need. In practice, it could be used to contain some user session information, some
configuration keys (for example, isMobile = true/false if we are in mobile mode).
- a description of the user interface: there should be a root component, which can
@@ -72,12 +81,11 @@ Here are a few steps that we may take to get started:
Let us now add the javascript to make it work, in `app.js`:
```javascript
const useState = owl.hooks.useState;
class ClickCounter extends owl.Component {
constructor() {
super(...arguments);
this.template = "clickcounter";
this.state = { value: 0 };
}
static template = "clickcounter";
state = useState({ value: 0 });
increment() {
this.state.value++;
@@ -88,11 +96,9 @@ class ClickCounter extends owl.Component {
// Application initialization
//------------------------------------------------------------------------------
async function start() {
const templates = await owl.utils.loadTemplates("templates.xml");
const env = {
qweb: new owl.QWeb(templates)
};
const counter = new ClickCounter(env);
const templates = await owl.utils.loadFile("templates.xml");
ClickCounter.env = { qweb: new owl.QWeb({ templates }) };
const counter = new ClickCounter();
const target = document.getElementById("main");
await counter.mount(target);
}
+211
View File
@@ -0,0 +1,211 @@
# 🦉 Testing and Debugging Owl components 🦉
## Content
- [Overview](#overview)
- [Unit Tests](#unit-tests)
- [Debugging](#debugging)
## Overview
It is a good practice to test applications and components to ensure that they
behave as expected. There are many ways to test a user interface: manual
testing, integration testing, unit testing, ...
In this section, we will discuss how to write unit tests for components, and
how to debug them if necessary.
## Unit Tests
Writing unit tests for Owl components really depends on the testing framework
used in a project. But usually, it involves the following steps:
- create a test file: for example `SomeComponent.test.js`,
- in that file, import the code for `SomeComponent`,
- add a test case:
- create a real DOM element to use as test fixture,
- create a test environment
- create an instance of `SomeComponent`, mount it to the fixture
- interact with the component and assert some properties.
To help with this, it is useful to have a `helper.js` file that contains some
common utility functions:
```js
export function makeTestFixture() {
let fixture = document.createElement("div");
document.body.appendChild(fixture);
return fixture;
}
export function nextTick() {
let requestAnimationFrame = owl.Component.scheduler.requestAnimationFrame;
return new Promise(function(resolve) {
setTimeout(() => requestAnimationFrame(() => resolve()));
});
}
export function makeTestEnv() {
// application specific. It needs a way to load actual templates
const templates = ...;
return {
qweb: new QWeb(templates),
..., // each service can be mocked here
};
}
```
With such a file, a typical test suite for Jest will look like this:
```js
// in SomeComponent.test.js
import { SomeComponent } from "../../src/ui/SomeComponent";
import { nextTick, makeTestFixture, makeTestEnv} from '../helpers';
//------------------------------------------------------------------------------
// Setup
//------------------------------------------------------------------------------
let fixture: HTMLElement;
let env: Env;
beforeEach(() => {
fixture = makeTestFixture();
env = makeTestEnv();
// we set here the default environment for each component created in the test
Component.env = env;
});
afterEach(() => {
fixture.remove();
});
//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
describe("SomeComponent", () => {
test("component behaves as expected", async () => {
const props = {...}; // depends on the component
const comp = new SomeComponent(null, props);
await comp.mount(fixture);
// do some assertions
expect(...).toBe(...);
fixture.querySelector('button').click();
await nextTick();
// some other assertions
expect(...).toBe(...);
});
});
```
Note that Owl does wait for the next animation frame to actually update the DOM.
This is why it is necessary to wait with the `nextTick` (or other methods) to
make sure that the DOM is up-to-date.
It is sometimes useful to wait until Owl is completely done updating components
(in particular, if we have a highly concurrent user interface). This next
helper simply polls every 20ms the internal Owl task queue and returns a promise
which resolves when it is empty:
```js
function afterUpdates() {
return new Promise((resolve, reject) => {
let timer = setTimeout(poll, 20);
let counter = 0;
function poll() {
counter++;
if (owl.Component.scheduler.tasks.length) {
if (counter > 10) {
reject(new Error("timeout"));
} else {
timer = setTimeout(poll);
}
} else {
resolve();
}
}
});
}
```
## Debugging
Non trivial applications become quickly more difficult to understand. It is then
useful to have a solid understanding of what is going on. To help with that,
the following code can simply be copy/pasted in an application. Once it is
executed, it will log a lot of information on each component main hooks.
```js
let current;
Object.defineProperty(owl.Component, "current", {
get() {
return current;
},
set(comp) {
current = comp;
const name = comp.constructor.name;
let __owl__;
Object.defineProperty(current, "__owl__", {
get() {
return __owl__;
},
set(val) {
__owl__ = val;
debugComponent(comp, name, __owl__.id);
}
});
}
});
function toStr(props) {
let str = JSON.stringify(props || {});
if (str.length > 200) {
str = str.slice(0, 200) + "...";
}
return str;
}
function debugComponent(component, name, id) {
console.log(`[DEBUG] constructor ${name}<id=${id}>, props=${toStr(component.props)}`);
owl.hooks.onWillStart(() => {
console.log(`[DEBUG] willStart: '${name}<id=${id}>'`);
});
owl.hooks.onMounted(() => {
console.log(`[DEBUG] mounted: '${name}<id=${id}>'`);
});
owl.hooks.onWillUpdateProps(nextProps => {
console.log(`[DEBUG] willUpdateProps: '${name}<id=${id}> nextprops=${toStr(nextProps)}`);
});
owl.hooks.onWillPatch(() => {
console.log(`[DEBUG] willPatch: '${name}<id=${id}>'`);
});
owl.hooks.onPatched(() => {
console.log(`[DEBUG] patched: '${name}<id=${id}>'`);
});
owl.hooks.onWillUnmount(() => {
console.log(`[DEBUG] willUnmount: '${name}<id=${id}>'`);
});
const __render = component.__render.bind(component);
component.__render = function(...args) {
console.log(`[DEBUG] rendering template: '${name}<id=${id}>'`);
__render(...args);
};
const render = component.render.bind(component);
component.render = function(...args) {
console.log(`[DEBUG] render: '${name}<id=${id}>'`);
return render(...args);
};
const mount = component.mount.bind(component);
component.mount = function(...args) {
console.log(`[DEBUG] mount: '${name}<id=${id}>'`);
return mount(...args);
};
}
```
Note that it is certainly useful to run this code at some point in an application,
just to get a feel of what each user action implies, for the framework.
File diff suppressed because it is too large Load Diff
-50
View File
@@ -1,50 +0,0 @@
# 🦉 Observer 🦉
Owl need to be able to react to state changes. For example, whenever the state
of a component is changed, we need to rerender it. To help with that, we have
an Observer class. Its job is to observe some object state, and react to any
change. To do that, it recursively replace all keys of the observed state by
getters and setters.
For example, this code will display `update` in the console:
```javascript
const observer = new owl.Observer();
observer.notifyCB = () => console.log("update");
observer.observe(obj);
const obj = { a: { b: 1 } };
obj.a.b = 2;
```
## Technical Limitations
Since the observer uses getters and setters, it is actually unable to react to
changes in three situations:
- adding a key to an object
- deleting a key from an object
- modifying an array by setting a new value at a given index
In those situations, we need a way to tell the observer that something happened.
This can be done by using the `set` and `delete` (only for objects) static
methods of the `Observer`.
```javascript
const observer = new owl.Observer();
const obj = { a: 1 };
observer.observe(obj);
obj.b = 2; // won't notify the change
owl.Observer.set(obj, "b", 2); // will notify the change
delete obj.b; // won't notify the change
owl.Observer.delete(obj, "b"); // will notify the change
```
```javascript
const observer = new owl.Observer();
const arr = ["a"];
observer.observe(arr);
arr[0] = "b"; // won't notify the change
owl.Observer.set(arr, 0, "b"); // will notify the change
```
+63 -9
View File
@@ -2,20 +2,74 @@
## Reference
- [Animations](animations.md)
- [Component](component.md)
- [Event Bus](event_bus.md)
- [Observer](observer.md)
- [QWeb](qweb.md)
- [Store](store.md)
- [Utils](utils.md)
- [Virtual DOM](vdom.md)
- [Animations](reference/animations.md)
- [Component](reference/component.md)
- [Configuration](reference/config.md)
- [Context](reference/context.md)
- [Environment](reference/environment.md)
- [Event Bus](reference/event_bus.md)
- [Hooks](reference/hooks.md)
- [Miscellaneous Components](reference/misc.md)
- [Observer](reference/observer.md)
- [Props](reference/props.md)
- [QWeb Templating Language](reference/qweb_templating_language.md)
- [QWeb Engine](reference/qweb_engine.md)
- [Router](reference/router.md)
- [Store](reference/store.md)
- [Tags](reference/tags.md)
- [Utils](reference/utils.md)
## Learning Resources
- [Quick Start](quick_start.md)
- [Quick Start: create an (almost) empty Owl application](learning/quick_start.md)
- [Tutorial: create a TodoList application](learning/tutorial_todoapp.md)
- [Testing and Debugging Owl components](learning/testing_components.md)
## Miscellaneous
- [Comparison with React/Vue](comparison.md)
- [Tooling](tooling.md)
- [Templates to start Owl applications (external link)](https://github.com/ged-odoo/owl-templates)
## Architecture
This section explains in more detail the inner workings of Owl. It is targeted
for developers working on Owl itself.
- [Virtual DOM](architecture/vdom.md)
- [Rendering](architecture/rendering.md)
## Owl Content
Here is a complete visual representation of everything exported by the `owl`
global object (so, for example, `Component` is available at `owl.Component`,
and `EventBus` is exported as `owl.core.EventBus`):
```
Component misc
Context AsyncRoot
QWeb router
Store Link
useState RouteComponent
config Router
mode tags
core xml
EventBus utils
Observer debounce
hooks escape
onWillStart loadJS
onMounted loadFile
onWillUpdateProps shallowEqual
onWillPatch whenReady
onPatched
onWillUnmount
useContext
useState
useRef
useSubEnv
useStore
useDispatch
useGetters
```
Note that for convenience, the `useState` hook is also exported at the root of the `owl` object.
@@ -30,7 +30,7 @@ btn {
}
```
will produce a nice flash effect whenever the user click (or activate with the
will produce a nice flash effect whenever the user clicks (or activates with the
keyboard) the button.
## CSS Transitions
@@ -47,7 +47,7 @@ the lifetime of a node. Since this is not easy to do by hand, Owl `t-transition`
directive is there to help.
Whenever a node has a `t-transition` directive, with a `name` value, the following
will happen:
sequence of events will happen:
At node insertion:
@@ -91,6 +91,5 @@ The `t-transition` directive can be applied on a node element or on a component.
Notes:
- more information on animations are available [here](animations.md).
- Owl does not support more than one transition on a single node, so the
`t-transition` expression must be a single value (i.e. no space allowed)
Owl does not support more than one transition on a single node, so the
`t-transition` expression must be a single value (i.e. no space allowed).
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
# 🦉 Config 🦉
The Owl framework is designed to work in many situations. However, it is
sometimes necessary to customize some behaviour. This is done by using the
global `config` object. It currently has one key:
- [`mode`](#mode).
## Mode
By default, Owl is in _production_ mode, this means that it will try to do its
job fast, and skip some expensive operations. However, it is sometimes necessary
to have better information on what is going on, this is the purpose
of the `dev` mode.
Owl has a mode flag, in `owl.config.mode`. Its default value is `prod`, but
it can be set to `dev`:
```js
owl.config.mode = "dev";
```
Note that templates compiled with the `prod` settings will not be recompiled.
So, changing this setting is best done at startup.
An important job done by the `dev` mode is to validate props for each component
creation and update. Also, extra props will cause an error.
+105
View File
@@ -0,0 +1,105 @@
# 🦉 Context 🦉
## Content
- [Overview](#overview)
- [Example](#example)
- [Reference](#reference)
- [`Context`](#context)
- [`useContext`](#usecontext)
## Overview
The `Context` object provides a way to share data between an arbitrary number
of components. Usually, data is passed from a parent to its children component,
but when we have to deal with some mostly global information, this can be
annoying, since each component will need to pass the information to each children,
even though some or most of them will not use the information.
With a `Context` object, each component can subscribe (with the `useContext` hook)
to its state, and will be updated whenever the context state is updated.
## Example
Assume that we have an application with various components which needs to render
differently depending on the size of the device. Here is how we could proceed
to make sure that the information is properly shared. First, let us create a
context, and add it to the environment:
```js
const deviceContext = new Context({ isMobile: true });
App.env.deviceContext = deviceContext;
```
If we want to make it completely responsive, we need to update its value whenever
the size of the screen is updated:
```js
const isMobile = () => window.innerWidth <= 768;
window.addEventListener(
"resize",
owl.utils.debounce(() => {
const state = deviceContext.state;
if (state.isMobile !== isMobile()) {
state.isMobile = !state.isMobile;
}
}, 15)
);
```
Then, each component that want can subscribe and render differently depending on the
fact that we are in a mobile or desktop mode.
```js
class SomeComponent extends Component {
static template = xml`
<div>
<t t-if=device.isMobile>
some simplified user interface
</t>
<t t-else="1">
a more advanced user interface
</t>
</div>`;
device = useContext(this.env.deviceContext);
}
```
## Reference
### `Context`
A `Context` object should be created with a state object:
```js
const someContext = new Context({ some: "key" });
```
Its state is now available in the `state` key:
```js
someContext.state.some = "other key";
```
This is the way some global code (such as the responsive code above) should
read and update the context state. However, components should not ever read the
context state directly from the context, they should instead use the `useContext`
hook to properly register themselves to state changes.
Note that the `Context` hook is different from the React version. For example,
there is no concept of provider/consumer. So, the `Context` feature does not
by itself allow the use of a different context state depending on the component
place in the component tree. However, this functionality can be obtained, if
necessary, with the use of sub environment.
### `useContext`
The `useContext` hook is the normal way for a component to register themselve
to context state changes. The `useContext` method returns the context state:
```js
device = useContext(this.env.deviceContext);
```
It is a simple observed state (with an owl `Observer`), which contains the shared
information.
+127
View File
@@ -0,0 +1,127 @@
# 🦉 Environment 🦉
## Content
- [Overview](#overview)
- [Setting an Environment](#setting-an-environment)
- [Using a sub environment](#using-a-sub-environment)
- [Content of an Environment](#content-of-an-environment)
## Overview
An environment is an object which contains a [`QWeb` instance](qweb_engine.md). Whenever
a root component is created, it is assigned an environment (see
[below](#setting-an-environment) for more info on this). This environment is
then automatically given to each sub component (and accessible in the `this.env`
property).
```
Root
/ \
A B
```
This way, all components share the same `QWeb` instance. Owl internally requires
that the environment has a `qweb` key which maps to a
[`QWeb`](qweb_engine.md) instance. This is the QWeb instance that will be used to
render each templates in this specific component tree. Note that if no `QWeb`
instance is provided, Owl will simply generate it on the fly.
The environment is mostly static. Each application is free to add anything to
the environment, which is very useful, since this can be accessed by each sub
component.
## Setting an environment
An Owl application needs an [environment](environment.md) to be executed. The
environment has an important key: the [QWeb](qweb_engine.md) instance, which will render
all templates.
Whenever a root component `App` is mounted, Owl will setup a valid environment by
following the next steps:
- take the `env` object defined on `App.env` (if no `env` was explicitely setup,
this will be return the empty `env` object defined on `Component`)
- if `env.qweb` is not set, then Owl will create a `QWeb` instance.
The correct way to customize an environment is to simply set it up on the root
component class, before the first component is created:
```js
App.env = {
_t: myTranslateFunction,
user: {...},
services: {
...
},
};
const app = new App();
app.mount(document.body);
```
It is also possible to simply share an environment between all root components,
by simply doing this:
```js
Component.env = myEnv; // will be the default env for all components
```
Note that this environment is the global owl environment for an application. The
next section explains how to extend an environment for a specific sub component
and its children.
## Using a sub environment
It is sometimes useful to add one (or more) specific keys to the environment,
from the perspective of a specific component and its children. In that case, the
solution presented above will not work, since it sets the global environment.
There is a hook for this situation: [`useSubEnv`](hooks.md#usesubenv).
```js
class FormComponent extends Component {
constructor(parent, props) {
super(parent, props);
useSubEnv({ myKey: someValue });
}
}
```
## Content of an Environment
Some good use cases for additional keys in the environment are:
- some configuration keys,
- session information,
- generic services (such as doing rpcs, or accessing local storage).
Doing it this way means that components are easily testable: we can simply
create a test environment with mock services.
For example:
```js
async function myEnv() {
const templates = await loadTemplates();
const qweb = new QWeb({ templates });
const session = getSession();
return {
_t: myTranslateFunction,
session: session,
qweb: qweb,
services: {
localStorage: localStorage,
rpc: rpc
},
debug: false,
inMobileMode: true
};
}
async function start() {
App.env = await myEnv();
const app = new App();
await app.mount(document.body);
}
```
@@ -5,7 +5,7 @@ parts of the code. Owl has a very simple bus class, which manages subscriptions,
triggering events, and callbacks.
```js
const bus = new owl.EventBus();
const bus = new owl.core.EventBus();
bus.on("some-event", null, function(...args) {
console.log(...args);
+435
View File
@@ -0,0 +1,435 @@
# 🦉 Hooks 🦉
## Content
- [Overview](#overview)
- [Example: Mouse Position](#example-mouse-position)
- [Example: Autofocus](#example-autofocus)
- [Reference](#reference)
- [One Rule](#one-rule)
- [`useState`](#usestate)
- [`onMounted`](#onmounted)
- [`onWillUnmount`](#onwillunmount)
- [`onWillPatch`](#onwillpatch)
- [`onPatched`](#onpatched)
- [`onWillStart`](#onwillstart)
- [`onWillUpdateProps`](#onwillupdateprops)
- [`useContext`](#usecontext)
- [`useRef`](#useref)
- [`useSubEnv`](#usesubenv)
- [`useStore`](#usestore)
- [`useDispatch`](#usedispatch)
- [`useGetters`](#usegetters)
- [Making customized hooks](#making-customized-hooks)
## Overview
Hooks were popularised by React as a way to solve the following issues:
- help reusing stateful logic between components
- help organizing code by feature in complex components
- use state in functional components, without writing a class.
Owl hooks serve the same purpose, except that they work for class components
(note: React hooks do not work on class components, and maybe because of that,
there seems to be the misconception that hooks are in opposition to class. This
is clearly not true, as shown by Owl hooks).
Hooks work beautifully with Owl components: they solve the problems mentioned
above, and in particular, they are the perfect way to make your component
reactive.
## Example: mouse position
Here is the classical example of a non trivial hook to track the mouse position.
```js
const { useState, onMounted, onWillUnmount } = owl.hooks;
// We define here a custom behaviour: this hook tracks the state of the mouse
// position
function useMouse() {
const position = useState({ x: 0, y: 0 });
function update(e) {
position.x = e.clientX;
position.y = e.clientY;
}
onMounted(() => {
window.addEventListener("mousemove", update);
});
onWillUnmount(() => {
window.removeEventListener("mousemove", update);
});
return position;
}
// Main root component
class App extends owl.Component {
static template = xml`
<div t-name="App">
<div>Mouse: <t t-esc="mouse.x"/>, <t t-esc="mouse.y"/></div>
</div>`;
// this hooks is bound to the 'mouse' property.
mouse = useMouse();
}
```
Note that we use the prefix `use` for hooks, just like in React. This is just
a convention.
## Example: autofocus
Hooks can be combined to create the desired effect. For example, the following
hook combines the `useRef` hook with the `onPatched` and `onMounted` functions
to create an easy way to focus an input whenever it appears in the DOM:
```js
function useAutofocus(name) {
let ref = useRef(name);
let isInDom = false;
function updateFocus() {
if (!isInDom && ref.el) {
isInDom = true;
ref.el.focus();
} else if (isInDom && !ref.el) {
isInDom = false;
}
}
onPatched(updateFocus);
onMounted(updateFocus);
}
```
This hook takes the name of a valid `t-ref` directive, which should be present
in the template. It then checks whenever the component is mounted or patched if
the reference is not valid, and in this case, it will focus the node element.
This hook can be used like this:
```js
class SomeComponent extends Component {
static template = xml`
<div>
<input />
<input t-ref="myinput"/>
</div>`;
constructor(...args) {
super(...args);
useAutofocus("myinput");
}
}
```
## Reference
### One rule
There is only one rule: every hook for a component has to be called in the
constructor (or in class fields):
```js
// ok
class SomeComponent extends Component {
state = useState({ value: 0 });
}
// also ok
class SomeComponent extends Component {
constructor(...args) {
super(...args);
this.state = useState({ value: 0 });
}
}
// not ok: this is executed after the constructor is called
class SomeComponent extends Component {
async willStart() {
this.state = useState({ value: 0 });
}
}
```
As you can see, the `useState` hook does not need to be given a reference to
the component. This is possible because there is a way to get a reference to the
current component: the `Component.current` static property is the reference to the
component instance that is currently being created.
Hooks need to be called in the constructor to ensure that this reference is
properly set. This is also a good thing for performance reasons (Owl can use
this to optimize its implementation), and for a clean architecture (this makes
it easier for developers to understand what is really happening in a component).
### `useState`
The `useState` hook is certainly the most important hook for Owl components:
this is what allows a component to be reactive, to react to state change.
The `useState` hook has to be given an object or an array, and will return
an observed version of it (using a `Proxy`).
```javascript
const { useState } = owl.hooks;
class Counter extends owl.Component {
static template = xml`
<button t-on-click="increment">
Click Me! [<t t-esc="state.value"/>]
</button>`;
state = useState({ value: 0 });
increment() {
this.state.value++;
}
}
```
It is important to remember that `useState` only works with objects or arrays. It
is necessary, since Owl needs to react to a change in state.
### `onMounted`
`onMounted` is not a user hook, but is a building block designed to help make useful
abstractions. `onMounted` registers a callback, which will be called when the component
is mounted (see example on top of this page).
### `onWillUnmount`
`onWillUnmount` is not a user hook, but is a building block designed to help make useful
abstractions. `onWillUnmount` registers a callback, which will be called when the component
is unmounted (see example on top of this page).
### `onWillPatch`
`onWillPatch` is not a user hook, but is a building block designed to help make useful
abstractions. `onWillPatch` registers a callback, which will be called just
before the component patched.
### `onPatched`
`onPatched` is not a user hook, but is a building block designed to help make useful
abstractions. `onPatched` registers a callback, which will be called just
after the component patched.
### `onWillStart`
`onWillStart` is an asynchronous hook. This means that the function registered
in the hook will be run just before the component is first rendered and can return a
promise, to express the fact that it is an asynchronous operation.
Note that if there are more than one `onWillStart` registered callback, then they
will all be run in parallel.
It can be used to load some initial data. For example, the following hook will
automatically load some data from the server, and return an object that will
be ready whenever the component is rendered:
```js
function useLoader() {
const component = Component.current;
const record = useState({});
onWillStart(async () => {
const recordId = component.props.id;
Object.assign(record, await fetchSomeRecord(recordId));
});
return record;
}
```
Note that this example does not update the record value whenever props are
updated. For that situation, we need to use the `onWillUpdateProps` hook.
### `onWillUpdateProps`
Just like `onWillStart`, `onWillUpdateProps` is an asynchronous hook. It is
designed to be run whenever the component props are updated. This could be
useful to perform some asynchronous task such as fetching updated data.
```js
function useLoader() {
const component = Component.current;
const record = useState({});
async function updateRecord(id) {
Object.assign(record, await fetchSomeRecord(id));
}
onWillStart(() => updateRecord(component.props.id));
onWillUpdateProps(nextProps => updateRecord(nextProps.id));
return record;
}
```
Note that if there are more than one `onWillUpdateProps` registered callback,
then they will all be run in parallel.
### `useContext`
See [`useContext`](context.md#usecontext) for reference documentation.
### `useRef`
The `useRef` hook is useful when we need a way to interact with some inside part
of a component, rendered by Owl. It can work either on a DOM node, or on a component,
tagged by the `t-ref` directive:
```xml
<div>
<div t-ref="someDiv"/>
<SubComponent t-ref="someComponent"/>
</div>
```
In this example, the component will be able to access the `div` and the component
`SubComponent` using the `useRef` hook:
```js
class Parent extends Component {
subRef = useRef("someComponent");
divRef = useRef("someDiv");
someMethod() {
// here, if component is mounted, refs are active:
// - this.divRef.el is the div HTMLElement
// - this.subRef.comp is the instance of the sub component
// - this.subRef.el is the root HTML node of the sub component (i.e. this.subRef.comp.el)
}
}
```
As shown by the example above, html elements are accessed by using the `el`
key, and components references are accessed with `comp`.
Notes:
- if used on a component, the reference will be set in the `refs`
variable between `willPatch` and `patched`,
- on a component, accessing `ref.el` will get the root node of the component.
The `t-ref` directive also accepts dynamic values with string interpolation
(like the [`t-attf-`](qweb_templating_language.md#dynamic-attributes) and
`t-component` directives). For example,
```xml
<div t-ref="component_{{someCondition ? '1' : '2'}}"/>
```
Here, the references need to be set like this:
```js
this.ref1 = useRef("component_1");
this.ref2 = useRef("component_2");
```
References are only guaranteed to be active while the parent component is mounted.
If this is not the case, accessing `el` or `comp` on it will return `null`.
### `useSubEnv`
The environment is sometimes useful to share some common information between
all components. But sometimes, we want to _scope_ that knowledge to a subtree.
For example, if we have a form view component, maybe we would like to make some
`model` object available to all sub components, but not to the whole application.
This is where the `useSubEnv` hook may be useful: it lets a component add some
information to the environment in a way that only the component and its children
can access it:
```js
class FormComponent extends Component {
constructor(...args) {
super(...args);
const model = makeModel();
useSubEnv({ model });
}
}
```
The `useSubEnv` takes one argument: an object which contains some key/value that
will be added to the parent environment. Note that it will extend, not replace
the parent environment. And of course, the parent environment will not be
affected.
### `useStore`
The `useStore` hook is the entry point for a component to connect to the store.
See the [store documentation](store.md) for more information.
### `useDispatch`
The `useDispatch` hook is the way for components to get a reference to the store
`dispatch` function. See the [store documentation](store.md) for more information.
### `useGetters`
The `useGetters` hook is the way for components to get a reference to the store
getters. See the [store documentation](store.md) for more information.
### Making customized hooks
Hooks are a wonderful way to organize the code of a complex component by feature
instead of by lifecycle methods. They are like mixins, except that they can be
easily composed together.
But, like every good things in life, hooks should be used with moderation. They are
not the solution to every problem.
- they may be overkill: if your component needs to perform some action specific
to itself (so, the specific code does not need to be shared), there is nothing
wrong with a simple class method:
```js
// maybe overkill
class A extends Component {
constructor(...args) {
super(...args);
useMySpecificHook();
}
}
// ok
class B extends Component {
constructor(...args) {
super(...args);
this.performSpecificTask();
}
}
```
Note that the second solution is easier to extend in sub components.
- they may be harder to test: if a customized hook injects some external side
effect dependency, then it is harder to test without doing some non obvious
manipulation. For example, assume that we want to give a reference to a
router in a `useRouter` hook. We could do this:
```js
const router = new Router(...);
function useRouter() {
return router;
}
```
As you can see, this does not _hook_ into the internal of the component. It
simply returns a global object, which is difficult to mock.
A better way would be to do something like this: get the reference from the
environment.
```js
function useRouter() {
return Component.current.env.router;
}
```
This means that we give control to the application developer to create the
router, which is good, so they can set it up, subclass it, ... And then, to
test our components, we can just add a mock router in the environment.
Note: the code above makes use of the `Component.current` property. This is the
way hooks are able to get a reference to the component currently being created.
+23
View File
@@ -0,0 +1,23 @@
# 🦉 Miscellaneous 🦉
## `AsyncRoot`
When this component is used, a new rendering sub tree is created, such that the
rendering of that component (and its children) is not tied to the rendering of
the rest of the interface. It can be used on an asynchronous component, to
prevent it from delaying the rendering of the whole interface, or on a
synchronous one, such that its rendering isn't delayed by other (asynchronous)
components. Note that this directive has no effect on the first rendering, but
only on subsequent ones (triggered by state or props changes).
```xml
<div t-name="ParentComponent">
<SyncChild />
<AsyncRoot>
<AsyncChild/>
</AsyncRoot>
</div>
```
The `AsyncRoot` assumes that there is exactly one root node inside it. It can
be a dom node or a component.
+52
View File
@@ -0,0 +1,52 @@
# 🦉 Observer 🦉
Owl needs to be able to react to state changes. For example, whenever the state
of a component is changed, Owl needs to rerender it. To help with that, there is
an Observer class. Its job is to observe the state of an object (or array), and
to react to any change. The observer is implemented with the native `Proxy`
object. Note that this means that it will not work on older browsers.
Note that the `Observer` is used by the `useState` and `useContext` hooks. This
is the way most Owl applications will create observers. For the majority of
use cases, there is no need to directly instantiate an observer.
## Example
For example, this code will display `update` in the console:
```javascript
const observer = new owl.Observer();
observer.notifyCB = () => console.log("update");
const obj = observer.observe({ a: { b: 1 } });
obj.a.b = 2;
```
This example shows that an observer can observe nested properties.
## Reference
**observe** An observer can observe multiple values with the `observe` method.
This method takes an object or an array as its argument and will return a proxy
(which is mapped to the initial object/array). With this proxy, the observer
can detect whenever any internal value is changed.
**Registering a callback** Whenever an observer sees a state change, it will
call its `notifyCB` method. No additional information is given to the callback.
**deepRevNumber** Each observed value has an internal revision number, which
is incremented every time the value is observed. Sometimes, it can be useful
to obtain that number:
```js
const observer = new owl.Observer();
const obj = observer.observe({ a: { b: 1 } });
observer.deepRevNumber(obj.a); // 1
obj.a.b = 2;
observer.deepRevNumber(obj.a); // 2
```
The `deepRevNumber` can also return 0, which indicates that the value is not
observed.
+97
View File
@@ -0,0 +1,97 @@
# 🦉 Props 🦉
## Content
- [Overview](#overview)
- [Definition](#definition)
- [Good Practices](#good-practices)
- [Dynamic Props](#dynamic-props)
## Overview
In Owl, `props` (short for _properties_) is an object which contains every piece
of data given to a component by its parent.
```js
class Child extends Component {
static template = xml`<div><t t-esc="props.a"/><t t-esc="props.b"/></div>`;
}
class Parent extends Component {
static template = xml`<div><ComponentA a="state.a" b="'string'"/></div>`;
static components = { Child };
state = useState({ a: "fromparent" });
}
```
In this example, the `Child` component receives two props from its parent: `a`
and `b`. They are collected into a `props` object by Owl, with each value being
evaluated in the context of the parent. So, `props.a` is equal to `'fromparent'` and
`props.b` is equal to `'string'`.
Note that `props` is an object that only makes sense from the perspective of the
child component.
## Definition
The `props` object is made of every attributes defined on the template, with the
following exceptions:
- every attribute starting with `t-` are not props (they are QWeb directives),
- `style` and `class` attributes are excluded as well (they are applied by Owl on
the root element of the component).
In the following example:
```xml
<div>
<ComponentA a="state.a" b="'string'"/>
<ComponentB t-if="state.flag" model="model"/>
<ComponentC style="color:red;" class="left-pane" />
</div>
```
the `props` object contains the following keys:
- for `ComponentA`: `a` and `b`,
- for `ComponentB`: `model`,
- for `ComponentC`: empty object
## Good Practices
A `props` object is a collection of values that come from the parent. As such,
they are owned by the parent, and should never be modified by the child:
```js
class MyComponent extends Component {
constructor(parent, props) {
super(parent, props);
props.a.b = 43; // Never do that!!!
}
}
```
Props should be considered readonly, from the perspective of the child component.
If there is a need to modify them, then the request to update them should be
sent to the parent (for example, with an event).
Any value can go in a props. Strings, objects, classes, or even callbacks could
be given to a child component (but then, in the case of callbacks, communicating
with events seems more appropriate).
## Dynamic Props
The `t-props` directive can be used to specify totally dynamic props:
```xml
<div t-name="ParentComponent">
<Child t-props="some.obj"/>
</div>
```
```js
class ParentComponent {
static components = { Child };
some = { obj: { a: 1, b: 2 } };
}
```
+151
View File
@@ -0,0 +1,151 @@
# 🦉 QWeb Engine 🦉
## Content
- [Overview](#overview)
- [Reference](#reference)
## Overview
[QWeb](https://www.odoo.com/documentation/13.0/reference/qweb.html) is the primary
templating engine used by Odoo. The QWeb class in the OWL project is an
implementation of that specification with a few interesting points:
- it compiles templates into functions that output a virtual DOM instead of a
string. This is necessary for the component system.
- it has a few extra directives: `t-component`, `t-on`, ...
We present in this section the engine, not the templating language.
## Reference
This section is about the javascript code that implements the `QWeb` specification.
Owl exports a `QWeb` class in `owl.QWeb`. To use it, it just needs to be
instantiated:
```js
const qweb = new owl.QWeb();
```
Its API is quite simple:
- **`constructor(config)`**: constructor. Takes an optional configuration object
with an optional `templates` string to add initial
templates (see `addTemplates` for more information on format of the string)
and an optional `translateFn` translate function (see the section on
[translations](#translations)).
```js
const qweb = new owl.QWeb({ templates: TEMPLATES, translateFn: _t });
```
- **`addTemplate(name, xmlStr, allowDuplicate)`**: add a specific template.
```js
qweb.addTemplate("mytemplate", "<div>hello</div>");
```
If the optional `allowDuplicate` is set to `true`, then `QWeb` will simply
ignore templates added for a second time. Otherwise, `QWeb` will crash.
- **`addTemplates(xmlStr)`**: add a list of templates (identified by `t-name`
attribute).
```js
const TEMPLATES = `
<templates>
<div t-name="App" class="main">main</div>
<div t-name="OtherComponent">other component</div>
</templates>`;
qweb.addTemplates(TEMPLATES);
```
- **`render(name, context, extra)`**: renders a template. This returns a `vnode`,
which is a virtual representation of the DOM (see [vdom doc](../architecture/vdom.md)).
```js
const vnode = qweb.render("App", component);
```
- **`renderToString(name, context)`**: renders a template, but returns an html
string.
```js
const str = qweb.renderToString("someTemplate", somecontext);
```
- **`registerTemplate(name, template)`**: static function to register a global
QWeb template. This is useful for commonly used components accross the
application, and for making a template available to an application without
having a reference to the actual QWeb instance.
```js
QWeb.registerTemplate("mytemplate", `<div>some template</div>`);
```
- **`registerComponent(name, Component)`**: static function to register an OWL Component
to QWeb's global registry. Globally registered Components can be used in
templates (see the `t-component` directive). This is useful for commonly used
components accross the application.
```js
class Dialog extends owl.Component { ... }
QWeb.registerComponent("Dialog", Dialog);
...
class ParentComponent extends owl.Component { ... }
qweb.addTemplate("ParentComponent", "<div><Dialog/></div>");
```
In some way, a `QWeb` instance is the core of an Owl application. It is the only
mandatory element of an [environment](environment.md). As such, it
has an extra responsibility: it can act as an event bus for internal communication
between Owl classes. This is the reason why `QWeb` actually extends [EventBus](event_bus.md).
### Translations
If properly setup, Owl QWeb engine can translate all rendered templates. To do
so, it needs a translate function, which takes a string and returns a string.
For example:
```js
const translations = {
hello: "bonjour",
yes: "oui",
no: "non"
};
const translateFn = str => translations[str] || str;
const qweb = new QWeb({ translateFn });
```
Once setup, all rendered templates will be translated using `translateFn`:
- each text node will be replaced with its translation,
- each of the following attribute values will be translated as well: `title`,
`placeholder`, `label` and `alt`,
- translating text nodes can be disabled with the special attribute `t-translation`,
if its value is `off`.
So, with the above `translateFn`, the following templates:
```xml
<div>hello</div>
<div t-translation="off">hello</div>
<div>Are you sure?</div>
<input placeholder="hello" other="yes"/>
```
will be rendered as:
```xml
<div>bonjour</div>
<div>hello</div>
<div>Are you sure?</div>
<input placeholder="bonjour" other="yes"/>
```
Note that the translation is done during the compilation of the template, not
when it is rendered.
@@ -1,10 +1,9 @@
# 🦉 QWeb 🦉
# 🦉 QWeb Templating Language🦉
## Content
- [Overview](#overview)
- [Directives](#directives)
- [QWeb Engine](#qweb-engine)
- [Reference](#reference)
- [White Spaces](#white-spaces)
- [Root Nodes](#root-nodes)
@@ -16,148 +15,72 @@
- [Dynamic Attributes](#dynamic-attributes)
- [Loops](#loops)
- [Rendering Sub Templates](#rendering-sub-templates)
- [Translations](#translations)
- [Debugging](#debugging)
## Overview
[QWeb](https://www.odoo.com/documentation/12.0/reference/qweb.html) is the primary templating engine used by Odoo. It is based on the XML format, and used
[QWeb](https://www.odoo.com/documentation/13.0/reference/qweb.html) is the primary
templating engine used by Odoo. It is based on the XML format, and used
mostly to generate HTML. In OWL, QWeb templates are compiled into functions that
generate a virtual dom representation of the HTML.
Template directives are specified as XML attributes prefixed with `t-`, for instance `t-if` for conditionals, with elements and other attributes being rendered directly.
To avoid element rendering, a placeholder element `<t>` is also available, which executes its directive but doesnt generate any output in and of itself.
```xml
<div>
<span t-if="somecondition">Some string</span>
<ul t-else="1">
<li t-foreach="messages" t-as="message">
<t t-esc="message">
<t t-esc="message"/>
</li>
</ul>
</div>
```
The QWeb class in the OWL project is an implementation of that specification
with a few interesting points:
Template directives are specified as XML attributes prefixed with `t-`, for
instance `t-if` for conditionals, with elements and other attributes being
rendered directly.
- it compiles templates into functions that output a virtual DOM instead of a
string. This is necessary for the component system.
- it has a few extra directives: `t-component`, `t-on`, ...
To avoid element rendering, a placeholder element `<t>` is also available, which
executes its directive but doesnt generate any output in and of itself.
We present in this section the templating language, including its Owl specific
extensions.
## Directives
We present here a list of all standard QWeb directives:
For reference, here is a list of all standard QWeb directives:
| Name | Description |
| ------------------------------ | ------------------------------------------------------------ |
| `t-esc` | [Outputting safely a value](#outputting-data) |
| `t-raw` | [Outputting value, without escaping](#outputting-data) |
| `t-set`, `t-value` | [Setting variables](#setting-variables) |
| `t-if`, `t-elif`, `t-else`, | [conditionally rendering](#conditionals) |
| `t-foreach`, `t-as` | [Loops](#loops) |
| `t-att`, `t-attf-*`, `t-att-*` | [Dynamic attributes](#dynamic-attributes) |
| `t-call` | [Rendering sub templates](#rendering-sub-templates) |
| `t-debug`, `t-log` | [Debugging](#debugging) |
| `t-name` | [Defining a template (not really a directive)](#qweb-engine) |
| Name | Description |
| ------------------------------ | -------------------------------------------------------------- |
| `t-esc` | [Outputting safely a value](#outputting-data) |
| `t-raw` | [Outputting value, without escaping](#outputting-data) |
| `t-set`, `t-value` | [Setting variables](#setting-variables) |
| `t-if`, `t-elif`, `t-else`, | [conditionally rendering](#conditionals) |
| `t-foreach`, `t-as` | [Loops](#loops) |
| `t-att`, `t-attf-*`, `t-att-*` | [Dynamic attributes](#dynamic-attributes) |
| `t-call` | [Rendering sub templates](#rendering-sub-templates) |
| `t-debug`, `t-log` | [Debugging](#debugging) |
| `t-translation` | [Disabling the translation of a node](#translations) |
| `t-name` | [Defining a template (not really a directive)](qweb_engine.md) |
The component system in Owl requires additional directives, to express various
needs. Here is a list of all Owl specific directives:
| Name | Description |
| ------------------------------------------- | ----------------------------------------------------------------------------------- |
| `t-component`, `t-keepalive`, `t-asyncroot` | [Defining a sub component](component.md#composition) |
| `t-ref` | [Setting a reference to a dom node or a sub component](component.md#references) |
| `t-key` | [Defining a key (to help virtual dom reconciliation)](component.md#t-key-directive) |
| `t-on-*` | [Event handling](component.md#event-handling) |
| `t-transition` | [Defining an animation](animations.md#css-transitions) |
| `t-mounted` | [Callback when a node or component is mounted](component.md#t-mounted-directive) |
| `t-slot` | [Rendering a slot](component.md#slots) |
| `t-model` | [Form input bindings](component.md#form-input-bindings) |
## QWeb Engine
This section is about the javascript code that implements the `QWeb` specification.
Owl exports a `QWeb` class in `owl.QWeb`. To use it, it just needs to be
instantiated:
```js
const qweb = new owl.QWeb();
```
It's API is quite simple:
- **`constructor(data)`**: constructor. Takes an optional string to add initial
templates (see `addTemplates` for more information on format of the string).
```js
const qweb = new owl.QWeb(TEMPLATES);
```
- **`addTemplate(name, xmlStr, allowDuplicate)`**: add a specific template.
```js
qweb.addTemplate("mytemplate", "<div>hello</div>");
```
If the optional `allowDuplicate` is set to `true`, then `QWeb` will simply return whenever a template is added for a second time. Otherwise, `QWeb` will crash.
- **`addTemplates(xmlStr)`**: add a list of templates (identified by `t-name`
attribute).
```js
const TEMPLATES = `
<templates>
<div t-name="App" class="main">main</div>
<div t-name="OtherComponent">other component</div>
</templates>`;
qweb.addTemplates(TEMPLATES);
```
- **`render(name, context, extra)`**: renders a template. This returns a `vnode`,
which is a virtual representation of the DOM (see [vdom doc](vdom.md)).
```js
const vnode = qweb.render("App", component);
```
- **`renderToString(name, context)`**: renders a template, but returns an html
string.
```js
const str = qweb.renderToString("someTemplate", somecontext);
```
- **`register(name, Component)`**: static function to register an OWL Component
to QWeb's global registry. Globally registered Components can be used in
templates (see the `t-component` directive). This is useful for commonly used
components accross the application.
```js
class Dialog extends owl.Component { ... }
QWeb.register("Dialog", Dialog);
...
class ParentComponent extends owl.Component { ... }
qweb.addTemplate("ParentComponent", "<div><Dialog/></div>");
```
In some way, a `QWeb` instance is the core of an Owl application. It is the only
mandatory element of an [environment](component.md#environment). As such, it
has an extra responsability: it can act as an event bus for internal communication
between Owl classes. This is the reason why `QWeb` actually extends [EventBus](event_bus.md).
| Name | Description |
| ------------------------ | ------------------------------------------------------------------------------- |
| `t-component`, `t-props` | [Defining a sub component](component.md#composition) |
| `t-ref` | [Setting a reference to a dom node or a sub component](component.md#references) |
| `t-key` | [Defining a key (to help virtual dom reconciliation)](#loops) |
| `t-on-*` | [Event handling](component.md#event-handling) |
| `t-transition` | [Defining an animation](animations.md#css-transitions) |
| `t-slot` | [Rendering a slot](component.md#slots) |
| `t-model` | [Form input bindings](component.md#form-input-bindings) |
## Reference
We define in this section the specification of how `QWeb` templates should be
rendered. Note that we only document here the standard QWeb specification. Owl
specific extensions are documented in various other parts of the documentation.
### White Spaces
White spaces in a templates are handled in a special way:
White spaces in a template are handled in a special way:
- consecutive whitespaces are always condensed to a single whitespace
- if a whitespace-only text node contains a linebreak, it is ignored
@@ -192,14 +115,14 @@ root nodes.
### Expression Evaluation
QWeb expressions are strings that will be processed at compile time. Each variable in
the javascript expression will be replaced by a lookup in the context (so, the
the javascript expression will be replaced with a lookup in the context (so, the
component). For example, `a + b.c(d)` will be converted into:
```js
context["a"] + context["b"].c(context["d"]);
```
It is useful to explain the various rules that applies on these expressions:
It is useful to explain the various rules that apply on these expressions:
1. it should be a simple expression which returns a value. It cannot be a statement.
@@ -225,14 +148,14 @@ It is useful to explain the various rules that applies on these expressions:
3. it can use a few special operators to avoid using symbols such as `<`, `>`,
`&` or `|`. This is useful to make sure that we still write valid XML.
| Word | will be replaced by |
| ----- | ------------------- |
| `and` | `&&` |
| `or` | `\|\|` |
| `gt` | `>` |
| `gte` | `>=` |
| `lt` | `<` |
| `lte` | `<=` |
| Word | replaced with |
| ----- | ------------- |
| `and` | `&&` |
| `or` | `\|\|` |
| `gt` | `>` |
| `gte` | `>=` |
| `lt` | `<` |
| `lte` | `<=` |
So, one can write this:
@@ -277,6 +200,11 @@ rendered with the value `value` set to `<span>foo</span>` in the rendering conte
<p><span>foo</span></p>
```
Note that since the content of the expression is not known beforehand, the `t-raw`
directive has to parse the html (and convert it to a virtual dom structure) for
each rendering. So, it will be much slower than a regular template. It is
therefore advised to limit the use of `t-raw` whenever possible.
### Setting Variables
QWeb allows creating variables from within the template, to memoize a computation (to use it multiple times), give a piece of data a clearer name, ...
@@ -376,14 +304,25 @@ If an expression evaluates to a falsy value, it will not be set at all:
<div t-att-foo="false"/> <!-- result: <div></div> -->
```
There is another way to format a string attribute: the `t-attf-` directive. With
it, you get string interpolation:
It is sometimes convenient to format an attribute with string interpolation. In
that case, the `t-attf-` directive can be used. It is useful when we need to mix
literal and dynamic elements, such as css classes.
```xml
<div t-attf-foo="a {{value1}} is {{value2}} of {{value3}} ]"/>
<!-- result if values are set to 1,2 and 3: <div foo="a 0 is 1 of 2 ]"></div> -->
```
If we need completely dynamic attribute names, then there is an additional
directive: `t-att`, which takes either an object (with keys mapping to their
values) or a pair `[key, value]`. For example:
```xml
<div t-att="{'a': 1, 'b': 2}"/> <!-- result: <div a="1" b="2"></div> -->
<div t-att="['a', 'b']"/> <!-- <div a="b"></div> -->
```
### Loops
QWeb has an iteration directive `t-foreach` which take an expression returning the
@@ -418,7 +357,7 @@ is equivalent to the previous example.
or an object (the current item will be the current key).
In addition to the name passed via t-as, `t-foreach` provides a few other
variables for various data points (note: `$as` will be replaced by the name
variables for various data points (note: `$as` will be replaced with the name
passed to `t-as`):
- `$as_value`: the current iteration value, identical to `$as` for lists and
@@ -436,19 +375,83 @@ the context of the `t-foreach`, the value is copied at the end of the foreach
into the global context.
```xml
<t t-set="existing_variable" t-value="False"/>
<t t-set="existing_variable" t-value="false"/>
<!-- existing_variable now False -->
<p t-foreach="Array(3)" t-as="i">
<t t-set="existing_variable" t-value="True"/>
<t t-set="new_variable" t-value="True"/>
<!-- existing_variable and new_variable now True -->
<t t-set="existing_variable" t-value="true"/>
<t t-set="new_variable" t-value="true"/>
<!-- existing_variable and new_variable now true -->
</p>
<!-- existing_variable always True -->
<!-- existing_variable always true -->
<!-- new_variable undefined -->
```
Even though Owl tries to be as declarative as possible, the DOM does not fully
expose its state declaratively in the DOM tree. For example, the scrolling state,
the current user selection, the focused element or the state of an input are not
set as attribute in the DOM tree. This is why we use a virtual dom
algorithm to keep the actual DOM node as much as possible.
However, in some situations, this is not enough, and we need to help Owl decide
if an element is actually the same, or is a different element with the same
properties.
Consider the following situation: we have a list of two items `[{text: "a"}, {text: "b"}]`
and we render them in this template:
```xml
<p t-foreach="items" t-as="item"><t t-esc="item.text"/></p>
```
The result will be two `<p>` tags with text `a` and `b`. Now, if we swap them,
and rerender the template, Owl needs to know what the intent is:
- should Owl actually swap the DOM nodes,
- or should it keep the DOM nodes, but with an updated text content?
This might look trivial, but it actually matters. These two possibilities lead
to different results in some cases. For example, if the user selected the text
of the first `p`, swapping them will keep the selection while updating the
text content will not.
There are many other cases where this is important: `input` tags with their
value, css classes and animations, scroll position...
So, the `t-key` directive is used to give an identity to an element. It allows
Owl to understand if different elements of a list are actually different or not.
The above example could be modified by adding an ID: `[{id: 1, text: "a"}, {id: 2, text: "b"}]`.
Then, the template could look like this:
```xml
<p t-foreach="items" t-as="item" t-key="item.id"><t t-esc="item.text"/></p>
```
The `t-key` directive is useful for lists (`t-foreach`). A key should be
a unique number or string (objects will not work: they will be cast to the
`"[object Object]"` string, which is obviously not unique).
Also, the key can be set on a `t` tag or on its children. The following variations
are all equivalent:
```xml
<p t-foreach="items" t-as="item" t-key="item.id">
<t t-esc="item.text"/>
</p>
<t t-foreach="items" t-as="item" t-key="item.id">
<p t-esc="item.text"/>
</t>
<t t-foreach="items" t-as="item">
<p t-key="item.id" t-esc="item.text"/>
</t>
```
If there is no `t-key` directive, Owl will use the index as a default key.
### Rendering Sub Templates
QWeb templates can be used for top level rendering, but they can also be used
@@ -497,6 +500,21 @@ will result in :
</div>
```
### Translations
By default, QWeb specify that templates should be translated. If this behaviour
is not wanted, there is a `t-translation` directive which can turn off
translations (if it is set to the `off` value), with the following rules:
- each text node will be replaced with its translation,
- each of the following attribute values will be translated as well: `title`,
`placeholder`, `label` and `alt`,
- translating text nodes can be disabled with the special attribute `t-translation`,
if its value is `off`.
See [here](qweb_engine.md#translations) for more information on how to setup a
translate function in Owl QWeb.
### Debugging
The javascript QWeb implementation provides two useful debugging directives:
@@ -518,4 +536,4 @@ will stop execution if the browser dev tools are open.
<t t-log="foo"/>
```
will print 42 to the console
will print 42 to the console.
+181
View File
@@ -0,0 +1,181 @@
# 🦉 Router 🦉
## Content
- [Overview](#overview)
- [Example](#example)
- [Reference](#reference)
- [Route Definition](#route-definition)
- [Router](#router)
- [Navigation Guards](#navigation-guards)
- [RouteComponent](#routecomponent)
- [Link](#link)
## Overview
It is often useful to organize an application around urls. If the application is
a single page application, then we need a way to manage those urls in the browser.
This is why there are many different routers for different frameworks. A generic
router can do the job just fine, but a specialized router for Owl can give a
better developer experience.
The Owl router support the following features:
- `history` or `hash` mode
- declarative routes
- route redirection
- navigation guards
- parameterized routes
- a `<Link/>` component
- a `<RouteComponent/>` component
Note that it is still in early stage of developments, and there are probably
still some issues.
## Example
To use the Owl router, there are some steps that needs to be done:
- declare some routes
- create a router
- add it to the environment
```js
async function protectRoute({ env, to }) {
if (!env.session.authUser) {
env.session.setNextRoute(to.name);
return { to: "SIGN_IN" };
}
return true;
}
export const ROUTES = [
{ name: "LANDING", path: "/", component: Landing },
{ name: "TASK", path: "/tasks/{{id}}", component: Task },
{ name: "SIGN_UP", path: "/signup", component: SignUp },
{ name: "SIGN_IN", path: "/signin", component: SignIn },
{ name: "ADMIN", path: "/admin", component: Admin, beforeRouteEnter: protectRoute },
{ name: "ACCOUNT", path: "/account", component: Account, beforeRouteEnter: protectRoute },
{ name: "UNKNOWN", path: "*", redirect: { to: "LANDING" } }
];
function makeEnvironment() {
...
const env = { qweb };
env.session = new Session(env);
env.router = new owl.router.Router(env, ROUTES);
await env.router.start();
return env;
}
App.env = makeEnvironment();
// create root component here
```
Notice that the router needs to be started. This is an asynchronous operation
because it needs to apply the potential navigation guards on the current route
(which may or may not mean that the application is redirected to another route).
## Reference
### Route definition
A route need to be defined as an object with the following keys:
- `name` (optional): a (unique) string useful to identify the current route. If not
given, it will be assigned an automatic name,
- `path`: a string describing the url. It can be static: `/admin` or dynamic: `/users/{{id}}`.
It also can be `*`, to catch all remaining routes.
- `component` (optional): an Owl component that will be used by the `t-routecomponent`
directive if the route is active
- `redirect` (optional): should be destination object (with optional keys `path`, `to` and `params`) if given, the application will be redirected to the destination whenever we match this route
- `beforeRouteEnter`: defines a [navigation guard](#navigation-guards).
### `Router`
The `Router` constructor takes three arguments:
- `env`: a valid environment,
- a list of routes,
- an optional object (with the only key `mode` which can be `history` (default
value) or `hash`).
`history` will use the browser [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API) as the mechanism to manage URL.\
Example: `https://yourdomain.tld/my_custom_route`.\
For this mechanism to work, you need a way to configure your web server accordingly.
`hash` will manipulate the hash of the URL.\
Example: `https://yourdomain.tld/index.html#/my_custom_route`.
```js
const ROUTES = [...];
const router = new owl.router.Router(env, ROUTES, {mode: 'history'});
```
Note that the route are defined in a list, and the order matters: the router
tries to find a match by going down the list.
The router needs to be added to the environment in the `router` sub key.
Once a router is created, it needs to be started. This is necessary to initialize
its current state to the current URL (and also, to potentially apply any
navigation guards and/or redirecting).
```js
await router.start();
```
Once started, the router will keep track of the current url and reflect its
value in two keys:
- `router.currentRoute`
- `router.currentParams`
The router also has a `navigate` method, useful to programmatically change the
application to another state (and the url):
```js
router.navigate({ to: "USER", params: { id: 51 } });
```
### Navigation Guards
Navigation guards are very useful to be able to execute some business logic/
perform some actions or redirect to other routes whenever the application is
entering a new route. For example, the following guard checks if there is an
authenticated user, and if it is not the case, redirect to the sign in route.
```js
async function protectRoute({ env, to }) {
if (!env.session.authUser) {
env.session.setNextRoute(to.name);
return { to: "SIGN_IN" };
}
return true;
}
```
A navigation guard is a function that returns a promise, which either resolves
to `true` (the navigation is accepted), or to another destination object.
### `RouteComponent`
The `RouteComponent` component directs Owl to render the component associated
to the currently active route (if any):
```xml
<div t-name="App">
<NavBar />
<RouteComponent />
</div>
```
### `Link`
The `Link` component is a Owl component which render as a `<a>` tag with any
content. It will compute the proper href from its props, and allow Owl to
properly navigate to a given url if clicked on it.
```xml
<Link to="'HOME'">Home</Link>
```
+337
View File
@@ -0,0 +1,337 @@
# 🦉 Store 🦉
## Content
- [Overview](#overview)
- [Example](#example)
- [Reference](#reference)
- [Store](#store)
- [Actions](#actions)
- [Getters](#getters)
- [Connecting a Component](#connecting-a-component)
- [`useStore`](#usestore)
- [`useDispatch`](#usedispatch)
- [`useGetters`](#usegetters)
- [Semantics](#semantics)
- [Good Practices](#good-practices)
## Overview
Managing the state in an application is not an easy task. In some cases, the
state of an application can be part of the component tree, in a natural way.
However, there are situations where some parts of the state need to be displayed
in various parts of the user interface, and then, it is not obvious which
component should own which part of the state.
Owl's solution to this issue is a centralized store. It is a class that owns
some (or all) state, and lets the developer update it in a structured way, with
`actions`. Owl components can then connect to the store, and will be updated if
necessary.
Note: Owl store is inspired by React Redux and VueX.
## Example
Here is what a simple store looks like:
```js
const actions = {
addTodo({ state }, message) {
state.todos.push({
id: state.nextId++,
message,
isCompleted: false
});
}
};
const state = {
todos: [],
nextId: 1
};
const store = new owl.Store({ state, actions });
store.on("update", null, () => console.log(store.state));
// updating the state
store.dispatch("addTodo", "fix all bugs");
```
This example shows how a store can be defined and used. Note that in most cases,
actions will be dispatched by connected components.
## Reference
### `Store`
The store is a simple [`owl.EventBus`](event_bus.md) that triggers `update` events
whenever its state is changed. Note that these events are triggered only after a
microtask tick, so only one event will be triggered for any number of state changes in a
call stack.
Also, it is important to mention that the state is observed (with an `owl.Observer`),
which is the reason why it is able to know if it was changed. See the
[Observer](observer.md)'s documentation for more details.
The `Store` class is quite small. It has two public methods:
- its constructor
- `dispatch`
The constructor takes a configuration object with four (optional) keys:
- the initial state
- the actions
- the getters
- the environment
```javascript
const config = {
state,
actions,
getters,
env
};
const store = new Store(config);
```
### Actions
Actions are used to coordinate state changes. It can be used for both synchronous
and asynchronous logic.
```js
const actions = {
async login({ state }, info) {
state.loginState = "pending";
try {
const loginInfo = await doSomeRPC("/login/", info);
state.loginState = loginInfo;
} catch (e) {
state.loginState = "error";
}
}
};
```
The first argument to an action method is an object with four keys:
- `state`: the current state of the store content,
- `dispatch`: a function that can be used to dispatch other actions,
- `getters`: an object containing all getters defined in the store,
- `env`: the current environment. This is useful sometimes, in particular if
an action needs to apply some side effects (such as performing an rpc), and
the `rpc` method is located in the environment.
Actions are called with the `dispatch` method on the store, and can receive an
arbitrary number of arguments.
```js
store.dispatch("login", someInfo);
```
Note that anything returned by an action will also be returned by the `dispatch`
call.
Also, it is important to be aware that we need to be careful with asynchronous
logic. Each state change will potentially trigger a rerendering, so we need to
make sure that we do not have a partially corrupted state. Here is an example that
is likely not a good idea:
```javascript
const actions = {
async fetchSomeData({ state }, recordId) {
state.recordId = recordId;
const data = await doSomeRPC("/read/", recordId);
state.recordData = data;
}
};
```
In the previous example, there is a period of time in which the state has a
`recordId` which does not correspond to the `recordData`. It is more likely that
we want an atomic update: updating the `recordId` at the same time as the `recordData`
values:
```javascript
const actions = {
async fetchSomeData({ state }, recordId) {
const data = await doSomeRPC("/read/", recordId);
state.recordId = recordId;
state.recordData = data;
}
};
```
### Getters
Usually, data contained in the store will be stored in a normalized way. For
example,
```js
{
posts: [{id: 11, authorId: 4, content: 'Greetings'}],
authors: [{id: 4, name: 'John'}]
}
```
However, the user interface will probably need some denormalized data like
```js
{id: 11, author: {id: 4, name: 'John'}, content: 'Greetings'}
```
This is what `getters` are for: they give a centralized way to process and
transform the data contained in the store.
```js
const getters = {
getPost({ state }, id) {
const post = state.posts.find(p => p.id === id);
const author = state.authors.find(a => a.id === post.id);
return {
id,
author,
content: post.content
};
}
};
// somewhere else
const post = store.getters.getPost(id);
```
Getters take _at most_ one argument.
Note that getters are not cached.
### Connecting a Component
At some point, we need a way to interact with the store from a component. This
can be done with the help of the three store hooks:
- [`useStore`](#usestore) to subscribe a component to some part of the store state,
- [`useDispatch`](#usedispatch) to get a reference to a dispatch function,
- [`useGetters`](#usegetters) to get a reference to the getters defined in the store.
Assume we have this store:
```javascript
const actions = {
increment({ state }, val) {
state.counter.value += val;
}
};
const state = {
counter: { value: 0 }
};
const store = new owl.Store({ state, actions });
```
A counter component can then select this value and dispatch an action like this:
```js
class Counter extends Component {
counter = useStore(state => state.counter);
dispatch = useDispatch();
}
const counter = new Counter({ store, qweb });
```
```xml
<button t-name="Counter" t-on-click="dispatch('increment')">
Click Me! [<t t-esc="counter.value"/>]
</button>
```
### `useStore`
The `useStore` hook is used to select some part of the store state. It accepts
two arguments:
- a selector function, which takes the store state as first argument (and the
component props as second argument) and which must return the part of the
store state that will be made available and observed for changes,
- optionally, an object which can have the following optional keys:
- a `store` key containing a store object if we want to use another store than
the default store,
- an `isEqual` key containing an equality function if we want to specialize
the comparison (the function must accept two arguments: the previous result
and the new result, and must return whether they are equal),
- and an `onUpdate` key containing an update function if we want to execute an
arbitrary code every time the selected state changes (the function will
receive one argument, the new result, and can execute arbitrary code).
If the `useStore` selector returns a sub part of the store state, the component
will only be rerendered whenever this part of the state changes. Otherwise, it
will perform a strict equality check (unless the `isEqual` option is defined,
then it will call it) and will update the component every time this check fails.
Note that if the selector function returns a primitive type, the result of
`useStore` will be immutable and it will not react to changes. In this case, it
is important to define the `onUpdate` option to properly update the value
manually when it changes.
Also, the return value from `useStore` is not supposed to be modified. The store
state should only be updated with actions.
### `useDispatch`
The `useDispatch` hook is useful when a component needs to be able to dispatch
actions. It takes an optional argument, which is a store. If not given, it will
use the store in the environment.
Note that a component does not need to be connected in any other way to the store.
For example:
```js
class DoSomethingButton extends Component {
static template = xml`<button t-on-click="dispatch('something')">Click</button>`;
dispatch = useDispatch();
}
```
### `useGetters`
The `useGetters` hook is useful when a component needs to be able to use the
getters defined in a store. It takes an optional argument, which is a store. If
not given, it will use the store in the environment.
Note that a component does not need to be connected in any other way to the store.
For example:
```js
class InfoButton extends Component {
static template = xml`<span><t t-esc="getters.somevalue()"></span>`;
getters = useGetters();
}
```
### Semantics
The `Store` class and the `useStore` hook try to be smart and to optimize as much
as possible the rendering and update process. What is important to know is:
- components are always updated in the order of their creation (so, parent
before children),
- they are updated only if they are in the DOM,
- if a parent is asynchronous, the system will wait for it to complete its
update before updating other components,
- in general, updates are not coordinated. This is not a problem for synchronous
components, but if there are many asynchronous components, this could lead to
a situation where some part of the UI is updated and some other part of the UI is
not updated.
### Good Practices
- avoid asynchronous components as much as possible. Asynchronous components
lead to situations where parts of the UI is not updated immediately,
- do not be afraid to connect many components, parent or children if needed. For
example, a `MessageList` component could get a list of ids in its `useStore`
call and a `Message` component could get the data of its own
message,
- since the `useStore` function is called for each connected component,
for each state update, it is important to make sure that these functions are
as fast as possible.
+54
View File
@@ -0,0 +1,54 @@
# 🦉 Tags 🦉
## Content
- [Overview](#overview)
- [`xml` tag](#xml-tag)
## Overview
Tags are very small helpers to make it easy to write inline templates. There is
only one currently available tag: `xml`, but we plan to add other tags later,
such as a `css` tag, which will be used to write [single file components](../tooling.md#single-file-component).
## XML tag
Without tags, creating a standalone component would look like this:
```js
import { Component } from 'owl'
const name = 'some-unique-name';
const template = `
<div>
<span t-if="somecondition">text</span>
<button t-on-click="someMethod">Click</button>
</div>
`;
QWeb.registerTemplate(name, template);
class MyComponent extends Component {
static template = name;
...
}
```
With tags, this process is slightly simplified. The name is uniquely generated,
and the template is automatically registered:
```js
const { Component } = owl;
const { xml } = owl.tags;
class MyComponent extends Component {
static template = xml`
<div>
<span t-if="somecondition">text</span>
<button t-on-click="someMethod">Click</button>
</div>
`;
...
}
```
+148
View File
@@ -0,0 +1,148 @@
# 🦉 Utils 🦉
Owl export a few useful utility functions, to help with common issues. Those
functions are all available in the `owl.utils` namespace.
## Content
- [`whenReady`](#whenready): executing code when DOM is ready
- [`loadJS`](#loadjs): loading script files
- [`loadFile`](#loadfile): loading a file (useful for templates)
- [`escape`](#escape): sanitizing strings
- [`debounce`](#debounce): limiting rate of function calls
- [`shallowEqual`](#shallowequal): shallow object comparison
## `whenReady`
The function `whenReady` returns a `Promise` resolved when the DOM is ready (if
not ready yet, resolved directly otherwise). If called with a callback as
argument, it executes it as soon as the DOM ready (or directly).
```js
Promise.all([loadFile("templates.xml"), owl.utils.whenReady()]).then(function([templates]) {
const qweb = new owl.QWeb({ templates });
const app = new App({ qweb });
app.mount(document.body);
});
```
or alternatively:
```js
owl.utils.whenReady(function() {
const qweb = new owl.QWeb();
const app = new App({ qweb });
app.mount(document.body);
});
```
## `loadJS`
`loadJS` takes a url (string) for a javascript resource, and loads it (by adding
a script tag in the document head). It returns a promise, so the caller can
properly reacts when it is ready. Also, it is smart: it maintains a list of urls
previously loaded (or currently being loaded), and prevent doing twice the work.
For example, it is useful for lazy loading external libraries:
```js
class MyComponent extends owl.Component {
willStart() {
return owl.utils.loadJS("/static/libs/someLib.js");
}
}
```
## `loadFile`
`loadFile` is a helper function to fetch a file. It simply
performs a `GET` request and returns the resulting string in a promise. The
initial usecase for this function is to load a template file. For example:
```js
async function makeEnv() {
const templates = await owl.utils.loadFile("templates.xml");
const qweb = new owl.QWeb({ templates });
return { qweb };
}
```
Note that unlike `loadJS`, this function returns the content of the file as a
string. It does not add a `script` tag or any other side effect.
## `escape`
Sometimes, we need to display dynamic data (for example user-generated data) in
the user interface. If this is done by a `QWeb` template, it is not an issue:
```xml
<div><t t-esc="user.data"/></div>
```
The `QWeb` engine will create a `div` node and add the content of the `user.data`
string as a text node, so the web browser will not parse it as html. However,
it may be a problem if this is done with some javascript code like this:
```js
class BadComponent extends Component {
// some template with a ref to a div
// some code ...
mounted() {
this.divRef.el.innerHTML = this.state.value;
}
}
```
In this case, the content of the `div` will be parsed as html, which may inject
unwanted behaviour. To fix this, the `escape` function will simply transform a
string into an escaped version of the same string, which will be properly displayed
by the browser, but which will not be parsed as html (for example, `"<ok>"` is
escaped to the string: `"&lt;ok&gt;"`). So, the bad example above can be fixed
with the following change:
```js
this.divRef.el.innerHTML = owl.utils.escape(this.state.value);
```
## `debounce`
The `debounce` function is useful when we want to limit the number of times some
function/action is perfomed. For example, this may be useful to prevent issue
with people double clicking on a button.
It takes three arguments:
- `func` (function): this is the function that will be rate limited
- `wait` (number): this is the number of milliseconds that we want to use to
rate limit the function `func`
- `immediate` (optional, boolean, default=false): if `immediate` is true, the
function will be triggered immediately (leading edge of the interval). If false,
the function will be triggered at the end (trailing edge).
It returns a function. For example:
```js
const debounce = owl.utils.debounce;
window.addEventListener("mousemove", debounce(doSomething, 100));
```
As this example shows, it is usualy useful for event handlers which are triggered
very quickly, such as `scroll` or `mousemove` events.
## `shallowEqual`
This function checks if two objects have the same values assigned to each keys:
```js
shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2 }); // true
shallowEqual({ a: 1, b: 2 }, { a: 1, b: 3 }); // false
```
However, for performance reasons, it assumes that the two objects have the same
keys. If we are in a situation where this is not guaranteed, the following code
will work:
```js
const completeShallowEqual = (a, b) => shallowEqual(a, b) && shallowEqual(b, a);
```
-251
View File
@@ -1,251 +0,0 @@
# 🦉 Store 🦉
## Content
- [Overview](#overview)
- [Example](#example)
- [Reference](#reference)
- [Public API](#public-api)
- [Mutations](#mutations)
- [Actions](#actions)
- [Getters](#getters)
- [Connecting a Component](#connecting-a-component)
- [Semantics](#semantics)
- [Good Practices](#good-practices)
## Overview
Managing the state in an application is not an easy task. In some cases, the
state of an application can be part of the component tree, in a natural way.
However, there are situations where some part of the state need to be displayed
in various parts of the user interface, and then, it is not obvious which
component should own which part of the state.
Owl's solution to this issue is a centralized store. It is a class that owns
some state, and let the developer update it in a structured way (through
mutations and actions). Owl components can then connect to the store, and will
be updated if necessary.
Note: Owl's store is inspired by React Redux and VueX.
## Example
Here is what a simple store looks like:
```js
const actions = {
addTodo({ commit }, message) {
commit("addTodo", message);
}
};
const mutations = {
addTodo({ state }, message) {
const todo = {
id: state.nextId++,
message,
isCompleted: false
};
state.todos.push(todo);
}
};
const state = {
todos: [],
nextId: 1
};
const store = new owl.Store({ state, actions, mutations });
store.on("update", () => console.log(store.state));
// updating the state
store.dispatch("addTodo", "fix all bugs");
```
## Reference
The store is a simple [`owl.EventBus`](event_bus.md) that triggers `update` events whenever its
state is changed. Note that these events are triggered only after a microtask
tick, so only one event will be triggered for any number of state changes in a
call stack.
Also, it is important to mention that the state is observed (with an `owl.Observer`),
which is the reason why it is able to know if it was changed. This implies that
state changes need to be done carefully in some cases (adding a new key to an
object, or modifying an array with the `arr[i] = newValue` syntax). See the
[Observer](observer.md)'s documentation for more details.
### Public API
1. `constructor`
2. `commit`
3. `dispatch`
### Mutations
Mutations are the only way to modify the state. Changing the state outside a
mutation is not allowed (and should throw an error). Mutations are synchronous.
```js
const mutations = {
setLoginState({ state }, loginState) {
state.loginState = loginState;
}
};
```
Mutations are called with the `commit` method on the store, and can receive an arbitrary number of arguments.
```js
store.commit("setLoginState", "error");
```
### Actions
Actions are used to coordinate state changes. It is also useful whenever some
asynchronous logic is necessary. For example, fetching data should be done
in an action.
```js
const actions = {
async login({ commit }, info) {
commit("setLoginState", "pending");
try {
const loginInfo = await doSomeRPC("/login/", info);
commit("setLoginState", loginInfo);
} catch {
commit("setLoginState", "error");
}
}
};
```
Actions are called with the `dispatch` method on the store, and can receive an
arbitrary number of arguments.
```js
store.dispatch("login", someInfo);
```
### Getters
Usually, data contained in the store will be stored in a normalized way. For
example,
```js
{
posts: [{id: 11, authorId: 4, content: 'Greetings'}],
authors: [{id: 4, name: 'John'}]
}
```
However, the user interface will probably need some denormalized data like
```js
{id: 11, author: {id: 4, name: 'John'}, content: 'Greetings'}
```
This is what `getters` are for: they give a centralized way to process and
transform the data contained in the store.
```js
const getters = {
getPost({ state }, id) {
const post = state.posts.find(p => p.id === id);
const author = state.authors.find(a => (a.id = post.id));
return {
id,
author,
content: post.content
};
}
};
// somewhere else
const post = store.getters.getPost(id);
```
Getters take _at most_ one argument.
Note that getters are cached if they don't take any argument, or their argument
is a string or a number.
### Connecting a Component
At some point, we need a way to access the state in the store from a component.
By default, an Owl `Component` is not connected to any store. To do that, we
need to create a component inheriting from `OwlComponent`:
```javascript
const actions = {
increment({ commit }) {
commit("increment", 1);
}
};
const mutations = {
increment({ state }, val) {
state.counter += val;
}
};
const state = {
counter: 0
};
const store = new owl.Store({ state, actions, mutations });
class Counter extends owl.ConnectedComponent {
static mapStoreToProps(state) {
return {
value: state.counter
};
}
increment() {
this.env.store.dispatch("increment");
}
}
const counter = new Counter({ store, qweb });
```
```xml
<button t-name="Counter" t-on-click="increment">
Click Me! [<t t-esc="props.value"/>]
</button>
```
The `ConnectedComponent` class can be configured with the following fields:
- `mapStoreToProps`: a function that extracts the `props` of the Component
from the `state` of the `Store` and returns them as a dict.
- `getStore`: a function that takes the `env` in arguments and returns an
instance of `Store` to connect to (if not given, connects to `env.store`)
- `hashFunction`: the function to use to detect changes in the state (if not
given, generates a function that uses revision numbers, incremented at
each state change)
- `deep` (boolean): [only useful if no hashFunction is given] if `false`, only watch
for top level state changes (`true` by default)
### Semantics
The `Store` and the `ConnectedComponent` try to be smart and to optimize as much
as possible the rendering and update process. What is important to know is:
- components are always updated in the order of their creation (so, parent
before children)
- they are updated only if they are in the DOM
- if a parent is asynchronous, the system will wait for it to complete its
update before updating other components.
- in general, updates are not coordinated. This is not a problem for synchronous
components, but if there are many asynchronous components, this could lead to
a situation where some part of the UI is updated and other parts of the UI is
not updated.
### Good Practices
- avoid asynchronous components as much as possible. Asynchronous components
lead to situations where parts of the UI is not updated immediately.
- do not be afraid to connect many components, parent or children if needed. For
example, a `MessageList` component could get a list of ids in its `mapStoreToProps` and a `Message` component could get the data of its own
message
- since the `mapStoreToProps` function is called for each connected component,
for each state update, it is important to make sure that these functions are
as fast as possible.
+43 -21
View File
@@ -3,9 +3,9 @@
## Content
- [Overview](#overview)
- [Development Mode](#development-mode)
- [Playground](#playground)
- [Benchmarks](#benchmarks)
- [Single File Component](#single-file-component)
## Overview
@@ -20,26 +20,6 @@ by using a static http server. A simple python
server is available in `server.py`. There is also a npm script to start it:
`npm run tools` (and its version with a watcher: `npm run tools:watch`).
## Development Mode
By default, Owl is in _production_ mode, this means that it will try to do its
job fast, and skip some expensive operations. However, in some cases, it is
convenient to have better information on what is going on, this is the purpose
of the dev mode.
Owl has a mode flag, in `owl.__info__.mode`. Its default value is `prod`, but
it can be set to `dev`:
```js
owl.__info__.mode = "dev";
```
Note that templates compiled with the `prod` settings will not be recompiled.
So, changing this setting is best done at startup.
An important job done by the `dev` mode is to validate props for each component
creation and update. Also, extra props will cause an error.
## Playground
The playground is an important application designed to help learning and
@@ -56,3 +36,45 @@ Note: This is more an internal tool, useful for people working on Owl.
The benchmarks application is a very small application, implemented in different
frameworks, and in different versions of Owl. This is a simple internal tool,
useful to compare various performance metrics on some tasks.
## Single File Component
It is very useful to group code by feature instead of by type of file. It makes
it easier to scale application to larger size.
To do so, Owl currently has a small helper that makes it easy to define a
template inside a javascript (or typescript) file: the [`xml`](reference/tags.md#xml-tag)
helper. With this, a template is automatically registered to [QWeb](reference/qweb_engine.md).
This means that the template and the javascript code can be defined in the same
file. It is not currently possible to add css to the same file, but Owl may
get a `css` tag helper later.
```js
const { Component } = owl;
const { xml } = owl.tags;
// -----------------------------------------------------------------------------
// TEMPLATE
// -----------------------------------------------------------------------------
const TEMPLATE = xml/* xml */ `
<div class="main two-columns">
<Sidebar/>
<Content />
</div>`;
// -----------------------------------------------------------------------------
// CODE
// -----------------------------------------------------------------------------
class MyComponent extends Component {
static template = TEMPLATE;
static components = { Sidebar, Content };
// rest of component...
}
```
Note that the above example has an inline xml comment, just after the `xml` call.
This is useful for some editor plugins, such as the VS Code addon
`Comment tagged template`, which, if installed, add syntax highlighting to the
content of the template string.
-65
View File
@@ -1,65 +0,0 @@
# 🦉 Utils 🦉
Owl export a few useful utility functions, to help with common issues. Those
functions are all available in the `owl.utils` namespace.
## Content
- [`whenReady`](#whenready): executing code when DOM is ready
- [`loadJS`](#loadjs): loading script files
- [`loadTemplates`](#loadtemplates): loading xml files
- [`escape`](#escape): sanitizing strings
- [`debounce`](#debounce): limiting rate of function calls
## `whenReady`
The function `whenReady` returns a `Promise` resolved when the DOM is ready (if
not ready yet, resolved directly otherwise). If called with a callback as
argument, it executes it as soon as the DOM ready (or directly).
```js
Promise.all([loadTemplates(), owl.utils.whenReady()]).then(function([
templates
]) {
const qweb = new owl.QWeb(templates);
const app = new App({ qweb });
app.mount(document.body);
});
```
```js
owl.utils.whenReady(function() {
const qweb = new owl.QWeb();
const app = new App({ qweb });
app.mount(document.body);
});
```
## `loadJS`
`loadJS` takes a url (string) for a javascript resource, and loads it. It returns
a promise, so the caller can properly react when it is ready. Also, it is smart:
it maintains a list of urls previously loaded (or currently being loaded), and
prevent doing twice the work.
```js
class MyComponent extends owl.Component {
willStart() {
return owl.utils.loadJS("/static/libs/someLib.js");
}
}
```
## `loadTemplates`
```js
async function makeEnv() {
const templates = await owl.utils.loadTemplates("templates.xml");
const qweb = new owl.QWeb(templates);
return { qweb };
}
```
## `escape`
## `debounce`
+15 -9
View File
@@ -1,47 +1,53 @@
{
"name": "owl",
"version": "0.17.0",
"name": "owl-framework",
"version": "1.0.0-alpha5",
"description": "Odoo Web Library (OWL)",
"main": "src/index.ts",
"engines": {
"node": ">=10.15.3"
},
"scripts": {
"build:js": "tsc --target esnext --module es6 --outDir dist/owl",
"build:bundle": "rollup -c",
"build": "npm run build:js && npm run build:bundle",
"buildcommonjs": "npm run build:js && npm run build:bundle -- -f cjs",
"minify": "uglifyjs dist/owl.js -o dist/owl.min.js --compress --mangle",
"test": "jest",
"test:watch": "jest --watch",
"tools:serve": "python3 tools/server.py || python tools/server.py",
"tools": "npm run build && npm run tools:serve",
"pretools:watch": "npm run build",
"tools:watch": "npm-run-all --parallel tools:serve \"build:* -- --watch\""
"tools:watch": "npm-run-all --parallel tools:serve \"build:* -- --watch\"",
"prettier": "prettier {src/*.ts,src/**/*.ts,tests/*.ts,tests/**/*.ts,doc/*.md,doc/**/*.md} --write"
},
"repository": {
"type": "git",
"url": "git+https://github.com/odoo/owl.git"
},
"author": "Odoo",
"license": "GPL-3.0-or-later",
"license": "LGPL",
"bugs": {
"url": "https://github.com/odoo/owl/issues"
},
"homepage": "https://github.com/odoo/owl#readme",
"dependencies": {},
"devDependencies": {
"@types/jest": "^23.3.12",
"cpx": "^1.5.0",
"git-rev-sync": "^1.12.0",
"jest": "^23.6.0",
"jest-environment-jsdom": "^24.7.1",
"live-server": "^1.2.1",
"npm-run-all": "^4.1.5",
"prettier": "^1.19.1",
"rollup": "^1.6.0",
"rollup-plugin-typescript2": "^0.20.1",
"sass": "^1.16.1",
"source-map-support": "^0.5.10",
"ts-jest": "^23.10.5",
"typescript": "^3.2.2",
"uglify-es": "^3.3.9",
"jest-environment-jsdom": "^24.7.1"
"typescript": "^3.7.2",
"uglify-es": "^3.3.9"
},
"dependencies": {},
"jest": {
"roots": [
"<rootDir>/src",
@@ -62,6 +68,6 @@
]
},
"prettier": {
"printWidth": 100
"printWidth": 100
}
}
+48
View File
@@ -0,0 +1,48 @@
# 🦉 OWL Roadmap 🦉
- Current version: 1.0.0-alpha5
- Status: mostly stable
This roadmap is only an attempt at predicting Owl's future. Everything may
change!
### November 2019
Owl will be used in various Odoo projects. We plan to:
- fix any issues encountered
- maybe cleanup slightly the router API
- improve the documentation
- improve error handling, add more helpful error messages
### December 2019
If all goes well, Owl will be upgraded to beta status. From then, no API change,
even small, is expected.
### End of 2019
Release v1.0
- API should be stable,
- we will use semantic versioning,
- we will maintain a changelog and an upgrade guide.
### 1.x
- add chrome and firefox devtools,
- add support for single file components,
- fix every bugs,
- improve documentation,
- small backward compatible improvements.
### 2.x (2021? 2022?)
Maybe:
- reimplement vdom to use *block* system, like Vue 3,
- refactor `QWeb` to use an intermediate representation (some kind of AST) to
allow additional optimisations.
-655
View File
@@ -1,655 +0,0 @@
import { Observer } from "./observer";
import { QWeb, CompiledTemplate, UTILS } from "./qweb_core";
import { h, patch, VNode } from "./vdom";
/**
* Owl Component System
*
* This file introduces a declarative and composable component system. It
* contains:
*
* - the Env interface (generic type for the environment)
* - the Meta interface (the owl specific metadata attached to a component)
* - the Component class
*/
//------------------------------------------------------------------------------
// Types/helpers
//------------------------------------------------------------------------------
/**
* An Env (environment) is an object that will be (mostly) shared between all
* components of an Owl application. It is the location which should contain
* the qweb instance necessary to render all components.
*
* Note that it is totally fine to extend the environment with application
* specific keys/objects/whatever. For example, a key `isMobile` (to declare
* if we are in "mobile" mode), or a shared bus could be useful.
*/
export interface Env {
qweb: QWeb;
}
/**
* This is mostly an internal detail of implementation. The Meta interface is
* useful to typecheck and describe the internal keys used by Owl to manage the
* component tree.
*/
export interface Meta<T extends Env, Props> {
readonly id: number;
vnode: VNode | null;
isMounted: boolean;
isDestroyed: boolean;
parent: Component<T, any, any> | null;
children: { [key: number]: Component<T, any, any> };
// children mapping: from templateID to componentID
// should it be a map number => Component?
cmap: { [key: number]: number };
renderId: number;
renderProps: Props | null;
renderPromise: Promise<VNode> | null;
boundHandlers: { [key: number]: any };
observer?: Observer;
render?: CompiledTemplate;
mountedHandlers: { [key: number]: Function };
classObj?: { [key: string]: boolean };
}
// If a component does not define explicitely a template
// key, it needs to find a template with its name (or a parent's). This is
// qweb dependant, so we need a place to store this information indexed by
// qweb instances.
const TEMPLATE_MAP: { [key: number]: { [name: string]: string } } = {};
//------------------------------------------------------------------------------
// Component
//------------------------------------------------------------------------------
let nextId = 1;
export class Component<T extends Env, Props extends {}, State extends {}> {
readonly __owl__: Meta<Env, Props>;
template?: string;
/**
* The `el` is the root element of the component. Note that it could be null:
* this is the case if the component is not mounted yet, or is destroyed.
*/
get el(): HTMLElement | null {
return this.__owl__.vnode ? (<any>this).__owl__.vnode.elm : null;
}
env: T;
state?: State;
props: Props;
// type of props is not easily representable in typescript...
static props?: any;
static defaultProps?: any;
refs: {
[key: string]: Component<T, any, any> | HTMLElement | undefined;
} = {};
//--------------------------------------------------------------------------
// Lifecycle
//--------------------------------------------------------------------------
/**
* Creates an instance of Component.
*
* The root component of a component tree needs an environment:
*
* ```javascript
* const root = new RootComponent(env, props);
* ```
*
* Every other component simply needs a reference to its parent:
*
* ```javascript
* const child = new SomeComponent(parent, props);
* ```
*
* Note that most of the time, only the root component needs to be created by
* hand. Other components should be created automatically by the framework (with
* the t-component directive in a template)
*/
constructor(parent: Component<T, any, any> | T, props?: Props) {
const defaultProps = (<any>this.constructor).defaultProps;
if (defaultProps) {
props = this.__applyDefaultProps(props, defaultProps);
}
// is this a good idea?
// Pro: if props is empty, we can create easily a component
// Con: this is not really safe
// Pro: but creating component (by a template) is always unsafe anyway
this.props = <Props>props || <Props>{};
let id: number = nextId++;
let p: Component<T, any, any> | null = null;
if (parent instanceof Component) {
p = parent;
this.env = parent.env;
parent.__owl__.children[id] = this;
} else {
this.env = parent;
if (QWeb.dev) {
// we only validate props for root widgets here. "Regular" widget
// props are validated by the t-component directive
UTILS.validateProps(this.constructor, this.props);
}
this.env.qweb.on("update", this, () => {
if (this.__owl__.isMounted) {
this.render(true);
}
if (this.__owl__.isDestroyed) {
// this is unlikely to happen, but if a root widget is destroyed,
// we want to remove our subscription. The usual way to do that
// would be to perform some check in the destroy method, but since
// it is very performance sensitive, and since this is a rare event,
// we simply do it lazily
this.env.qweb.off("update", this);
}
});
}
this.__owl__ = {
id: id,
vnode: null,
isMounted: false,
isDestroyed: false,
parent: p,
children: {},
cmap: {},
renderId: 1,
renderPromise: null,
renderProps: props || null,
boundHandlers: {},
mountedHandlers: {}
};
}
/**
* willStart is an asynchronous hook that can be implemented to perform some
* action before the initial rendering of a component.
*
* It will be called exactly once before the initial rendering. It is useful
* in some cases, for example, to load external assets (such as a JS library)
* before the component is rendered.
*
* Note that a slow willStart method will slow down the rendering of the user
* interface. Therefore, some effort should be made to make this method as
* fast as possible.
*
* Note: this method should not be called manually.
*/
async willStart() {}
/**
* mounted is a hook that is called each time a component is attached to the
* DOM. This is a good place to add some listeners, or to interact with the
* DOM, if the component needs to perform some measure for example.
*
* Note: this method should not be called manually.
*
* @see willUnmount
*/
mounted() {}
/**
* The willUpdateProps is an asynchronous hook, called just before new props
* are set. This is useful if the component needs some asynchronous task
* performed, depending on the props (for example, assuming that the props are
* some record Id, fetching the record data).
*
* This hook is not called during the first render (but willStart is called
* and performs a similar job).
*/
async willUpdateProps(nextProps: Props) {}
/**
* The willPatch hook is called just before the DOM patching process starts.
* It is not called on the initial render. This is useful to get some
* information which are in the DOM. For example, the current position of the
* scrollbar
*
* The return value of willPatch will be given to the patched function.
*/
willPatch(): any {}
/**
* This hook is called whenever a component did actually update its props,
* state or env.
*
* This method is not called on the initial render. It is useful to interact
* with the DOM (for example, through an external library) whenever the
* component was updated.
*
* Updating the component state in this hook is possible, but not encouraged.
* One need to be careful, because updates here will cause rerender, which in
* turn will cause other calls to updated. So, we need to be particularly
* careful at avoiding endless cycles.
*
* The snapshot parameter is the result of the call to willPatch.
*/
patched(snapshot: any) {}
/**
* willUnmount is a hook that is called each time just before a component is
* unmounted from the DOM. This is a good place to remove some listeners, for
* example.
*
* Note: this method should not be called manually.
*
* @see mounted
*/
willUnmount() {}
/**
* catchError is a method called whenever some error happens in the rendering or
* lifecycle hooks of a child.
*/
catchError(error: Error): void {}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Mount the component to a target element.
*
* This should only be done if the component was created manually. Components
* created declaratively in templates are managed by the Owl system.
*/
async mount(target: HTMLElement): Promise<void> {
const vnode = await this.__prepare();
if (this.__owl__.isDestroyed) {
// component was destroyed before we get here...
return;
}
this.__patch(vnode);
target.appendChild(this.el!);
if (document.body.contains(target)) {
this.__callMounted();
}
}
unmount() {
if (this.__owl__.isMounted) {
this.__callWillUnmount();
this.el!.remove();
}
}
async render(force: boolean = false, patchQueue?: any[], scope?: any, vars?: any): Promise<void> {
const __owl__ = this.__owl__;
if (!__owl__.isMounted) {
return;
}
const shouldPatch: boolean = !patchQueue;
if (shouldPatch) {
patchQueue = [];
}
const renderVDom = this.__render(force, patchQueue, scope, vars);
const renderId = __owl__.renderId;
await renderVDom;
if (shouldPatch && __owl__.isMounted && renderId === __owl__.renderId) {
// we only update the vnode and the actual DOM if no other rendering
// occurred between now and when the render method was initially called.
this.__applyPatchQueue(<any[]>patchQueue);
}
}
/**
* Destroy the component. This operation is quite complex:
* - it recursively destroy all children
* - call the willUnmount hooks if necessary
* - remove the dom node from the dom
*
* This should only be called manually if you created the component. Most
* components will be automatically destroyed.
*/
destroy() {
const __owl__ = this.__owl__;
if (!__owl__.isDestroyed) {
const el = this.el;
this.__destroy(__owl__.parent);
if (el) {
el.remove();
}
}
}
/**
* This method is called by the component system whenever its props are
* updated. If it returns true, then the component will be rendered.
* Otherwise, it will skip the rendering (also, its props will not be updated)
*/
shouldUpdate(nextProps: Props): boolean {
return true;
}
/**
* This method is the correct way to update the environment of a component. Doing
* this will cause a full rerender of the component and its children, so this is
* an operation that should not be done frequently.
*
* A good usecase for updating the environment would be to update some mostly
* static config keys, such as a boolean to determine if we are in mobile
* mode or not.
*/
async updateEnv(nextEnv: Partial<T>): Promise<void> {
const __owl__ = this.__owl__;
if (__owl__.parent && __owl__.parent.env === this.env) {
this.env = Object.create(this.env);
}
Object.assign(this.env, nextEnv);
if (__owl__.isMounted) {
await this.render(true);
}
}
/**
* Emit a custom event of type 'eventType' with the given 'payload' on the
* component's el, if it exists. However, note that the event will only bubble
* up to the parent DOM nodes. Thus, it must be called between mounted() and
* willUnmount().
*/
trigger(eventType: string, payload?: any) {
if (this.el) {
const ev = new CustomEvent(eventType, {
bubbles: true,
cancelable: true,
detail: payload
});
this.el.dispatchEvent(ev);
}
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
__destroy(parent: Component<any, any, any> | null) {
const __owl__ = this.__owl__;
const isMounted = __owl__.isMounted;
if (isMounted) {
this.willUnmount();
__owl__.isMounted = false;
}
const children = __owl__.children;
for (let key in children) {
children[key].__destroy(this);
}
if (parent) {
let id = __owl__.id;
delete parent.__owl__.children[id];
__owl__.parent = null;
}
__owl__.isDestroyed = true;
delete __owl__.vnode;
}
__callMounted() {
const __owl__ = this.__owl__;
const children = __owl__.children;
for (let id in children) {
const comp = children[id];
if (!comp.__owl__.isMounted && this.el!.contains(comp.el)) {
comp.__callMounted();
}
}
__owl__.isMounted = true;
const handlers = __owl__.mountedHandlers;
for (let key in handlers) {
handlers[key]();
}
try {
this.mounted();
} catch (e) {
errorHandler(e, this);
}
}
__callWillUnmount() {
this.willUnmount();
const __owl__ = this.__owl__;
__owl__.isMounted = false;
const children = __owl__.children;
for (let id in children) {
const comp = children[id];
if (comp.__owl__.isMounted) {
comp.__callWillUnmount();
}
}
}
async __updateProps(
nextProps: Props,
forceUpdate: boolean = false,
patchQueue?: any[],
scope?: any,
vars?: any
): Promise<void> {
const shouldUpdate = forceUpdate || this.shouldUpdate(nextProps);
if (shouldUpdate) {
const defaultProps = (<any>this.constructor).defaultProps;
if (defaultProps) {
nextProps = this.__applyDefaultProps(nextProps, defaultProps);
}
await this.willUpdateProps(nextProps);
this.props = nextProps;
await this.render(forceUpdate, patchQueue, scope, vars);
}
}
__patch(vnode) {
const __owl__ = this.__owl__;
__owl__.renderPromise = null;
const target = __owl__.vnode || document.createElement(vnode.sel!);
if (this.__owl__.classObj) {
(<any>vnode).data.class = Object.assign((<any>vnode).data.class || {}, this.__owl__.classObj);
}
__owl__.vnode = patch(target, vnode);
}
__prepare(scope?: Object, vars?: any): Promise<VNode> {
const __owl__ = this.__owl__;
__owl__.renderProps = this.props;
__owl__.renderPromise = this.__prepareAndRender(scope, vars);
return __owl__.renderPromise;
}
async __prepareAndRender(scope?: Object, vars?: any): Promise<VNode> {
try {
await this.willStart();
} catch (e) {
errorHandler(e, this);
return Promise.resolve(h("div"));
}
const __owl__ = this.__owl__;
if (__owl__.isDestroyed) {
return Promise.resolve(h("div"));
}
const qweb = this.env.qweb;
if (!this.template) {
let tmap = TEMPLATE_MAP[qweb.id];
if (!tmap) {
tmap = {};
TEMPLATE_MAP[qweb.id] = tmap;
}
let p = (<any>this).constructor;
let name: string = p.name;
let template = tmap[name];
if (template) {
this.template = template;
} else {
while ((template = p.name) && !(template in qweb.templates) && p !== Component) {
p = p.__proto__;
}
if (p === Component) {
throw new Error(`Could not find template for component "${this.constructor.name}"`);
} else {
tmap[name] = template;
this.template = template;
}
}
}
__owl__.render = qweb.render.bind(qweb, this.template);
this.__observeState();
return this.__render(false, [], scope, vars);
}
async __render(
force: boolean = false,
patchQueue: any[] = [],
scope?: Object,
vars?: any
): Promise<VNode> {
const __owl__ = this.__owl__;
__owl__.renderId++;
const promises: Promise<void>[] = [];
const patch: any[] = [this];
if (__owl__.isMounted) {
patchQueue.push(patch);
}
if (__owl__.observer) {
__owl__.observer.allowMutations = false;
}
let vnode;
try {
vnode = __owl__.render!(this, {
promises,
handlers: __owl__.boundHandlers,
mountedHandlers: __owl__.mountedHandlers,
forceUpdate: force,
patchQueue,
scope,
vars
});
} catch (e) {
vnode = __owl__.vnode || h("div");
errorHandler(e, this);
}
patch.push(vnode);
if (__owl__.observer) {
__owl__.observer.allowMutations = true;
}
// this part is critical for the patching process to be done correctly. The
// tricky part is that a child component can be rerendered on its own, which
// will update its own vnode representation without the knowledge of the
// parent component. With this, we make sure that the parent component will be
// able to patch itself properly after
vnode.key = __owl__.id;
__owl__.renderProps = this.props;
__owl__.renderPromise = Promise.all(promises).then(() => vnode);
return __owl__.renderPromise;
}
/**
* Only called by qweb t-component directive
*/
__mount(vnode: VNode, elm: HTMLElement): VNode {
const __owl__ = this.__owl__;
if (__owl__.classObj) {
(<any>vnode).data.class = Object.assign((<any>vnode).data.class || {}, __owl__.classObj);
}
__owl__.vnode = patch(elm, vnode);
if (__owl__.parent!.__owl__.isMounted && !__owl__.isMounted) {
this.__callMounted();
}
return __owl__.vnode;
}
/**
* Only called by qweb t-component directive (when t-keepalive is set)
*/
__remount() {
const __owl__ = this.__owl__;
if (!__owl__.isMounted) {
__owl__.isMounted = true;
this.mounted();
}
}
__observeState() {
if (this.state) {
const __owl__ = this.__owl__;
__owl__.observer = new Observer();
__owl__.observer.observe(this.state);
__owl__.observer.notifyCB = this.render.bind(this);
}
}
/**
* Apply default props (only top level).
*
* Note that this method does not modify in place the props, it returns a new
* prop object
*/
__applyDefaultProps(props: Object | undefined, defaultProps: Object): Props {
props = props ? Object.create(props) : {};
for (let propName in defaultProps) {
if (props![propName] === undefined) {
props![propName] = defaultProps[propName];
}
}
return <Props>props;
}
/**
* Apply the given patch queue. A patch is a pair [c, vn], where c is a
* Component instance and vn a VNode.
* 1) Call 'willPatch' on the component of each patch
* 2) Call '__patch' on the component of each patch
* 3) Call 'patched' on the component of each patch, in inverse order
*/
__applyPatchQueue(patchQueue: any[]) {
let component = this;
try {
const patchLen = patchQueue.length;
for (let i = 0; i < patchLen; i++) {
const patch = patchQueue[i];
component = patch[0];
patch.push(patch[0].willPatch());
}
for (let i = 0; i < patchLen; i++) {
const patch = patchQueue[i];
patch[0].__patch(patch[1]);
}
for (let i = patchLen - 1; i >= 0; i--) {
const patch = patchQueue[i];
component = patch[0];
patch[0].patched(patch[2]);
}
} catch (e) {
errorHandler(e, component);
}
}
}
//------------------------------------------------------------------------------
// Error handling
//------------------------------------------------------------------------------
function errorHandler(error, component) {
let canCatch = false;
let qweb = component.env.qweb;
let root = component;
while (component && !(canCatch = component.catchError !== Component.prototype.catchError)) {
root = component;
component = component.__owl__.parent;
}
console.error(error);
// we trigger error on QWeb so it can be logged/handled
qweb.trigger("error", error);
if (canCatch) {
setTimeout(() => {
component.catchError(error);
});
} else {
root.destroy();
}
}
+652
View File
@@ -0,0 +1,652 @@
import { Observer } from "../core/observer";
import { OwlEvent } from "../core/owl_event";
import { CompiledTemplate, QWeb } from "../qweb/index";
import { patch, VNode } from "../vdom/index";
import "./directive";
import { Fiber } from "./fiber";
import "./props_validation";
import { Scheduler, scheduler } from "./scheduler";
/**
* Owl Component System
*
* This file introduces a declarative and composable component system. It
* contains:
*
* - the Env interface (generic type for the environment)
* - the Internal interface (the owl specific metadata attached to a component)
* - the Component class
*/
//------------------------------------------------------------------------------
// Types/helpers
//------------------------------------------------------------------------------
/**
* An Env (environment) is an object that will be (mostly) shared between all
* components of an Owl application. It is the location which should contain
* the qweb instance necessary to render all components.
*
* Note that it is totally fine to extend the environment with application
* specific keys/objects/whatever. For example, a key `isMobile` (to declare
* if we are in "mobile" mode), or a shared bus could be useful.
*/
export interface Env {
qweb: QWeb;
[key: string]: any;
}
/**
* This is mostly an internal detail of implementation. The Meta interface is
* useful to typecheck and describe the internal keys used by Owl to manage the
* component tree.
*/
interface Internal<T extends Env, Props> {
// each component has a unique id, useful mostly to handle parent/child
// relationships
readonly id: number;
depth: number;
vnode: VNode | null;
pvnode: VNode | null;
isMounted: boolean;
isDestroyed: boolean;
// parent and children keys are obviously useful to setup the parent-children
// relationship.
parent: Component<T, any> | null;
children: { [key: number]: Component<T, any> };
// children mapping: from templateID to componentID. templateID identifies a
// place in a template. The t-component directive needs it to be able to get
// the component instance back whenever the template is rerendered.
cmap: { [key: number]: number };
currentFiber: Fiber | null;
// parentLastFiberId is there to help the parent component to detect, among
// its children, those that are not used anymore and thus can be destroyed
parentLastFiberId: number;
// when a rendering is initiated by a parent, it may set variables in 'scope'
// and 'vars' (typically when the component is rendered in a slot). We need to
// store that information in case the component would be re-rendered later on.
scope: any;
vars: any;
boundHandlers: { [key: number]: any };
observer: Observer | null;
renderFn: CompiledTemplate;
mountedCB: Function | null;
willUnmountCB: Function | null;
willPatchCB: Function | null;
patchedCB: Function | null;
willStartCB: Function | null;
willUpdatePropsCB: Function | null;
classObj: { [key: string]: boolean } | null;
refs: { [key: string]: Component<T, any> | HTMLElement | undefined } | null;
}
//------------------------------------------------------------------------------
// Component
//------------------------------------------------------------------------------
let nextId = 1;
export class Component<T extends Env, Props extends {}> {
readonly __owl__: Internal<Env, Props>;
static template?: string | null = null;
static _template?: string | null = null;
static current: Component<any, any> | null = null;
static components = {};
static props?: any;
static defaultProps?: any;
static env: any = {};
// expose scheduler s.t. it can be mocked for testing purposes
static scheduler: Scheduler = scheduler;
/**
* The `el` is the root element of the component. Note that it could be null:
* this is the case if the component is not mounted yet, or is destroyed.
*/
get el(): HTMLElement | null {
return this.__owl__.vnode ? (<any>this).__owl__.vnode.elm : null;
}
env: T;
props: Props;
//--------------------------------------------------------------------------
// Lifecycle
//--------------------------------------------------------------------------
/**
* Creates an instance of Component.
*
* Note that most of the time, only the root component needs to be created by
* hand. Other components should be created automatically by the framework (with
* the t-component directive in a template)
*/
constructor(parent?: Component<T, any> | null, props?: Props) {
Component.current = this;
let constr = this.constructor as any;
const defaultProps = constr.defaultProps;
if (defaultProps) {
props = props || ({} as Props);
this.__applyDefaultProps(props, defaultProps);
}
this.props = <Props>props;
if (QWeb.dev) {
QWeb.utils.validateProps(constr, this.props);
}
const id: number = nextId++;
let depth;
if (parent) {
this.env = parent.env;
const __powl__ = parent.__owl__;
__powl__.children[id] = this;
depth = __powl__.depth + 1;
} else {
// we are the root component
this.env = (this.constructor as any).env;
if (!this.env.qweb) {
this.env.qweb = new QWeb();
}
this.env.qweb.on("update", this, () => {
if (this.__owl__.isMounted) {
this.render(true);
}
if (this.__owl__.isDestroyed) {
// this is unlikely to happen, but if a root widget is destroyed,
// we want to remove our subscription. The usual way to do that
// would be to perform some check in the destroy method, but since
// it is very performance sensitive, and since this is a rare event,
// we simply do it lazily
this.env.qweb.off("update", this);
}
});
depth = 0;
}
const qweb = this.env.qweb;
const template = constr.template || this.__getTemplate(qweb);
this.__owl__ = {
id: id,
depth: depth,
vnode: null,
pvnode: null,
isMounted: false,
isDestroyed: false,
parent: parent || null,
children: {},
cmap: {},
currentFiber: null,
parentLastFiberId: 0,
boundHandlers: {},
mountedCB: null,
willUnmountCB: null,
willPatchCB: null,
patchedCB: null,
willStartCB: null,
willUpdatePropsCB: null,
observer: null,
renderFn: qweb.render.bind(qweb, template),
classObj: null,
refs: null,
scope: null,
vars: null
};
}
/**
* willStart is an asynchronous hook that can be implemented to perform some
* action before the initial rendering of a component.
*
* It will be called exactly once before the initial rendering. It is useful
* in some cases, for example, to load external assets (such as a JS library)
* before the component is rendered.
*
* Note that a slow willStart method will slow down the rendering of the user
* interface. Therefore, some effort should be made to make this method as
* fast as possible.
*
* Note: this method should not be called manually.
*/
async willStart() {}
/**
* mounted is a hook that is called each time a component is attached to the
* DOM. This is a good place to add some listeners, or to interact with the
* DOM, if the component needs to perform some measure for example.
*
* Note: this method should not be called manually.
*
* @see willUnmount
*/
mounted() {}
/**
* The willUpdateProps is an asynchronous hook, called just before new props
* are set. This is useful if the component needs some asynchronous task
* performed, depending on the props (for example, assuming that the props are
* some record Id, fetching the record data).
*
* This hook is not called during the first render (but willStart is called
* and performs a similar job).
*/
async willUpdateProps(nextProps: Props) {}
/**
* The willPatch hook is called just before the DOM patching process starts.
* It is not called on the initial render. This is useful to get some
* information which are in the DOM. For example, the current position of the
* scrollbar
*/
willPatch(): any {}
/**
* This hook is called whenever a component did actually update its props,
* state or env.
*
* This method is not called on the initial render. It is useful to interact
* with the DOM (for example, through an external library) whenever the
* component was updated.
*
* Updating the component state in this hook is possible, but not encouraged.
* One need to be careful, because updates here will cause rerender, which in
* turn will cause other calls to updated. So, we need to be particularly
* careful at avoiding endless cycles.
*/
patched() {}
/**
* willUnmount is a hook that is called each time just before a component is
* unmounted from the DOM. This is a good place to remove some listeners, for
* example.
*
* Note: this method should not be called manually.
*
* @see mounted
*/
willUnmount() {}
/**
* catchError is a method called whenever some error happens in the rendering or
* lifecycle hooks of a child.
*
* It needs to be implemented by a component that is designed to handle the
* error properly.
*/
catchError?(error?: Error): void;
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Mount the component to a target element.
*
* This should only be done if the component was created manually. Components
* created declaratively in templates are managed by the Owl system.
*
* Note that a component can be mounted an unmounted several times
*/
async mount(target: HTMLElement | DocumentFragment): Promise<void> {
const __owl__ = this.__owl__;
if (__owl__.isMounted) {
return Promise.resolve();
}
if (!(target instanceof HTMLElement || target instanceof DocumentFragment)) {
let message = `Component '${this.constructor.name}' cannot be mounted: the target is not a valid DOM node.`;
message += `\nMaybe the DOM is not ready yet? (in that case, you can use owl.utils.whenReady)`;
throw new Error(message);
}
const fiber = new Fiber(null, this, false, target);
fiber.shouldPatch = false;
if (!__owl__.vnode) {
this.__prepareAndRender(fiber);
} else {
this.__render(fiber);
}
return scheduler.addFiber(fiber);
}
/**
* The unmount method is the opposite of the mount method. It is useful
* to call willUnmount calls and remove the component from the DOM.
*/
unmount() {
if (this.__owl__.isMounted) {
this.__callWillUnmount();
this.el!.remove();
}
}
/**
* The render method is the main entry point to render a component (once it
* is ready. This method is not initially called when the component is
* rendered the first time).
*
* This method will cause all its sub components to potentially rerender
* themselves. Note that `render` is not called if a component is updated via
* its props.
*/
async render(force: boolean = false): Promise<void> {
const __owl__ = this.__owl__;
if (!__owl__.isMounted && !__owl__.currentFiber) {
// if we get here, this means that the component was either never mounted,
// or was unmounted and some state change triggered a render. Either way,
// we do not want to actually render anything in this case.
return;
}
if (__owl__.currentFiber && !__owl__.currentFiber.isRendered) {
return scheduler.addFiber(__owl__.currentFiber.root);
}
// if we aren't mounted at this point, it implies that there is a
// currentFiber that is already rendered (isRendered is true), so we are
// about to be mounted
const isMounted = __owl__.isMounted;
const fiber = new Fiber(null, this, force, null);
Promise.resolve().then(() => {
if (__owl__.isMounted || !isMounted) {
// we are mounted (__owl__.isMounted), or if we are currently being
// mounted (!isMounted), so we call __render
this.__render(fiber);
} else {
// we were mounted when render was called, but we aren't anymore, so we
// were actually about to be unmounted ; we can thus forget about this
// fiber
fiber.isCompleted = true;
__owl__.currentFiber = null;
}
});
return scheduler.addFiber(fiber);
}
/**
* Destroy the component. This operation is quite complex:
* - it recursively destroy all children
* - call the willUnmount hooks if necessary
* - remove the dom node from the dom
*
* This should only be called manually if you created the component. Most
* components will be automatically destroyed.
*/
destroy() {
const __owl__ = this.__owl__;
if (!__owl__.isDestroyed) {
const el = this.el;
this.__destroy(__owl__.parent);
if (el) {
el.remove();
}
}
}
/**
* This method is called by the component system whenever its props are
* updated. If it returns true, then the component will be rendered.
* Otherwise, it will skip the rendering (also, its props will not be updated)
*/
shouldUpdate(nextProps: Props): boolean {
return true;
}
/**
* Emit a custom event of type 'eventType' with the given 'payload' on the
* component's el, if it exists. However, note that the event will only bubble
* up to the parent DOM nodes. Thus, it must be called between mounted() and
* willUnmount().
*/
trigger(eventType: string, payload?: any) {
if (this.el) {
const ev = new OwlEvent(this, eventType, {
bubbles: true,
cancelable: true,
detail: payload
});
this.el.dispatchEvent(ev);
}
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Private helper to perform a full destroy, from the point of view of an Owl
* component. It does not remove the el (this is done only once on the top
* level destroyed component, for performance reasons).
*
* The job of this method is mostly to call willUnmount hooks, and to perform
* all necessary internal cleanup.
*
* Note that it does not call the __callWillUnmount method to avoid visiting
* all children many times.
*/
__destroy(parent: Component<any, any> | null) {
const __owl__ = this.__owl__;
const isMounted = __owl__.isMounted;
if (isMounted) {
if (__owl__.willUnmountCB) {
__owl__.willUnmountCB();
}
this.willUnmount();
__owl__.isMounted = false;
}
const children = __owl__.children;
for (let key in children) {
children[key].__destroy(this);
}
if (parent) {
let id = __owl__.id;
delete parent.__owl__.children[id];
__owl__.parent = null;
}
__owl__.isDestroyed = true;
delete __owl__.vnode;
if (__owl__.currentFiber) {
__owl__.currentFiber.isCompleted = true;
}
}
__callMounted() {
const __owl__ = this.__owl__;
__owl__.isMounted = true;
__owl__.currentFiber = null;
this.mounted();
if (__owl__.mountedCB) {
__owl__.mountedCB();
}
}
__callWillUnmount() {
const __owl__ = this.__owl__;
if (__owl__.willUnmountCB) {
__owl__.willUnmountCB();
}
this.willUnmount();
__owl__.isMounted = false;
if (this.__owl__.currentFiber) {
this.__owl__.currentFiber.isCompleted = true;
this.__owl__.currentFiber.root.counter = 0;
}
const children = __owl__.children;
for (let id in children) {
const comp = children[id];
if (comp.__owl__.isMounted) {
comp.__callWillUnmount();
}
}
}
/**
* The __updateProps method is called by the t-component directive whenever
* it updates a component (so, when the parent template is rerendered).
*/
async __updateProps(nextProps: Props, parentFiber: Fiber, scope: any, vars: any): Promise<void> {
this.__owl__.scope = scope;
this.__owl__.vars = vars;
const shouldUpdate = parentFiber.force || this.shouldUpdate(nextProps);
if (shouldUpdate) {
const __owl__ = this.__owl__;
const fiber = new Fiber(parentFiber, this, parentFiber.force, null);
if (!parentFiber.child) {
parentFiber.child = fiber;
} else {
parentFiber.lastChild!.sibling = fiber;
}
parentFiber.lastChild = fiber;
const defaultProps = (<any>this.constructor).defaultProps;
if (defaultProps) {
this.__applyDefaultProps(nextProps, defaultProps);
}
if (QWeb.dev) {
QWeb.utils.validateProps(this.constructor, nextProps);
}
await Promise.all([
this.willUpdateProps(nextProps),
__owl__.willUpdatePropsCB && __owl__.willUpdatePropsCB(nextProps)
]);
if (fiber.isCompleted) {
return;
}
this.props = nextProps;
this.__render(fiber);
}
}
/**
* Main patching method. We call the virtual dom patch method here to convert
* a virtual dom vnode into some actual dom.
*/
__patch(vnode: VNode) {
const __owl__ = this.__owl__;
const target = __owl__.vnode || document.createElement(vnode.sel!);
__owl__.vnode = patch(target, vnode);
}
/**
* The __prepare method is only called by the t-component directive, when a
* subcomponent is created. It gets its scope and vars, if any, from the
* parent template.
*/
__prepare(parentFiber: Fiber, scope: any, vars: any) {
this.__owl__.scope = scope;
this.__owl__.vars = vars;
const fiber = new Fiber(parentFiber, this, parentFiber.force, null);
fiber.shouldPatch = false;
if (!parentFiber.child) {
parentFiber.child = fiber;
} else {
parentFiber.lastChild!.sibling = fiber;
}
parentFiber.lastChild = fiber;
return this.__prepareAndRender(fiber);
}
__getTemplate(qweb: QWeb): string {
let p = (<any>this).constructor;
if (!p.hasOwnProperty("_template")) {
// here, the component and none of its superclasses defines a static `template`
// key. So we fall back on looking for a template matching its name (or
// one of its subclass).
let template: string;
while ((template = p.name) && !(template in qweb.templates) && p !== Component) {
p = p.__proto__;
}
if (p === Component) {
throw new Error(`Could not find template for component "${this.constructor.name}"`);
} else {
p._template = template;
}
}
return p._template;
}
async __prepareAndRender(fiber: Fiber) {
try {
await Promise.all([this.willStart(), this.__owl__.willStartCB && this.__owl__.willStartCB()]);
} catch (e) {
fiber.handleError(e);
return Promise.resolve();
}
if (this.__owl__.isDestroyed) {
return Promise.resolve();
}
if (!fiber.isCompleted) {
this.__render(fiber);
}
}
__render(fiber: Fiber) {
const __owl__ = this.__owl__;
if (__owl__.observer) {
__owl__.observer.allowMutations = false;
}
let error;
try {
let vnode = __owl__.renderFn!(this, {
handlers: __owl__.boundHandlers,
fiber: fiber
});
// we iterate over the children to detect those that no longer belong to the
// current rendering: those ones, if not mounted yet, can (and have to) be
// destroyed right now, because they are not in the DOM, and thus we won't
// be notified later on (when patching), that they are removed from the DOM
for (let childKey in __owl__.children) {
let child = __owl__.children[childKey];
if (!child.__owl__.isMounted && child.__owl__.parentLastFiberId < fiber.id) {
child.destroy();
}
}
if (!vnode) {
throw new Error(`Rendering '${this.constructor.name}' did not return anything`);
}
fiber.vnode = vnode;
// we apply here the class information described on the component by the
// template (so, something like <MyComponent class="..."/>) to the actual
// root vnode
if (__owl__.classObj) {
const data = vnode.data!;
data.class = Object.assign(data.class || {}, __owl__.classObj);
}
} catch (e) {
error = e;
}
if (__owl__.observer) {
__owl__.observer.allowMutations = true;
}
fiber.root.counter--;
fiber.isRendered = true;
if (error) {
fiber.handleError(error);
}
}
/**
* Only called by qweb t-component directive (when t-keepalive is set)
*/
__remount() {
const __owl__ = this.__owl__;
if (!__owl__.isMounted) {
__owl__.isMounted = true;
this.mounted();
}
}
/**
* Apply default props (only top level).
*
* Note that this method does modify in place the props
*/
__applyDefaultProps(props: Object, defaultProps: Object) {
for (let propName in defaultProps) {
if (props![propName] === undefined) {
props![propName] = defaultProps[propName];
}
}
}
}
+470
View File
@@ -0,0 +1,470 @@
import { QWeb } from "../qweb/index";
import { INTERP_REGEXP } from "../qweb/compilation_context";
import { MODS_CODE } from "../qweb/extensions";
//------------------------------------------------------------------------------
// t-component
//------------------------------------------------------------------------------
const T_COMPONENT_MODS_CODE = Object.assign({}, MODS_CODE, {
self: "if (e.target !== vn.elm) {return}"
});
QWeb.utils.defineProxy = function defineProxy(target, source) {
for (let k in source) {
Object.defineProperty(target, k, {
get() {
return source[k];
},
set(val) {
source[k] = val;
}
});
}
};
/**
* The t-component directive is certainly a complicated and hard to maintain piece
* of code. To help you, fellow developer, if you have to maintain it, I offer
* you this advice: Good luck...
*
* Since it is not 'direct' code, but rather code that generates other code, it
* is not easy to understand. To help you, here is a detailed and commented
* explanation of the code generated by the t-component directive for the following
* situation:
* ```xml
* <Child
* t-key="'somestring'"
* flag="state.flag"
* t-transition="fade"/>
* ```
*
* ```js
* // we assign utils on top of the function because it will be useful for
* // each components
* let utils = this.utils;
*
* // this is the virtual node representing the parent div
* let c1 = [], p1 = { key: 1 };
* var vn1 = h("div", p1, c1);
*
* // t-component directive: we start by evaluating the expression given by t-key:
* let key5 = "somestring";
*
* // def3 is the promise that will contain later either the new component
* // creation, or the props update...
* let def3;
*
* // this is kind of tricky: we need here to find if the component was already
* // created by a previous rendering. This is done by checking the internal
* // `cmap` (children map) of the parent component: it maps keys to component ids,
* // and, then, if there is an id, we look into the children list to get the
* // instance
* let w4 =
* key5 in context.__owl__.cmap
* ? context.__owl__.children[context.__owl__.cmap[key5]]
* : false;
*
* // We keep the index of the position of the component in the closure. We push
* // null to reserve the slot, and will replace it later by the component vnode,
* // when it will be ready (do not forget that preparing/rendering a component is
* // asynchronous)
* let _2_index = c1.length;
* c1.push(null);
*
* // we evaluate here the props given to the component. It is done here to be
* // able to easily reference it later, and also, it might be an expensive
* // computation, so it is certainly better to do it only once
* let props4 = { flag: context["state"].flag };
*
* // If we have a component, currently rendering, but not ready yet, we do not want
* // to wait for it to be ready if we can avoid it
* if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) {
* // we check if the props are the same. In that case, we can simply reuse
* // the previous rendering and skip all useless work
* if (utils.shallowEqual(props4, w4.__owl__.renderProps)) {
* def3 = w4.__owl__.renderPromise;
* } else {
* // if the props are not the same, we destroy the component and starts anew.
* // this will be faster than waiting for its rendering, then updating it
* w4.destroy();
* w4 = false;
* }
* }
*
* if (!w4) {
* // in this situation, we need to create a new component. First step is
* // to get a reference to the class, then create an instance with
* // current context as parent, and the props.
* let W4 = context.component && context.components[componentKey4] || QWeb.component[componentKey4];
* if (!W4) {
* throw new Error("Cannot find the definition of component 'child'");
* }
* w4 = new W4(owner, props4);
*
* // Whenever we rerender the parent component, we need to be sure that we
* // are able to find the component instance. To do that, we register it to
* // the parent cmap (children map). Note that the 'template' key is
* // used here, since this is what identify the component from the template
* // perspective.
* context.__owl__.cmap[key5] = w4.__owl__.id;
*
* // __prepare is called, to basically call willStart, then render the
* // component
* def3 = w4.__prepare();
*
* def3 = def3.then(vnode => {
* // we create here a virtual node for the parent (NOT the component). This
* // means that the vdom of the parent will be stopped here, and from
* // the parent's perspective, it simply is a vnode with no children.
* // However, it shares the same dom element with the component root
* // vnode.
* let pvnode = h(vnode.sel, { key: key5 });
*
* // we add hooks to the parent vnode so we can interact with the new
* // component at the proper time
* pvnode.data.hook = {
* insert(vn) {
* // the __mount method will patch the component vdom into the elm vn.elm,
* // then call the mounted hooks. However, suprisingly, the snabbdom
* // patch method actually replace the elm by a new elm, so we need
* // to synchronise the pvnode elm with the resulting elm
* let nvn = w4.__mount(vnode, vn.elm);
* pvnode.elm = nvn.elm;
* // what follows is only present if there are animations on the component
* utils.transitionInsert(vn, "fade");
* },
* remove() {
* // override with empty function to prevent from removing the node
* // directly. It will be removed when destroy is called anyway, which
* // delays the removal if there are animations.
* },
* destroy() {
* // if there are animations, we delay the call to destroy on the
* // component, if not, we call it directly.
* let finalize = () => {
* w4.destroy();
* };
* utils.transitionRemove(vn, "fade", finalize);
* }
* };
* // the pvnode is inserted at the correct position in the div's children
* c1[_2_index] = pvnode;
*
* // we keep here a reference to the parent vnode (representing the
* // component, so we can reuse it later whenever we update the component
* w4.__owl__.pvnode = pvnode;
* });
* } else {
* // this is the 'update' path of the directive.
* // the call to __updateProps is the actual component update
* // Note that we only update the props if we cannot reuse the previous
* // rendering work (in the case it was rendered with the same props)
* def3 = def3 || w4.__updateProps(props4, extra.forceUpdate, extra.patchQueue);
* def3 = def3.then(() => {
* // if component was destroyed in the meantime, we do nothing (so, this
* // means that the parent's element children list will have a null in
* // the component's position, which will cause the pvnode to be removed
* // when it is patched.
* if (w4.__owl__.isDestroyed) {
* return;
* }
* // like above, we register the pvnode to the children list, so it
* // will not be patched out of the dom.
* let pvnode = w4.__owl__.pvnode;
* c1[_2_index] = pvnode;
* });
* }
*
* // we register the deferred here so the parent can coordinate its patch operation
* // with all the children.
* extra.promises.push(def3);
* return vn1;
* ```
*/
QWeb.addDirective({
name: "component",
extraNames: ["props"],
priority: 100,
atNodeEncounter({ ctx, value, node, qweb }): boolean {
ctx.addLine("//COMPONENT");
ctx.rootContext.shouldDefineOwner = true;
ctx.rootContext.shouldDefineQWeb = true;
ctx.rootContext.shouldDefineParent = true;
ctx.rootContext.shouldDefineUtils = true;
let hasDynamicProps = node.getAttribute("t-props") ? true : false;
// t-on- events and t-transition
const events: [string, string[], string, string][] = [];
let transition: string = "";
const attributes = (<Element>node).attributes;
const props: { [key: string]: string } = {};
for (let i = 0; i < attributes.length; i++) {
const name = attributes[i].name;
const value = attributes[i].textContent!;
if (name.startsWith("t-on-")) {
const [eventName, ...mods] = name.slice(5).split(".");
let extraArgs;
let handlerValue = value.replace(/\(.*\)/, function(args) {
extraArgs = args.slice(1, -1);
return "";
});
events.push([eventName, mods, handlerValue, extraArgs]);
} else if (name === "t-transition") {
transition = value;
} else if (!name.startsWith("t-")) {
if (name !== "class" && name !== "style") {
// this is a prop!
props[name] = ctx.formatExpression(value);
}
}
}
// computing the props string representing the props object
let propStr = Object.keys(props)
.map(k => k + ":" + props[k])
.join(",");
let defID = ctx.generateID();
let componentID = ctx.generateID();
const templateKey = ctx.generateTemplateKey();
let ref = node.getAttribute("t-ref");
let refExpr = "";
let refKey: string = "";
if (ref) {
ctx.rootContext.shouldDefineRefs = true;
refKey = `ref${ctx.generateID()}`;
ctx.addLine(`const ${refKey} = ${ctx.interpolate(ref)};`);
refExpr = `context.__owl__.refs[${refKey}] = w${componentID};`;
}
let finalizeComponentCode = `w${componentID}.destroy();`;
if (ref) {
finalizeComponentCode += `delete context.__owl__.refs[${refKey}];`;
}
if (transition) {
finalizeComponentCode = `let finalize = () => {
${finalizeComponentCode}
};
delete w${componentID}.__owl__.transitionInserted;
utils.transitionRemove(vn, '${transition}', finalize);`;
}
let createHook = "";
let classAttr = node.getAttribute("class");
let tattClass = node.getAttribute("t-att-class");
let styleAttr = node.getAttribute("style");
let tattStyle = node.getAttribute("t-att-style");
if (tattStyle) {
const attVar = `_${ctx.generateID()}`;
ctx.addLine(`const ${attVar} = ${ctx.formatExpression(tattStyle)};`);
tattStyle = attVar;
}
let classObj = "";
if (classAttr || tattClass || styleAttr || tattStyle || events.length) {
if (classAttr) {
let classDef = classAttr
.trim()
.split(/\s+/)
.map(a => `'${a}':true`)
.join(",");
classObj = `_${ctx.generateID()}`;
ctx.addLine(`let ${classObj} = {${classDef}};`);
}
if (tattClass) {
let tattExpr = ctx.formatExpression(tattClass);
if (tattExpr[0] !== "{" || tattExpr[tattExpr.length - 1] !== "}") {
tattExpr = `utils.toObj(${tattExpr})`;
}
if (classAttr) {
ctx.addLine(`Object.assign(${classObj}, ${tattExpr})`);
} else {
classObj = `_${ctx.generateID()}`;
ctx.addLine(`let ${classObj} = ${tattExpr};`);
}
}
let eventsCode = events
.map(function([eventName, mods, handlerValue, extraArgs]) {
let params = "owner";
if (extraArgs) {
if (ctx.loopNumber) {
let argId = ctx.generateID();
// we need to evaluate the arguments now, because the handler will
// be set asynchronously later when the widget is ready, and the
// context might be different.
ctx.addLine(`let arg${argId} = ${ctx.formatExpression(extraArgs)};`);
params = `owner, arg${argId}`;
} else {
params = `owner, ${ctx.formatExpression(extraArgs)}`;
}
}
let handler = `function (e) {`;
handler += mods
.map(function(mod) {
return T_COMPONENT_MODS_CODE[mod];
})
.join("");
if (handlerValue) {
handler += `const fn = owner['${handlerValue}'];`;
handler += `if (fn) { fn.call(${params}, e); } else { owner.${handlerValue}; }`;
}
handler += `}`;
return `vn.elm.addEventListener('${eventName}', ${handler});`;
})
.join("");
const styleExpr = tattStyle || (styleAttr ? `'${styleAttr}'` : false);
const styleCode = styleExpr ? `vn.elm.style = ${styleExpr};` : "";
createHook = `vnode.data.hook = {create(_, vn){${styleCode}${eventsCode}}};`;
}
ctx.addLine(
`let w${componentID} = ${templateKey} in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[${templateKey}]] : false;`
);
let shouldProxy = !ctx.parentNode;
if (shouldProxy) {
let id = ctx.generateID();
ctx.rootContext.rootNode = id;
shouldProxy = true;
ctx.rootContext.shouldDefineResult = true;
ctx.addLine(`let vn${id} = {};`);
ctx.addLine(`result = vn${id};`);
}
if (hasDynamicProps) {
const dynamicProp = ctx.formatExpression(node.getAttribute("t-props")!);
ctx.addLine(`let props${componentID} = Object.assign({${propStr}}, ${dynamicProp});`);
} else {
ctx.addLine(`let props${componentID} = {${propStr}};`);
}
ctx.addIf(
`w${componentID} && w${componentID}.__owl__.currentFiber && !w${componentID}.__owl__.vnode`
);
ctx.addLine(`w${componentID}.destroy();`);
ctx.addLine(`w${componentID} = false;`);
ctx.closeIf();
let registerCode = "";
if (shouldProxy) {
registerCode = `utils.defineProxy(vn${ctx.rootNode}, pvnode);`;
}
// SLOTS
const varDefs: string[] = [];
const hasSlots = node.childNodes.length;
if (hasSlots) {
ctx.rootContext.shouldTrackScope = true;
for (let v of Object.values(ctx.variables)) {
if (v["id"]) {
varDefs.push(v["id"]);
}
}
}
let scopeVars;
if (hasSlots) {
let scope = ctx.scopeVars.length ? `Object.assign({}, scope)` : `{}`;
let vars = varDefs.length ? `{${varDefs.join(",")}}` : "undefined";
scopeVars = `${scope}, ${vars}`;
} else {
scopeVars = "undefined, undefined";
}
ctx.addIf(`w${componentID}`);
// need to update component
let styleCode = "";
if (tattStyle) {
styleCode = `.then(()=>{if (w${componentID}.__owl__.isDestroyed) {return};w${componentID}.el.style=${tattStyle};});`;
}
ctx.addLine(
`w${componentID}.__updateProps(props${componentID}, extra.fiber${scopeVars &&
", " + scopeVars})${styleCode};`
);
ctx.addLine(`let pvnode = w${componentID}.__owl__.pvnode;`);
if (registerCode) {
ctx.addLine(registerCode);
}
if (ctx.parentNode) {
ctx.addLine(`c${ctx.parentNode}.push(pvnode);`);
}
ctx.addElse();
// new component
let dynamicFallback = "";
if (!value.match(INTERP_REGEXP)) {
dynamicFallback = `|| ${ctx.formatExpression(value)}`;
}
const interpValue = ctx.interpolate(value);
ctx.addLine(`let componentKey${componentID} = ${interpValue};`);
ctx.addLine(
`let W${componentID} = context.constructor.components[componentKey${componentID}] || QWeb.components[componentKey${componentID}]${dynamicFallback};`
);
// maybe only do this in dev mode...
ctx.addLine(
`if (!W${componentID}) {throw new Error('Cannot find the definition of component "' + componentKey${componentID} + '"')}`
);
ctx.addLine(`w${componentID} = new W${componentID}(parent, props${componentID});`);
if (transition) {
ctx.addLine(`const __patch${componentID} = w${componentID}.__patch;`);
ctx.addLine(
`w${componentID}.__patch = fiber => {__patch${componentID}.call(w${componentID}, fiber); if(!w${componentID}.__owl__.transitionInserted){w${componentID}.__owl__.transitionInserted = true;utils.transitionInsert(w${componentID}.__owl__.vnode, '${transition}');}};`
);
}
ctx.addLine(`parent.__owl__.cmap[${templateKey}] = w${componentID}.__owl__.id;`);
if (hasSlots) {
const clone = <Element>node.cloneNode(true);
const slotNodes = clone.querySelectorAll("[t-set]");
const slotId = QWeb.nextSlotId++;
ctx.addLine(`w${componentID}.__owl__.slotId = ${slotId};`);
if (slotNodes.length) {
for (let i = 0, length = slotNodes.length; i < length; i++) {
const slotNode = slotNodes[i];
slotNode.parentElement!.removeChild(slotNode);
const key = slotNode.getAttribute("t-set")!;
slotNode.removeAttribute("t-set");
const slotFn = qweb._compile(`slot_${key}_template`, slotNode, ctx);
QWeb.slots[`${slotId}_${key}`] = slotFn;
}
}
if (clone.childNodes.length) {
const t = clone.ownerDocument!.createElement("t");
for (let child of Object.values(clone.childNodes)) {
t.appendChild(child);
}
const slotFn = qweb._compile(`slot_default_template`, t, ctx);
QWeb.slots[`${slotId}_default`] = slotFn;
}
}
ctx.addLine(`let def${defID} = w${componentID}.__prepare(extra.fiber, ${scopeVars});`);
// hack: specify empty remove hook to prevent the node from being removed from the DOM
const insertHook = refExpr ? `insert(vn) {${refExpr}},` : "";
ctx.addLine(
`let pvnode = h('dummy', {key: ${templateKey}, hook: {${insertHook}remove() {},destroy(vn) {${finalizeComponentCode}}}});`
);
ctx.addLine(`const fiber = w${componentID}.__owl__.currentFiber;`);
ctx.addLine(
`def${defID}.then(function () { if (fiber.isCompleted) { return; } const vnode = fiber.vnode; pvnode.sel = vnode.sel; ${createHook}});`
);
if (registerCode) {
ctx.addLine(registerCode);
}
if (ctx.parentNode) {
ctx.addLine(`c${ctx.parentNode}.push(pvnode);`);
}
ctx.addLine(`w${componentID}.__owl__.pvnode = pvnode;`);
ctx.closeIf();
if (classObj) {
ctx.addLine(`w${componentID}.__owl__.classObj=${classObj};`);
}
ctx.addLine(`w${componentID}.__owl__.parentLastFiberId = extra.fiber.id;`);
return true;
}
});
+287
View File
@@ -0,0 +1,287 @@
import { h, VNode } from "../vdom/index";
import { Component } from "./component";
import { scheduler } from "./scheduler";
/**
* Owl Fiber Class
*
* Fibers are small abstractions designed to contain all the internal state
* associated with a "rendering work unit", relative to a specific component.
*
* A rendering will cause the creation of a fiber for each impacted components.
*
* Fibers capture all that necessary information, which is critical to owl
* asynchronous rendering pipeline. Fibers can be cancelled, can be in different
* states and in general determine the state of the rendering.
*/
export class Fiber {
static nextId: number = 1;
id: number = Fiber.nextId++;
// The force attribute determines if a rendering should bypass the `shouldUpdate`
// method potentially implemented by a component. It is usually set to false.
force: boolean;
// isCompleted means that the rendering corresponding to this fiber's work is
// done, either because the component has been mounted or patched, or because
// fiber has been cancelled.
isCompleted: boolean = false;
// the fibers corresponding to component updates (updateProps) need to call
// the willPatch and patched hooks from the corresponding component. However,
// fibers corresponding to a new component do not need to do that. So, the
// shouldPatch hook is the boolean that we check whenever we need to apply
// a patch.
shouldPatch: boolean = true;
// isRendered is the last state of a fiber. If true, this means that it has
// been rendered and is inert (so, it should not be taken into account when
// counting the number of active fibers).
isRendered: boolean = false;
// the counter number is a critical information. It is only necessary for a
// root fiber. For that fiber, this number counts the number of active sub
// fibers. When that number reaches 0, the fiber can be applied by the
// scheduler.
counter: number = 0;
target: HTMLElement | null;
scope: any;
vars: any;
component: Component<any, any>;
vnode: VNode | null = null;
root: Fiber;
child: Fiber | null = null;
sibling: Fiber | null = null;
lastChild: Fiber | null = null;
parent: Fiber | null = null;
error?: Error;
constructor(parent: Fiber | null, component: Component<any, any>, force, target) {
this.component = component;
this.force = force;
this.target = target;
const __owl__ = component.__owl__;
this.scope = __owl__.scope;
this.vars = __owl__.vars;
this.root = parent ? parent.root : this;
this.parent = parent;
let oldFiber = __owl__.currentFiber;
if (oldFiber && !oldFiber.isCompleted) {
if (oldFiber.root === oldFiber && !parent) {
// both oldFiber and this fiber are root fibers
this._reuseFiber(oldFiber);
return oldFiber;
} else {
this._remapFiber(oldFiber);
}
}
this.root.counter++;
__owl__.currentFiber = this;
}
/**
* When the oldFiber is not completed yet, and both oldFiber and this fiber
* are root fibers, we want to reuse the oldFiber instead of creating a new
* one. Doing so will guarantee that the initiator(s) of those renderings will
* be notified (the promise will resolve) when the last rendering will be done.
*
* This function thus assumes that oldFiber is a root fiber.
*/
_reuseFiber(oldFiber: Fiber) {
oldFiber.cancel(); // cancel children fibers
oldFiber.isCompleted = false; // keep the root fiber alive
oldFiber.isRendered = false; // the fiber has to be re-rendered
if (oldFiber.child) {
// remove relation to children
oldFiber.child.parent = null;
oldFiber.child = null;
oldFiber.lastChild = null;
}
oldFiber.counter = 1; // re-initialize counter
oldFiber.id = Fiber.nextId++;
}
/**
* In some cases, a rendering initiated at some component can detect that it
* should be part of a larger rendering initiated somewhere up the component
* tree. In that case, it needs to cancel the previous rendering and
* remap itself as a part of the current parent rendering.
*/
_remapFiber(oldFiber: Fiber) {
oldFiber.cancel();
this.shouldPatch = oldFiber.shouldPatch;
if (oldFiber === oldFiber.root) {
oldFiber.counter++;
}
if (oldFiber.parent && !this.parent) {
// re-map links
this.parent = oldFiber.parent;
this.root = this.parent.root;
this.sibling = oldFiber.sibling;
if (this.parent.lastChild === oldFiber) {
this.parent.lastChild = this;
}
if (this.parent.child === oldFiber) {
this.parent.child = this;
} else {
let current = this.parent.child!;
while (true) {
if (current.sibling === oldFiber) {
current.sibling = this;
break;
}
current = current.sibling!;
}
}
}
}
/**
* This function has been taken from
* https://medium.com/react-in-depth/the-how-and-why-on-reacts-usage-of-linked-list-in-fiber-67f1014d0eb7
*/
_walk(doWork: (f: Fiber) => Fiber | null) {
let root = this;
let current: Fiber = this;
while (true) {
const child = doWork(current);
if (child) {
current = child;
continue;
}
if (current === root) {
return;
}
while (!current.sibling) {
if (!current.parent || current.parent === root) {
return;
}
current = current.parent;
}
current = current.sibling;
}
}
/**
* Successfully complete the work of the fiber: call the mount or patch hooks
* and patch the DOM. This function is called once the fiber and its children
* are ready, and the scheduler decides to process it.
*/
complete() {
let component = this.component;
this.isCompleted = true;
if (!this.target && !component.__owl__.isMounted) {
return;
}
// build patchQueue
const patchQueue: Fiber[] = [];
const doWork: (Fiber) => Fiber | null = function(f) {
patchQueue.push(f);
return f.child;
};
this._walk(doWork);
const patchLen = patchQueue.length;
// call willPatch hook on each fiber of patchQueue
for (let i = 0; i < patchLen; i++) {
const fiber = patchQueue[i];
if (fiber.shouldPatch) {
component = fiber.component;
if (component.__owl__.willPatchCB) {
component.__owl__.willPatchCB();
}
component.willPatch();
}
}
// call __patch on each fiber of (reversed) patchQueue
for (let i = patchLen - 1; i >= 0; i--) {
const fiber = patchQueue[i];
component = fiber.component;
component.__patch(fiber.vnode!);
if (!fiber.shouldPatch && (!fiber.target || i !== 0)) {
component.__owl__.pvnode!.elm = component.__owl__.vnode!.elm;
}
component.__owl__.currentFiber = null;
}
// insert into the DOM (mount case)
let inDOM = false;
if (this.target) {
this.target.appendChild(this.component.el!);
inDOM = document.body.contains(this.target);
}
// call patched/mounted hook on each fiber of (reversed) patchQueue
for (let i = patchLen - 1; i >= 0; i--) {
const fiber = patchQueue[i];
component = fiber.component;
if (fiber.shouldPatch && !this.target) {
component.patched();
if (component.__owl__.patchedCB) {
component.__owl__.patchedCB();
}
} else if (this.target ? inDOM : true) {
component.__callMounted();
}
}
}
/**
* Cancel a fiber and all its children.
*/
cancel() {
this._walk(f => {
if (!f.isRendered) {
f.root.counter--;
}
f.isCompleted = true;
return f.child;
});
}
/**
* This is the global error handler for errors occurring in Owl main lifecycle
* methods. Caught errors are triggered on the QWeb instance, and are
* potentially given to some parent component which implements `catchError`.
*
* If there are no such component, we destroy everything. This is better than
* being in a corrupted state.
*/
handleError(error: Error) {
let component = this.component;
this.vnode = component.__owl__.vnode || h("div");
const qweb = component.env.qweb;
let root = component;
let canCatch = false;
while (component && !(canCatch = !!component.catchError)) {
root = component;
component = component.__owl__.parent!;
}
qweb.trigger("error", error);
if (canCatch) {
component.catchError!(error);
} else {
// the 3 next lines aim to mark the root fiber as being in error, and
// to force it to end, without waiting for its children
this.root.counter = 0;
this.root.error = error;
scheduler.flush();
root.destroy();
}
}
}
+115
View File
@@ -0,0 +1,115 @@
import { QWeb } from "../qweb/index";
//------------------------------------------------------------------------------
// Prop validation helper
//------------------------------------------------------------------------------
/**
* Validate the component props (or next props) against the (static) props
* description. This is potentially an expensive operation: it may needs to
* visit recursively the props and all the children to check if they are valid.
* This is why it is only done in 'dev' mode.
*/
QWeb.utils.validateProps = function(Widget, props: Object) {
const propsDef = (<any>Widget).props;
if (propsDef instanceof Array) {
// list of strings (prop names)
for (let i = 0, l = propsDef.length; i < l; i++) {
const propName = propsDef[i];
if (propName[propName.length - 1] === "?") {
// optional prop
break;
}
if (!(propName in props)) {
throw new Error(`Missing props '${propsDef[i]}' (component '${Widget.name}')`);
}
}
for (let key in props) {
if (!propsDef.includes(key) && !propsDef.includes(key + "?")) {
throw new Error(`Unknown prop '${key}' given to component '${Widget.name}'`);
}
}
} else if (propsDef) {
// propsDef is an object now
for (let propName in propsDef) {
if (props[propName] === undefined) {
if (propsDef[propName] && !propsDef[propName].optional) {
throw new Error(`Missing props '${propName}' (component '${Widget.name}')`);
} else {
break;
}
}
let isValid;
try {
isValid = isValidProp(props[propName], propsDef[propName]);
} catch (e) {
e.message = `Invalid prop '${propName}' in component ${Widget.name} (${e.message})`;
throw e;
}
if (!isValid) {
throw new Error(`Invalid Prop '${propName}' in component '${Widget.name}'`);
}
}
for (let propName in props) {
if (!(propName in propsDef)) {
throw new Error(`Unknown prop '${propName}' given to component '${Widget.name}'`);
}
}
}
};
/**
* Check if an invidual prop value matches its (static) prop definition
*/
function isValidProp(prop, propDef): boolean {
if (propDef === true) {
return true;
}
if (typeof propDef === "function") {
// Check if a value is constructed by some Constructor. Note that there is a
// slight abuse of language: we want to consider primitive values as well.
//
// So, even though 1 is not an instance of Number, we want to consider that
// it is valid.
if (typeof prop === "object") {
return prop instanceof propDef;
}
return typeof prop === propDef.name.toLowerCase();
} else if (propDef instanceof Array) {
// If this code is executed, this means that we want to check if a prop
// matches at least one of its descriptor.
let result = false;
for (let i = 0, iLen = propDef.length; i < iLen; i++) {
result = result || isValidProp(prop, propDef[i]);
}
return result;
}
// propsDef is an object
if (propDef.optional && prop === undefined) {
return true;
}
let result = propDef.type ? isValidProp(prop, propDef.type) : true;
if (propDef.validate) {
result = result && propDef.validate(prop);
}
if (propDef.type === Array && propDef.element) {
for (let i = 0, iLen = prop.length; i < iLen; i++) {
result = result && isValidProp(prop[i], propDef.element);
}
}
if (propDef.type === Object && propDef.shape) {
const shape = propDef.shape;
for (let key in shape) {
result = result && isValidProp(prop[key], shape[key]);
}
if (result) {
for (let propName in prop) {
if (!(propName in shape)) {
throw new Error(`unknown prop '${propName}'`);
}
}
}
}
return result;
}
+93
View File
@@ -0,0 +1,93 @@
import { Fiber } from "./fiber";
/**
* Owl Scheduler Class
*
* The scheduler is the part of Owl that will effectively apply a rendering
* whenever a fiber is ready.
*
* Briefly, it can be used to register root fibers. Whenever there is an
* active root fiber, it will poll continuously each animation frame (so, about
* once every 16ms) and whenever a root fiber is ready, it will apply it.
*/
interface Task {
fiber: Fiber;
callback: (err?: Error) => void;
}
export class Scheduler {
tasks: Task[] = [];
isRunning: boolean = false;
requestAnimationFrame: typeof window.requestAnimationFrame;
constructor(requestAnimationFrame) {
this.requestAnimationFrame = requestAnimationFrame;
}
addFiber(fiber): Promise<void> {
// if the fiber was remapped into a larger rendering fiber, it may not be a
// root fiber. But we only want to register root fibers
fiber = fiber.root;
return new Promise((resolve, reject) => {
if (fiber.error) {
return reject(fiber.error);
}
this.tasks.push({
fiber,
callback: () => {
if (fiber.error) {
return reject(fiber.error);
}
resolve();
}
});
if (!this.isRunning) {
this.scheduleTasks();
}
});
}
/**
* Process all current tasks. This only applies to the fibers that are ready.
* Other tasks are left unchanged.
*/
flush() {
let tasks = this.tasks;
this.tasks = [];
tasks = tasks.filter(task => {
if (task.fiber.isCompleted) {
task.callback();
return false;
}
if (task.fiber.counter === 0) {
if (!task.fiber.error) {
try {
task.fiber.complete();
} catch (e) {
task.fiber.handleError(e);
}
}
task.callback();
return false;
}
return true;
});
this.tasks = tasks.concat(this.tasks);
}
scheduleTasks() {
this.isRunning = true;
this.requestAnimationFrame(() => {
this.flush();
if (this.tasks.length > 0) {
this.scheduleTasks();
} else {
this.isRunning = false;
}
});
}
}
const raf = window.requestAnimationFrame.bind(window);
export const scheduler = new Scheduler(raf);
+30
View File
@@ -0,0 +1,30 @@
import { QWeb } from "./qweb/index";
/**
* This file creates and exports the OWL 'config' object, with keys:
* - 'mode': 'prod' or 'dev',
* - 'env': the environment to use in root components.
*/
interface Config {
mode: string;
}
export const config = {} as Config;
Object.defineProperty(config, "mode", {
get() {
return QWeb.dev ? "dev" : "prod";
},
set(mode: string) {
QWeb.dev = mode === "dev";
if (QWeb.dev) {
const url = `https://github.com/odoo/owl/blob/master/doc/reference/config.md#mode`;
console.warn(
`Owl is running in 'dev' mode. This is not suitable for production use. See ${url} for more information.`
);
} else {
console.log(`Owl is now running in 'prod' mode.`);
}
}
});
+148
View File
@@ -0,0 +1,148 @@
import { Component } from "./component/component";
import { scheduler } from "./component/scheduler";
import { EventBus } from "./core/event_bus";
import { Observer } from "./core/observer";
/**
* The `Context` object provides a way to share data between an arbitrary number
* of component. Usually, data is passed from a parent to its children component,
* but when we have to deal with some mostly global information, this can be
* annoying, since each component will need to pass the information to each
* children, even though some or most of them will not use the information.
*
* With a `Context` object, each component can subscribe (with the `useContext`
* hook) to its state, and will be updated whenever the context state is updated.
*/
function partitionBy<T>(arr: T[], fn: (t: T) => boolean) {
let lastGroup: T[] | false = false;
let lastValue;
return arr.reduce((acc: T[][], cur) => {
let curVal = fn(cur);
if (lastGroup) {
if (curVal === lastValue) {
lastGroup.push(cur);
} else {
lastGroup = false;
}
}
if (!lastGroup) {
lastGroup = [cur];
acc.push(lastGroup);
}
lastValue = curVal;
return acc;
}, []);
}
export class Context extends EventBus {
state: any;
observer: Observer;
rev: number = 1;
// mapping from component id to last observed context id
mapping: { [componentId: number]: number } = {};
constructor(state: Object = {}) {
super();
this.observer = new Observer();
this.observer.notifyCB = () => {
// notify components in the next microtask tick to ensure that subscribers
// are notified only once for all changes that occur in the same micro tick
let rev = this.rev;
return Promise.resolve().then(() => {
if (rev === this.rev) {
this.__notifyComponents();
}
});
};
this.state = this.observer.observe(state);
this.subscriptions.update = [];
}
/**
* Instead of using trigger to emit an update event, we actually implement
* our own function to do that. The reason is that we need to be smarter than
* a simple trigger function: we need to wait for parent components to be
* done before doing children components. More precisely, if an update
* as an effect of destroying a children, we do not want to call any code
* from the child, and certainly not render it.
*
* This method implements a simple grouping algorithm by depth. If we have
* connected components of depths [2, 4,4,4,4, 3,8,8], the Context will notify
* them in the following groups: [2], [4,4,4,4], [3], [8,8]. Each group will
* be updated sequentially, but each components in a given group will be done in
* parallel.
*
* This is a very simple algorithm, but it avoids checking if a given
* component is a child of another.
*/
async __notifyComponents() {
const rev = ++this.rev;
const subscriptions = this.subscriptions.update;
const groups = partitionBy(subscriptions, s => (s.owner ? s.owner.__owl__.depth : -1));
for (let group of groups) {
const proms = group.map(sub => sub.callback.call(sub.owner, rev));
// at this point, each component in the current group has registered a
// top level fiber in the scheduler. It could happen that rendering these
// components is done (if they have no children). This is why we manually
// flush the scheduler. This will force the scheduler to check
// immediately if they are done, which will cause their rendering
// promise to resolve earlier, which means that there is a chance of
// processing the next group in the same frame.
scheduler.flush();
await Promise.all(proms);
}
}
}
/**
* The`useContext` hook is the normal way for a component to register themselve
* to context state changes. The `useContext` method returns the context state
*/
export function useContext(ctx: Context): any {
const component: Component<any, any> = Component.current!;
return useContextWithCB(ctx, component, component.render.bind(component));
}
export function useContextWithCB(ctx: Context, component: Component<any, any>, method): any {
const __owl__ = component.__owl__;
const id = __owl__.id;
const mapping = ctx.mapping;
if (id in mapping) {
return ctx.state;
}
if (!__owl__.observer) {
__owl__.observer = new Observer();
__owl__.observer.notifyCB = component.render.bind(component);
}
const currentCB = __owl__.observer.notifyCB;
__owl__.observer.notifyCB = function() {
if (ctx.rev > mapping[id]) {
// in this case, the context has been updated since we were rendering
// last, and we do not need to render here with the observer. A
// rendering is coming anyway, with the correct props.
return;
}
currentCB();
};
mapping[id] = 0;
const renderFn = __owl__.renderFn;
__owl__.renderFn = function(comp, params) {
mapping[id] = ctx.rev;
return renderFn(comp, params);
};
ctx.on("update", component, async contextRev => {
if (mapping[id] < contextRev) {
mapping[id] = contextRev;
await method();
}
});
const __destroy = component.__destroy;
component.__destroy = parent => {
ctx.off("update", component);
delete mapping[id];
__destroy.call(component, parent);
};
return ctx.state;
}
+95
View File
@@ -0,0 +1,95 @@
/**
* Owl Observer
*
* This code contains the logic that allows Owl to observe and react to state
* changes.
*
* This is a Observer class that can observe any JS values. The way it works
* can be summarized thusly:
* - primitive values are not observed at all
* - Objects and arrays are observed by replacing them with a Proxy
* - each object/array metadata are tracked in a weakmap, and keep a revision
* number
*
* Note that this code is loosely inspired by Vue.
*/
//------------------------------------------------------------------------------
// Observer
//------------------------------------------------------------------------------
export class Observer {
rev: number = 1;
allowMutations: boolean = true;
dirty: boolean = false;
weakMap: WeakMap<any, any> = new WeakMap();
notifyCB() {}
observe<T>(value: T, parent?: any): T {
if (value === null || typeof value !== "object" || value instanceof Date) {
// fun fact: typeof null === 'object'
return value;
}
let metadata = this.weakMap.get(value) || this._observe(value, parent);
return metadata.proxy;
}
revNumber(value): number {
const metadata = this.weakMap.get(value);
return metadata ? metadata.rev : 0;
}
_observe(value, parent) {
var self = this;
const proxy = new Proxy(value, {
get(target, k) {
const targetValue = target[k];
return self.observe(targetValue, value);
},
set(target, key: string, newVal): boolean {
const value = target[key];
if (newVal !== value) {
if (!self.allowMutations) {
throw new Error(
`Observed state cannot be changed here! (key: "${key}", val: "${newVal}")`
);
}
self._updateRevNumber(target);
target[key] = newVal;
self.notifyCB();
}
return true;
},
deleteProperty(target, key) {
if (key in target) {
delete target[key];
self._updateRevNumber(target);
self.notifyCB();
}
return true;
}
});
const metadata = {
value,
proxy,
rev: this.rev,
parent
};
this.weakMap.set(value, metadata);
this.weakMap.set(metadata.proxy, metadata);
return metadata;
}
_updateRevNumber(target: any) {
this.rev++;
let metadata = this.weakMap.get(target);
let parent = target;
do {
metadata = this.weakMap.get(parent);
metadata.rev++;
} while ((parent = metadata.parent) && parent !== target);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Component } from "../component/component";
/**
* We define here OwlEvent, a subclass of CustomEvent, with an additional
* attribute:
* - originalComponent: the component that triggered the event
*/
export class OwlEvent<T> extends CustomEvent<T> {
originalComponent: Component<any, any>;
constructor(component, eventType, options) {
super(eventType, options);
this.originalComponent = component;
}
}
+133
View File
@@ -0,0 +1,133 @@
import { Component } from "./component/component";
import { Observer } from "./core/observer";
/**
* Owl Hook System
*
* This file introduces the concept of hooks, similar to React or Vue hooks.
* We have currently an implementation of:
* - useState (reactive state)
* - onMounted
* - onWillUnmount
* - useRef
*/
// -----------------------------------------------------------------------------
// useState
// -----------------------------------------------------------------------------
/**
* This is the main way a component can be made reactive. The useState hook
* will return an observed object (or array). Changes to that value will then
* trigger a rerendering of the current component.
*/
export function useState<T>(state: T): T {
const component: Component<any, any> = Component.current!;
const __owl__ = component.__owl__;
if (!__owl__.observer) {
__owl__.observer = new Observer();
__owl__.observer.notifyCB = component.render.bind(component);
}
return __owl__.observer.observe(state);
}
// -----------------------------------------------------------------------------
// Life cycle hooks
// -----------------------------------------------------------------------------
function makeLifecycleHook(method: string, reverse: boolean = false) {
if (reverse) {
return function(cb) {
const component: Component<any, any> = Component.current!;
if (component.__owl__[method]) {
const current = component.__owl__[method];
component.__owl__[method] = function() {
current.call(component);
cb.call(component);
};
} else {
component.__owl__[method] = cb;
}
};
} else {
return function(cb) {
const component: Component<any, any> = Component.current!;
if (component.__owl__[method]) {
const current = component.__owl__[method];
component.__owl__[method] = function() {
cb.call(component);
current.call(component);
};
} else {
component.__owl__[method] = cb;
}
};
}
}
function makeAsyncHook(method: string) {
return function(cb) {
const component: Component<any, any> = Component.current!;
if (component.__owl__[method]) {
const current = component.__owl__[method];
component.__owl__[method] = function(...args) {
return Promise.all[(current.call(component, ...args), cb.call(component, ...args))];
};
} else {
component.__owl__[method] = cb;
}
};
}
export const onMounted = makeLifecycleHook("mountedCB", true);
export const onWillUnmount = makeLifecycleHook("willUnmountCB");
export const onWillPatch = makeLifecycleHook("willPatchCB");
export const onPatched = makeLifecycleHook("patchedCB", true);
export const onWillStart = makeAsyncHook("willStartCB");
export const onWillUpdateProps = makeAsyncHook("willUpdatePropsCB");
// -----------------------------------------------------------------------------
// useRef
// -----------------------------------------------------------------------------
/**
* The purpose of this hook is to allow components to get a reference to a sub
* html node or component.
*/
interface Ref {
el: HTMLElement | null;
comp: Component<any, any> | null;
}
export function useRef(name: string): Ref {
const __owl__ = Component.current!.__owl__;
return {
get el(): HTMLElement | null {
const val = __owl__.refs && __owl__.refs[name];
if (val instanceof HTMLElement) {
return val;
} else if (val instanceof Component) {
return val.el;
}
return null;
},
get comp(): Component<any, any> | null {
const val = __owl__.refs && __owl__.refs[name];
return val instanceof Component ? val : null;
}
};
}
// -----------------------------------------------------------------------------
// useSubEnv
// -----------------------------------------------------------------------------
/**
* This hook is a simple way to let components use a sub environment. Note that
* like for all hooks, it is important that this is only called in the
* constructor method.
*/
export function useSubEnv(nextEnv) {
const component = Component.current!;
component.env = Object.assign(Object.create(component.env), nextEnv);
}
+29 -29
View File
@@ -4,36 +4,36 @@
*
* Note that dynamic values, such as a date or a commit hash are added by rollup
*/
export { Component } from "./component";
export { EventBus } from "./event_bus";
export { Observer } from "./observer";
// we need to import manually the extra directives so they can register
// themselves in QWeb, otherwise these files will not even be loaded.
import "./qweb_directives";
import "./qweb_extensions";
import { QWeb } from "./qweb_core";
export { QWeb };
export { Store, ConnectedComponent } from "./store";
import { EventBus } from "./core/event_bus";
import { Observer } from "./core/observer";
import { QWeb } from "./qweb/index";
import { config } from "./config";
import * as _store from "./store";
import * as _utils from "./utils";
import * as _tags from "./tags";
import { AsyncRoot } from "./misc/async_root";
import * as _hooks from "./hooks";
import * as _context from "./context";
import { Link } from "./router/link";
import { RouteComponent } from "./router/route_component";
import { Router } from "./router/router";
export const __info__ = {};
export { Component } from "./component/component";
export { QWeb };
export { config };
Object.defineProperty(__info__, "mode", {
get() {
return QWeb.dev ? "dev" : "prod";
},
set(mode: string) {
QWeb.dev = mode === "dev";
if (QWeb.dev) {
const url = `https://github.com/odoo/owl/blob/master/doc/tooling.md#development-mode`;
console.warn(
`Owl is running in 'dev' mode. This is not suitable for production use. See ${url} for more information.`
);
} else {
console.log(`Owl is now running in 'prod' mode.`);
}
}
});
export const Context = _context.Context;
export const useState = _hooks.useState;
export const core = { EventBus, Observer };
export const router = { Router, RouteComponent, Link };
export const Store = _store.Store;
export const utils = _utils;
export const tags = _tags;
export const misc = { AsyncRoot };
export const hooks = Object.assign({}, _hooks, {
useContext: _context.useContext,
useDispatch: _store.useDispatch,
useGetters: _store.useGetters,
useStore: _store.useStore
});
export const __info__ = {};
+19
View File
@@ -0,0 +1,19 @@
import { Component } from "../component/component";
import { xml } from "../tags";
/**
* AsyncRoot
*
* Owl is by default asynchronous, and the user interface will wait for all its
* subcomponents to be rendered before updating the DOM. This is most of the
* time what we want, but in some cases, it makes sense to "detach" a component
* from this coordination. This is the goal of the AsyncRoot component.
*/
export class AsyncRoot extends Component<any, any> {
static template = xml`<t t-slot="default"/>`;
async __updateProps(nextProps, parentFiber) {
this.render(parentFiber.force);
}
}
-194
View File
@@ -1,194 +0,0 @@
/**
* Owl Observer
*
* This code contains the logic that allows Owl to observe and react to state
* changes.
*
* This is a Observer class that can observe any JS values. The way it works
* can be summarized thusly:
* - primitive values are not observed at all
* - Objects are observed by replacing all their keys with getters/setters
* (recursively)
* - Arrays are observed by replacing their prototype with a customized version,
* which wrap some methods to allow the tracking of each state change.
*
* Note that this code is inspired by Vue.
*/
//------------------------------------------------------------------------------
// Modified Array prototype
//------------------------------------------------------------------------------
// we define here a new modified Array prototype, which basically override all
// Array methods that change some state to be able to track their changes
const methodsToPatch = ["push", "pop", "shift", "unshift", "splice", "sort", "reverse"];
const methodLen = methodsToPatch.length;
const ArrayProto = Array.prototype;
const ModifiedArrayProto = Object.create(ArrayProto);
for (let i = 0; i < methodLen; i++) {
const method = methodsToPatch[i];
const initialMethod = ArrayProto[method];
ModifiedArrayProto[method] = function(...args) {
if (!this.__observer__.allowMutations) {
throw new Error(`Array cannot be changed here")`);
}
this.__observer__.rev++;
this.__observer__.notifyChange();
this.__owl__.rev++;
let parent = this;
do {
parent.__owl__.deepRev++;
} while ((parent = parent.__owl__.parent));
let inserted;
switch (method) {
case "push":
case "unshift":
inserted = args;
break;
case "splice":
inserted = args.slice(2);
break;
}
if (inserted) {
for (let i = 0, iLen = inserted.length; i < iLen; i++) {
this.__observer__.observe(inserted[i], this);
}
}
return initialMethod.call(this, ...args);
};
}
//------------------------------------------------------------------------------
// Observer
//------------------------------------------------------------------------------
export class Observer {
rev: number = 1;
allowMutations: boolean = true;
dirty: boolean = false;
static set(target: any, key: number | string, value: any) {
if (!target.__owl__) {
throw Error("`Observer.set()` can only be called with observed Objects or Arrays");
}
target.__owl__.observer.set(target, key, value);
}
static delete(target: any, key: number | string) {
if (!target.__owl__) {
throw Error("`Observer.delete()` can only be called with observed Objects");
}
target.__owl__.observer.delete(target, key);
}
notifyCB() {}
notifyChange() {
this.dirty = true;
Promise.resolve().then(() => {
if (this.dirty) {
this.dirty = false;
this.notifyCB();
}
});
}
observe(value: any, parent?: any) {
if (value === null) {
// fun fact: typeof null === 'object'
return;
}
if (typeof value !== "object") {
return;
}
if ("__owl__" in value) {
// already observed
value.__owl__.parent = parent;
return;
}
if (Array.isArray(value)) {
this._observeArr(value, parent);
} else {
this._observeObj(value, parent);
}
}
set(target: any, key: number | string, value: any) {
let alreadyDefined =
key in target && Object.getOwnPropertyDescriptor(target, key)!.configurable === false;
if (alreadyDefined) {
target[key] = value;
} else {
this._addProp(target, key, value);
this._updateRevNumber(target);
}
this.notifyChange();
}
delete(target: any, key: number | string) {
delete target[key];
this._updateRevNumber(target);
this.notifyChange();
}
_observeObj<T extends { __owl__?: any }>(obj: T, parent?: any) {
obj.__owl__ = {
rev: this.rev,
deepRev: this.rev,
parent,
observer: this
};
Object.defineProperty(obj, "__owl__", { enumerable: false });
for (let key in obj) {
this._addProp(obj, key, obj[key]);
}
}
_observeArr(arr: Array<any>, parent?: any) {
(<any>arr).__owl__ = {
rev: this.rev,
deepRev: this.rev,
parent,
observer: this
};
Object.defineProperty(arr, "__owl__", { enumerable: false });
(<any>arr).__proto__ = Object.create(ModifiedArrayProto);
(<any>arr).__proto__.__observer__ = this;
for (let i = 0, iLen = arr.length; i < iLen; i++) {
this.observe(arr[i], arr);
}
}
_addProp<T extends { __owl__?: any }>(obj: T, key: string | number, value: any) {
var self = this;
Object.defineProperty(obj, key, {
configurable: true,
enumerable: true,
get() {
return value;
},
set(newVal) {
if (newVal !== value) {
if (!self.allowMutations) {
throw new Error(
`Observed state cannot be changed here! (key: "${key}", val: "${newVal}")`
);
}
self._updateRevNumber(obj);
value = newVal;
self.observe(newVal, obj);
self.notifyChange();
}
}
});
this.observe(value, obj);
}
_updateRevNumber(target: any) {
this.rev++;
target.__owl__.rev!++;
let parent = target;
do {
parent.__owl__.deepRev++;
} while ((parent = parent.__owl__.parent) && parent !== target);
}
}
@@ -1,5 +1,6 @@
import { Context, QWeb, UTILS } from "./qweb_core";
import { QWebExprVar } from "./qweb_expressions";
import { CompilationContext } from "./compilation_context";
import { QWeb } from "./qweb";
import { htmlToVDOM } from "../vdom/html_to_vdom";
/**
* Owl QWeb Directives
@@ -18,19 +19,24 @@ import { QWebExprVar } from "./qweb_expressions";
//------------------------------------------------------------------------------
// t-esc and t-raw
//------------------------------------------------------------------------------
(<any>UTILS).getFragment = function(str: string): DocumentFragment {
QWeb.utils.getFragment = function(str: string): DocumentFragment {
const temp = document.createElement("template");
temp.innerHTML = str;
return temp.content;
};
function compileValueNode(value: any, node: Element, qweb: QWeb, ctx: Context) {
if (value === "0" && ctx.caller) {
qweb._compileNode(ctx.caller, ctx);
return;
QWeb.utils.htmlToVDOM = htmlToVDOM;
function compileValueNode(value: any, node: Element, qweb: QWeb, ctx: CompilationContext) {
if (value === "0") {
const caller = ctx.getCaller();
if (caller) {
qweb._compileNode(caller, ctx.getInliningContext());
return;
}
}
if (value.xml instanceof NodeList) {
if (value.xml instanceof NodeList && !value.id) {
for (let node of Array.from(value.xml)) {
qweb._compileNode(<ChildNode>node, ctx);
}
@@ -54,22 +60,25 @@ function compileValueNode(value: any, node: Element, qweb: QWeb, ctx: Context) {
ctx.rootContext.rootNode = nodeID;
ctx.rootContext.parentTextNode = nodeID;
ctx.addLine(`var vn${nodeID} = {text: ${exprID}};`);
if (ctx.rootContext.shouldDefineResult) {
ctx.addLine(`result = vn${nodeID}`);
}
}
} else {
let fragID = ctx.generateID();
ctx.addLine(`var frag${fragID} = this.utils.getFragment(${exprID})`);
let tempNodeID = ctx.generateID();
ctx.addLine(`var p${tempNodeID} = {hook: {`);
ctx.addLine(` insert: n => n.elm.parentNode.replaceChild(frag${fragID}, n.elm),`);
ctx.addLine(`}};`);
ctx.addLine(`var vn${tempNodeID} = h('div', p${tempNodeID})`);
ctx.addLine(`c${ctx.parentNode}.push(vn${tempNodeID});`);
ctx.rootContext.shouldDefineUtils = true;
ctx.addLine(`c${ctx.parentNode}.push(...utils.htmlToVDOM(${exprID}));`);
}
if (node.childNodes.length) {
ctx.addElse();
qweb._compileChildren(node, ctx);
}
if (value.xml instanceof NodeList && value.id) {
ctx.addElse();
for (let node of Array.from(value.xml)) {
qweb._compileNode(<ChildNode>node, ctx);
}
}
ctx.closeIf();
}
@@ -77,10 +86,6 @@ QWeb.addDirective({
name: "esc",
priority: 70,
atNodeEncounter({ node, qweb, ctx }): boolean {
if (node.nodeName !== "t") {
let nodeID = qweb._compileGenericNode(node, ctx);
ctx = ctx.withParent(nodeID);
}
let value = ctx.getValue(node.getAttribute("t-esc")!);
compileValueNode(value, node, qweb, ctx.subContext("escaping", true));
return true;
@@ -91,10 +96,6 @@ QWeb.addDirective({
name: "raw",
priority: 80,
atNodeEncounter({ node, qweb, ctx }): boolean {
if (node.nodeName !== "t") {
let nodeID = qweb._compileGenericNode(node, ctx);
ctx = ctx.withParent(nodeID);
}
let value = ctx.getValue(node.getAttribute("t-raw")!);
compileValueNode(value, node, qweb, ctx);
return true;
@@ -111,22 +112,21 @@ QWeb.addDirective({
atNodeEncounter({ node, ctx }): boolean {
const variable = node.getAttribute("t-set")!;
let value = node.getAttribute("t-value")!;
ctx.variables[variable] = ctx.variables[variable] || {};
let qwebvar = ctx.variables[variable];
if (value) {
const formattedValue = ctx.formatExpression(value);
if (ctx.variables.hasOwnProperty(variable)) {
ctx.addLine(`${(<QWebExprVar>ctx.variables[variable]).id} = ${formattedValue}`);
if (ctx.variables.hasOwnProperty(variable) && qwebvar.id) {
ctx.addLine(`${qwebvar.id} = ${formattedValue}`);
} else {
const varName = `_${ctx.generateID()}`;
ctx.addLine(`var ${varName} = ${formattedValue};`);
ctx.variables[variable] = {
id: varName,
expr: formattedValue
};
qwebvar.id = varName;
qwebvar.expr = formattedValue;
}
} else {
ctx.variables[variable] = {
xml: node.childNodes
};
qwebvar.xml = node.childNodes;
}
return true;
}
@@ -140,7 +140,7 @@ QWeb.addDirective({
priority: 20,
atNodeEncounter({ node, ctx }): boolean {
let cond = ctx.getValue(node.getAttribute("t-if")!);
ctx.addIf(`${ctx.formatExpression(cond)}`);
ctx.addIf(typeof cond === "string" ? ctx.formatExpression(cond) : cond.id!);
return false;
},
finalize({ ctx }) {
@@ -153,7 +153,7 @@ QWeb.addDirective({
priority: 30,
atNodeEncounter({ node, ctx }): boolean {
let cond = ctx.getValue(node.getAttribute("t-elif")!);
ctx.addLine(`else if (${ctx.formatExpression(cond)}) {`);
ctx.addLine(`else if (${typeof cond === "string" ? ctx.formatExpression(cond) : cond.id}) {`);
ctx.indent();
return false;
},
@@ -194,30 +194,67 @@ QWeb.addDirective({
nodeCopy.removeAttribute("t-call");
// extract variables from nodecopy
const tempCtx = new Context();
tempCtx.nextID = ctx.rootContext.nextID;
const tempCtx = new CompilationContext();
tempCtx.allowMultipleRoots = true;
qweb._compileNode(nodeCopy, tempCtx);
const vars = Object.assign({}, ctx.variables, tempCtx.variables);
ctx.rootContext.nextID = tempCtx.nextID;
const templateMap = Object.create(ctx.templates);
// open new scope, if necessary
const hasNewVariables = Object.keys(tempCtx.variables).length > 0;
// compile sub template
let subCtx = ctx.subContext("caller", nodeCopy).subContext("variables", Object.create(vars));
subCtx = subCtx.subContext("templates", templateMap);
if (templateMap[subTemplate]) {
// OUCH, IT IS A RECURSIVE TEMPLATE SITUATION...
// This is a tricky situation... We obviously cannot inline the compiled
// template. So, what we need to do is to compile it, and make sure we
// properly transfer everything from the current scope to the sub template.
ctx.rootContext.shouldTrackScope = true;
ctx.rootContext.shouldDefineOwner = true;
let subTemplateName;
if (ctx.hasParentWidget) {
subTemplateName = ctx.templateName;
} else {
subTemplateName = `__${ctx.generateID()}`;
subCtx.variables = {};
let id = 0;
for (let v in vars) {
subCtx.variables[v] = vars[v];
(vars[v] as any).id = `_v${id++}`;
}
const subTemplateFn = qweb._compile(subTemplateName, nodeTemplate.elem, subCtx);
qweb.recursiveFns[subTemplateName] = subTemplateFn;
}
let varCode = `{}`;
if (Object.keys(vars).length) {
let id = 0;
const content = Object.values(vars)
.map((v: any) => `_v${id++}: ${v.expr}`)
.join(",");
varCode = `{${content}}`;
}
ctx.addLine(
`this.recursiveFns['${subTemplateName}'].call(this, context, Object.assign({}, extra, {parentNode: c${ctx.parentNode}, fiber: {vars: ${varCode}, scope}}));`
);
return true;
}
templateMap[subTemplate] = true;
if (hasNewVariables) {
ctx.addLine("{");
ctx.indent();
// add new variables, if any
for (let key in tempCtx.variables) {
const v = tempCtx.variables[key];
if ((<QWebExprVar>v).expr) {
ctx.addLine(`let ${(<QWebExprVar>v).id} = ${(<QWebExprVar>v).expr};`);
if (v.expr) {
ctx.addLine(`let ${v.id} = ${v.expr};`);
}
// todo: handle XML variables...
}
}
// compile sub template
const subCtx = ctx.subContext("caller", nodeCopy).subContext("variables", Object.create(vars));
qweb._compileNode(nodeTemplate.elem, subCtx);
// close new scope
@@ -239,7 +276,7 @@ QWeb.addDirective({
priority: 10,
atNodeEncounter({ node, qweb, ctx }): boolean {
ctx.rootContext.shouldProtectContext = true;
ctx = ctx.subContext("inLoop", true);
ctx = ctx.subContext("loopNumber", ctx.loopNumber + 1);
const elems = node.getAttribute("t-foreach")!;
const name = node.getAttribute("t-as")!;
let arrayID = ctx.generateID();
@@ -253,35 +290,26 @@ QWeb.addDirective({
ctx.addLine(`_${valuesID} = Object.values(_${arrayID});`);
ctx.closeIf();
ctx.addLine(`var _length${keysID} = _${keysID}.length;`);
ctx.addLine(`for (let i = 0; i < _length${keysID}; i++) {`);
const loopVar = `i${ctx.loopNumber}`;
ctx.addLine(`for (let ${loopVar} = 0; ${loopVar} < _length${keysID}; ${loopVar}++) {`);
ctx.indent();
ctx.addToScope(name + '_first', 'i === 0');
ctx.addToScope(name + '_last', `i === _length${keysID} - 1`);
ctx.addToScope(name + '_index', 'i');
ctx.addToScope(name, `_${keysID}[i]`);
ctx.addToScope(name + '_value', `_${valuesID}[i]`);
ctx.addToScope(name + "_first", `${loopVar} === 0`);
ctx.addToScope(name + "_last", `${loopVar} === _length${keysID} - 1`);
ctx.addToScope(name + "_index", loopVar);
ctx.addToScope(name, `_${keysID}[${loopVar}]`);
ctx.addToScope(name + "_value", `_${valuesID}[${loopVar}]`);
const nodeCopy = <Element>node.cloneNode(true);
let shouldWarn = nodeCopy.tagName !== "t" && !nodeCopy.hasAttribute("t-key");
if (!shouldWarn && node.tagName === "t") {
if (node.hasAttribute("t-component") && !node.hasAttribute("t-key")) {
shouldWarn = true;
}
if (
!shouldWarn &&
node.children.length === 1 &&
node.children[0].tagName !== "t" &&
!node.children[0].hasAttribute("t-key")
) {
shouldWarn = true;
}
}
let shouldWarn =
!nodeCopy.hasAttribute("t-key") &&
node.children.length === 1 &&
node.children[0].tagName !== "t" &&
!node.children[0].hasAttribute("t-key");
if (shouldWarn) {
console.warn(
`Directive t-foreach should always be used with a t-key! (in template: '${
ctx.templateName
}')`
`Directive t-foreach should always be used with a t-key! (in template: '${ctx.templateName}')`
);
}
nodeCopy.removeAttribute("t-foreach");
qweb._compileNode(nodeCopy, ctx);
ctx.dedent();
+228
View File
@@ -0,0 +1,228 @@
import { compileExpr, QWebVar } from "./expression_parser";
export const INTERP_REGEXP = /\{\{.*?\}\}/g;
//------------------------------------------------------------------------------
// Compilation Context
//------------------------------------------------------------------------------
export class CompilationContext {
static nextID: number = 1;
code: string[] = [];
variables: { [key: string]: QWebVar } = {};
escaping: boolean = false;
parentNode: number | null = null;
parentTextNode: number | null = null;
rootNode: number | null = null;
indentLevel: number = 0;
rootContext: CompilationContext;
caller: Element | undefined;
shouldDefineOwner: boolean = false;
shouldDefineParent: boolean = false;
shouldDefineQWeb: boolean = false;
shouldDefineUtils: boolean = false;
shouldDefineRefs: boolean = false;
shouldDefineResult: boolean = true;
shouldProtectContext: boolean = false;
shouldTrackScope: boolean = false;
loopNumber: number = 0;
inPreTag: boolean = false;
templateName: string;
allowMultipleRoots: boolean = false;
hasParentWidget: boolean = false;
scopeVars: any[] = [];
currentKey: string = "";
templates: { [key: string]: boolean } = {};
callingLevel: number = 0;
inliningLevel: number = 0;
constructor(name?: string) {
this.rootContext = this;
this.templateName = name || "noname";
this.templates[this.templateName] = true;
this.addLine("var h = this.h;");
}
generateID(): number {
return CompilationContext.nextID++;
}
/**
* This method generates a "template key", which is basically a unique key
* which depends on the currently set keys, and on the iteration numbers (if
* we are in a loop).
*
* Such a key is necessary when we need to associate an id to some element
* generated by a template (for example, a component)
*/
generateTemplateKey(): string {
const id = this.generateID();
let locationExpr = `\`__${this.generateID()}__`;
for (let i = 0; i < this.loopNumber - 1; i++) {
locationExpr += `\${i${i + 1}}__`;
}
if (this.currentKey) {
const k = this.currentKey;
this.addLine(`let k${id} = ${locationExpr}\` + ${k};`);
} else {
locationExpr += this.loopNumber ? `\${i${this.loopNumber}}__\`` : "`";
this.addLine(`let k${id} = ${locationExpr};`);
}
return `k${id}`;
}
generateCode(): string[] {
const shouldTrackScope = this.shouldTrackScope && this.scopeVars.length;
if (shouldTrackScope) {
// add some vars to scope if needed
for (let scopeVar of this.scopeVars.reverse()) {
let { index, key, indent } = scopeVar;
const prefix = new Array(indent + 2).join(" ");
this.code.splice(index + 1, 0, prefix + `scope.${key} = context.${key};`);
}
this.code.unshift(" const scope = Object.create(null);");
}
if (this.shouldProtectContext) {
this.code.unshift(" context = Object.create(context);");
}
if (this.shouldDefineResult) {
this.code.unshift(" let result;");
}
if (this.shouldDefineRefs) {
this.code.unshift(" context.__owl__.refs = context.__owl__.refs || {};");
}
if (this.shouldDefineOwner) {
// this is necessary to prevent some directives (t-forach for ex) to
// pollute the rendering context by adding some keys in it.
this.code.unshift(" let owner = context;");
}
if (this.shouldDefineParent) {
if (this.hasParentWidget) {
this.code.unshift(" let parent = extra.parent;");
} else {
this.code.unshift(" let parent = context;");
}
}
if (this.shouldDefineQWeb) {
this.code.unshift(" let QWeb = this.constructor;");
}
if (this.shouldDefineUtils) {
this.code.unshift(" let utils = this.constructor.utils;");
}
return this.code;
}
withParent(node: number): CompilationContext {
if (
!this.allowMultipleRoots &&
this === this.rootContext &&
(this.parentNode || this.parentTextNode)
) {
throw new Error("A template should not have more than one root node");
}
if (!this.rootContext.rootNode) {
this.rootContext.rootNode = node;
}
if (!this.parentNode && this.rootContext.shouldDefineResult) {
this.addLine(`result = vn${node};`);
}
return this.subContext("parentNode", node);
}
subContext(key: keyof CompilationContext, value: any): CompilationContext {
const newContext = Object.create(this);
newContext[key] = value;
if (key === "caller") {
newContext.callingLevel++;
newContext.inliningLevel++;
}
return newContext;
}
indent() {
this.indentLevel++;
}
dedent() {
this.indentLevel--;
}
addLine(line: string): number {
const prefix = new Array(this.indentLevel + 2).join(" ");
this.code.push(prefix + line);
return this.code.length - 1;
}
addToScope(key: string, expr: string) {
const index = this.addLine(`context.${key} = ${expr};`);
this.rootContext.scopeVars.push({ index, key, indent: this.indentLevel });
}
addIf(condition: string) {
this.addLine(`if (${condition}) {`);
this.indent();
}
addElse() {
this.dedent();
this.addLine("} else {");
this.indent();
}
closeIf() {
this.dedent();
this.addLine("}");
}
/**
* Recursively (inverse) fetches the `caller` of a context
* Useful to determine to which t-call a t-raw="0" refers
*/
getCaller(targetLevel?: number): Element | null {
if (targetLevel === undefined) {
targetLevel = this.inliningLevel;
}
if (targetLevel === this.callingLevel) {
return this.caller || null;
}
const proto = (this as any).__proto__;
return proto ? proto.getCaller(targetLevel) : null;
}
/**
* Marks the context with the current recursive level
* in which we are for inlining archs (t-raw="0")
*/
getInliningContext(): CompilationContext {
return this.subContext("inliningLevel", this.inliningLevel - 1);
}
getValue(val: any): QWebVar | string {
return val in this.variables ? this.getValue(this.variables[val]) : val;
}
/**
* Prepare an expression for being consumed at render time. Its main job
* is to
* - replace unknown variables by a lookup in the context
* - replace already defined variables by their internal name
*/
formatExpression(expr: string): string {
return compileExpr(expr, this.variables);
}
/**
* Perform string interpolation on the given string. Note that if the whole
* string is an expression, it simply returns it (formatted and enclosed in
* parentheses).
* For instance:
* 'Hello {{x}}!' -> `Hello ${x}`
* '{{x ? 'a': 'b'}}' -> (x ? 'a' : 'b')
*/
interpolate(s: string): string {
let matches = s.match(INTERP_REGEXP);
if (matches && matches[0].length === s.length) {
return `(${this.formatExpression(s.slice(2, -2))})`;
}
let r = s.replace(/\{\{.*?\}\}/g, s => "${" + this.formatExpression(s.slice(2, -2)) + "}");
return "`" + r + "`";
}
}
@@ -25,7 +25,7 @@
// Misc types, constants and helpers
//------------------------------------------------------------------------------
const RESERVED_WORDS = "true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,this,typeof,eval,void,Math,RegExp,Array,Object,Date".split(
const RESERVED_WORDS = "true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,this,eval,void,Math,RegExp,Array,Object,Date".split(
","
);
@@ -38,17 +38,12 @@ const WORD_REPLACEMENT = {
lte: "<="
};
export interface QWebExprVar {
id: string;
expr: string;
export interface QWebVar {
id?: string;
expr?: string;
xml?: NodeList;
}
export interface QWebXMLVar {
xml: NodeList;
}
export type QWebVar = QWebExprVar | QWebXMLVar;
//------------------------------------------------------------------------------
// Tokenizer
//------------------------------------------------------------------------------
@@ -82,7 +77,9 @@ const STATIC_TOKEN_MAP: { [key: string]: TKind } = {
")": "RIGHT_PAREN"
};
const OPERATORS = ".,===,==,+,!==,!=,!,||,&&,>=,>,<=,<,?,-,*,/,%".split(",");
// note that the space after typeof is relevant. It makes sure that the formatted
// expression has a space after typeof
const OPERATORS = ".,===,==,+,!==,!=,!,||,&&,>=,>,<=,<,?,-,*,/,%,typeof ".split(",");
type Tokenizer = (expr: string) => Token | false;
@@ -165,9 +162,9 @@ const tokenizeOperator: Tokenizer = function(expr) {
const TOKENIZERS = [
tokenizeString,
tokenizeNumber,
tokenizeOperator,
tokenizeSymbol,
tokenizeStatic,
tokenizeOperator
tokenizeStatic
];
/**
@@ -252,7 +249,7 @@ export function compileExpr(expr: string, vars: { [key: string]: QWebVar }): str
}
if (isVar) {
if (token.value in vars && "id" in vars[token.value]) {
token.value = (<QWebExprVar>vars[token.value]).id;
token.value = vars[token.value].id!;
} else {
token.value = `context['${token.value}']`;
}
+289
View File
@@ -0,0 +1,289 @@
import { VNode } from "../vdom/index";
import { QWeb } from "./qweb";
/**
* Owl QWeb Extensions
*
* This file contains the implementation of non standard QWeb directives, added
* by Owl and that will only work on Owl projects:
*
* - t-on
* - t-ref
* - t-transition
* - t-mounted
* - t-slot
* - t-model
*/
//------------------------------------------------------------------------------
// t-on
//------------------------------------------------------------------------------
// these are pieces of code that will be injected into the event handler if
// modifiers are specified
export const MODS_CODE = {
prevent: "e.preventDefault();",
self: "if (e.target !== this.elm) {return}",
stop: "e.stopPropagation();"
};
QWeb.addDirective({
name: "on",
priority: 90,
atNodeCreation({ ctx, fullName, value, nodeID }) {
ctx.rootContext.shouldDefineOwner = true;
const [eventName, ...mods] = fullName.slice(5).split(".");
if (!eventName) {
throw new Error("Missing event name with t-on directive");
}
let extraArgs;
let handlerName = value.replace(/\(.*\)/, function(args) {
extraArgs = args.slice(1, -1);
return "";
});
let params = extraArgs ? `owner, ${ctx.formatExpression(extraArgs)}` : "owner";
let handler = `function (e) {`;
handler += mods
.map(function(mod) {
return MODS_CODE[mod];
})
.join("");
if (handlerName) {
if (!extraArgs) {
handler += `const fn = context['${handlerName}'];`;
handler += `if (fn) { fn.call(${params}, e); } else { context.${handlerName}; }`;
handler += `}`;
ctx.addLine(
`extra.handlers['${eventName}' + ${nodeID}] = extra.handlers['${eventName}' + ${nodeID}] || ${handler};`
);
ctx.addLine(`p${nodeID}.on['${eventName}'] = extra.handlers['${eventName}' + ${nodeID}];`);
} else {
const handlerKey = `handler${ctx.generateID()}`;
ctx.addLine(
`const ${handlerKey} = context['${handlerName}'] && context['${handlerName}'].bind(${params});`
);
handler += `if (${handlerKey}) { ${handlerKey}(e); } else { context.${value}; }`;
handler += `}`;
ctx.addLine(`p${nodeID}.on['${eventName}'] = ${handler};`);
}
} else {
handler += "}";
ctx.addLine(`p${nodeID}.on['${eventName}'] = ${handler};`);
}
}
});
//------------------------------------------------------------------------------
// t-ref
//------------------------------------------------------------------------------
QWeb.addDirective({
name: "ref",
priority: 95,
atNodeCreation({ ctx, value, addNodeHook }) {
ctx.rootContext.shouldDefineRefs = true;
const refKey = `ref${ctx.generateID()}`;
ctx.addLine(`const ${refKey} = ${ctx.interpolate(value)};`);
addNodeHook("create", `context.__owl__.refs[${refKey}] = n.elm;`);
addNodeHook("destroy", `delete context.__owl__.refs[${refKey}];`);
}
});
//------------------------------------------------------------------------------
// t-transition
//------------------------------------------------------------------------------
QWeb.utils.nextFrame = function(cb: () => void) {
requestAnimationFrame(() => requestAnimationFrame(cb));
};
QWeb.utils.transitionInsert = function(vn: VNode, name: string) {
const elm = <HTMLElement>vn.elm;
// remove potential duplicated vnode that is currently being removed, to
// prevent from having twice the same node in the DOM during an animation
const dup = elm.parentElement && elm.parentElement!.querySelector(`*[data-owl-key='${vn.key}']`);
if (dup) {
dup.remove();
}
elm.classList.add(name + "-enter");
elm.classList.add(name + "-enter-active");
elm.classList.remove(name + "-leave-active");
elm.classList.remove(name + "-leave-to");
const finalize = () => {
elm.classList.remove(name + "-enter-active");
elm.classList.remove(name + "-enter-to");
};
this.nextFrame(() => {
elm.classList.remove(name + "-enter");
elm.classList.add(name + "-enter-to");
whenTransitionEnd(elm, finalize);
});
};
QWeb.utils.transitionRemove = function(vn: VNode, name: string, rm: () => void) {
const elm = <HTMLElement>vn.elm;
elm.setAttribute("data-owl-key", vn.key!);
elm.classList.add(name + "-leave");
elm.classList.add(name + "-leave-active");
const finalize = () => {
if (!elm.classList.contains(name + "-leave-active")) {
return;
}
elm.classList.remove(name + "-leave-active");
elm.classList.remove(name + "-leave-to");
rm();
};
this.nextFrame(() => {
elm.classList.remove(name + "-leave");
elm.classList.add(name + "-leave-to");
whenTransitionEnd(elm, finalize);
});
};
function getTimeout(delays: Array<string>, durations: Array<string>): number {
/* istanbul ignore next */
while (delays.length < durations.length) {
delays = delays.concat(delays);
}
return Math.max.apply(
null,
durations.map((d, i) => {
return toMs(d) + toMs(delays[i]);
})
);
}
// Old versions of Chromium (below 61.0.3163.100) formats floating pointer numbers
// in a locale-dependent way, using a comma instead of a dot.
// If comma is not replaced with a dot, the input will be rounded down (i.e. acting
// as a floor function) causing unexpected behaviors
function toMs(s: string): number {
return Number(s.slice(0, -1).replace(",", ".")) * 1000;
}
function whenTransitionEnd(elm: HTMLElement, cb) {
const styles = window.getComputedStyle(elm);
const delays: Array<string> = (styles.transitionDelay || "").split(", ");
const durations: Array<string> = (styles.transitionDuration || "").split(", ");
const timeout: number = getTimeout(delays, durations);
if (timeout > 0) {
elm.addEventListener("transitionend", cb, { once: true });
} else {
cb();
}
}
QWeb.addDirective({
name: "transition",
priority: 96,
atNodeCreation({ ctx, value, addNodeHook }) {
ctx.rootContext.shouldDefineUtils = true;
let name = value;
const hooks = {
insert: `utils.transitionInsert(vn, '${name}');`,
remove: `utils.transitionRemove(vn, '${name}', rm);`
};
for (let hookName in hooks) {
addNodeHook(hookName, hooks[hookName]);
}
}
});
//------------------------------------------------------------------------------
// t-slot
//------------------------------------------------------------------------------
QWeb.addDirective({
name: "slot",
priority: 80,
atNodeEncounter({ ctx, value }): boolean {
const slotKey = ctx.generateID();
ctx.rootContext.shouldDefineOwner = true;
ctx.addLine(
`const slot${slotKey} = this.constructor.slots[context.__owl__.slotId + '_' + '${value}'];`
);
ctx.addIf(`slot${slotKey}`);
let parentNode = `c${ctx.parentNode}`;
if (!ctx.parentNode) {
ctx.rootContext.shouldDefineResult = true;
ctx.rootContext.shouldDefineUtils = true;
parentNode = `children${ctx.generateID()}`;
ctx.addLine(`let ${parentNode}= []`);
ctx.addLine(`result = {}`);
}
ctx.addLine(
`slot${slotKey}.call(this, context.__owl__.parent, Object.assign({}, extra, {parentNode: ${parentNode}, vars: extra.vars, parent: extra.parent || owner}));`
);
if (!ctx.parentNode) {
ctx.addLine(`utils.defineProxy(result, ${parentNode}[0]);`);
}
ctx.closeIf();
return true;
}
});
//------------------------------------------------------------------------------
// t-model
//------------------------------------------------------------------------------
QWeb.utils.toNumber = function(val: string): number | string {
const n = parseFloat(val);
return isNaN(n) ? val : n;
};
QWeb.addDirective({
name: "model",
priority: 42,
atNodeCreation({ ctx, nodeID, value, node, fullName, addNodeHook }) {
const type = node.getAttribute("type");
let handler;
let event = fullName.includes(".lazy") ? "change" : "input";
// we keep here a reference to the "base expression" (if the expression
// is `t-model="some.expr.value", then the base expression is "some.expr").
// This is necessary so we can capture it in the handler closure.
let expr = ctx.formatExpression(value);
const index = expr.lastIndexOf(".");
const baseExpr = expr.slice(0, index);
ctx.addLine(`let expr${nodeID} = ${baseExpr};`);
expr = `expr${nodeID}.${expr.slice(index + 1)}`;
const key = ctx.generateTemplateKey();
if (node.tagName === "select") {
ctx.addLine(`p${nodeID}.props = {value: ${expr}};`);
addNodeHook("create", `n.elm.value=${expr};`);
event = "change";
handler = `(ev) => {${expr} = ev.target.value}`;
} else if (type === "checkbox") {
ctx.addLine(`p${nodeID}.props = {checked: ${expr}};`);
handler = `(ev) => {${expr} = ev.target.checked}`;
} else if (type === "radio") {
const nodeValue = node.getAttribute("value")!;
ctx.addLine(`p${nodeID}.props = {checked:${expr} === '${nodeValue}'};`);
handler = `(ev) => {${expr} = ev.target.value}`;
event = "click";
} else {
ctx.addLine(`p${nodeID}.props = {value: ${expr}};`);
const trimCode = fullName.includes(".trim") ? ".trim()" : "";
let valueCode = `ev.target.value${trimCode}`;
if (fullName.includes(".number")) {
ctx.rootContext.shouldDefineUtils = true;
valueCode = `utils.toNumber(${valueCode})`;
}
handler = `(ev) => {${expr} = ${valueCode}}`;
}
ctx.addLine(`extra.handlers[${key}] = extra.handlers[${key}] || (${handler});`);
ctx.addLine(`p${nodeID}.on['${event}'] = extra.handlers[${key}];`);
}
});
//------------------------------------------------------------------------------
// t-key
//------------------------------------------------------------------------------
QWeb.addDirective({
name: "key",
priority: 45,
atNodeEncounter({ ctx, value }) {
let id = ctx.generateID();
ctx.addLine(`const nodeKey${id} = ${ctx.formatExpression(value)};`);
ctx.currentKey = `nodeKey${id}`;
}
});
+4
View File
@@ -0,0 +1,4 @@
import "./base_directives";
import "./extensions";
export { CompiledTemplate, QWeb } from "./qweb";
+178 -244
View File
@@ -1,6 +1,8 @@
import { VNode, h, patch } from "./vdom";
import { QWebVar, compileExpr } from "./qweb_expressions";
import { EventBus } from "./event_bus";
import { EventBus } from "../core/event_bus";
import { h, patch, VNode } from "../vdom/index";
import { CompilationContext } from "./compilation_context";
import { shallowEqual } from "../utils";
import { addNS } from "../vdom/vdom";
/**
* Owl QWeb Engine
@@ -34,7 +36,7 @@ interface Template {
interface CompilationInfo {
node: Element;
qweb: QWeb;
ctx: Context;
ctx: CompilationContext;
fullName: string;
value: string;
}
@@ -55,39 +57,36 @@ export interface Directive {
finalize?(info: CompilationInfo): void;
}
interface QWebConfig {
templates?: string;
translateFn?(text: string): string;
}
//------------------------------------------------------------------------------
// Const/global stuff/helpers
//------------------------------------------------------------------------------
const DISABLED_TAGS = ["input", "textarea", "button", "select", "option", "optgroup"];
const TRANSLATABLE_ATTRS = ["label", "title", "placeholder", "alt"];
const lineBreakRE = /[\r\n]/;
const whitespaceRE = /\s+/g;
const DIRECTIVE_NAMES = {
name: 1,
att: 1,
attf: 1,
key: 1
};
const DIRECTIVES: Directive[] = [];
const NODE_HOOKS_PARAMS = {
create: "(_, n)",
insert: "vn",
remove: "(vn, rm)"
remove: "(vn, rm)",
destroy: "()"
};
interface Utils {
h: typeof h;
toObj(expr: any): Object;
shallowEqual(p1: Object, p2: Object): boolean;
[key: string]: any;
}
export const UTILS: Utils = {
h: h,
const UTILS: Utils = {
toObj(expr) {
if (typeof expr === "string") {
expr = expr.trim();
@@ -103,18 +102,15 @@ export const UTILS: Utils = {
}
return expr;
},
shallowEqual(p1, p2) {
for (let k in p1) {
if (p1[k] !== p2[k]) {
return false;
}
}
return true;
shallowEqual,
addNameSpace(vnode) {
addNS(vnode.data, vnode.children, vnode.sel);
}
};
function parseXML(xml: string): Document {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, "text/xml");
if (doc.getElementsByTagName("parsererror").length) {
let msg = "Invalid XML in template.";
@@ -142,52 +138,88 @@ function parseXML(xml: string): Document {
return doc;
}
let nextID = 1;
//------------------------------------------------------------------------------
// QWeb rendering engine
//------------------------------------------------------------------------------
export class QWeb extends EventBus {
templates: { [name: string]: Template } = {};
utils = UTILS;
templates: { [name: string]: Template };
static utils = UTILS;
static components = Object.create(null);
static DIRECTIVE_NAMES: { [key: string]: 1 } = {
name: 1,
att: 1,
attf: 1,
translation: 1
};
static DIRECTIVES: Directive[] = [];
static TEMPLATES: { [name: string]: Template } = {};
static nextId: number = 1;
h = h;
// dev mode enables better error messages or more costly validations
static dev: boolean = false;
// the id field is useful to be able to hash qweb instances. The current
// use case is that component's templates are qweb dependant, and need to be
// able to map a qweb instance to a template name.
id = nextID++;
// slots contains sub templates defined with t-set inside t-component nodes, and
// are meant to be used by the t-slot directive.
slots = {};
nextSlotId = 1;
static slots = {};
static nextSlotId = 1;
constructor(data?: string) {
// recursiveTemplates contains sub templates called with t-call, but which
// ends up in recursive situations. This is very similar to the slot situation,
// as in we need to propagate the scope.
recursiveFns = {};
isUpdating: boolean = false;
translateFn?: QWebConfig["translateFn"];
constructor(config: QWebConfig = {}) {
super();
if (data) {
this.addTemplates(data);
this.templates = Object.create(QWeb.TEMPLATES);
if (config.templates) {
this.addTemplates(config.templates);
}
if (config.translateFn) {
this.translateFn = config.translateFn;
}
}
static addDirective(directive: Directive) {
DIRECTIVES.push(directive);
DIRECTIVE_NAMES[directive.name] = 1;
DIRECTIVES.sort((d1, d2) => d1.priority - d2.priority);
if (directive.name in QWeb.DIRECTIVE_NAMES) {
throw new Error(`Directive "${directive.name} already registered`);
}
QWeb.DIRECTIVES.push(directive);
QWeb.DIRECTIVE_NAMES[directive.name] = 1;
QWeb.DIRECTIVES.sort((d1, d2) => d1.priority - d2.priority);
if (directive.extraNames) {
directive.extraNames.forEach(n => (DIRECTIVE_NAMES[n] = 1));
directive.extraNames.forEach(n => (QWeb.DIRECTIVE_NAMES[n] = 1));
}
}
static register(name: string, Component: any) {
static registerComponent(name: string, Component: any) {
if (QWeb.components[name]) {
throw new Error(`Component '${name}' has already been registered`);
}
QWeb.components[name] = Component;
}
/**
* Register globally a template. All QWeb instances will obtain their
* templates from their own template map, and then, from the global static
* TEMPLATES property.
*/
static registerTemplate(name: string, template: string) {
if (QWeb.TEMPLATES[name]) {
throw new Error(`Template '${name}' has already been registered`);
}
const qweb = new QWeb();
qweb.addTemplate(name, template);
QWeb.TEMPLATES[name] = qweb.templates[name];
}
/**
* Add a template to the internal template map. Note that it is not
* immediately compiled.
@@ -204,12 +236,12 @@ export class QWeb extends EventBus {
}
/**
* Load templates from a xml (as a string). This will look up for the first
* <templates> tag, and will consider each child of this as a template, with
* the name given by the t-name attribute.
* Load templates from a xml (as a string or xml document). This will look up
* for the first <templates> tag, and will consider each child of this as a
* template, with the name given by the t-name attribute.
*/
addTemplates(xmlstr: string) {
const doc = parseXML(xmlstr);
addTemplates(xmlstr: string | Document) {
const doc = typeof xmlstr === "string" ? parseXML(xmlstr) : xmlstr;
const templates = doc.getElementsByTagName("templates")[0];
if (!templates) {
return;
@@ -227,7 +259,7 @@ export class QWeb extends EventBus {
this._processTemplate(elem);
const template = {
elem,
fn: (context, extra) => {
fn: function(this: QWeb, context, extra) {
const compiledFunction = this._compile(name, elem);
template.fn = compiledFunction;
return compiledFunction.call(this, context, extra);
@@ -295,8 +327,8 @@ export class QWeb extends EventBus {
* to render a full component tree, since this is an asynchronous operation.
* This method can only render templates without components.
*/
renderToString(name: string, context: EvalContext = {}): string {
const vnode = this.render(name, context);
renderToString(name: string, context: EvalContext = {}, extra?: any): string {
const vnode = this.render(name, context, extra);
if (vnode.sel === undefined) {
return vnode.text!;
}
@@ -305,26 +337,46 @@ export class QWeb extends EventBus {
return (<HTMLElement>result.elm).outerHTML;
}
_compile(name: string, elem: Element, parentContext?: Context): CompiledTemplate {
/**
* Force all widgets connected to this QWeb instance to rerender themselves.
*
* This method is mostly useful for external code that want to modify the
* application in some cases. For example, a router plugin.
*/
forceUpdate() {
this.isUpdating = true;
Promise.resolve().then(() => {
if (this.isUpdating) {
this.isUpdating = false;
this.trigger("update");
}
});
}
_compile(name: string, elem: Element, parentContext?: CompilationContext): CompiledTemplate {
const isDebug = elem.attributes.hasOwnProperty("t-debug");
const ctx = new Context(name);
const ctx = new CompilationContext(name);
if (elem.tagName !== "t") {
ctx.shouldDefineResult = false;
}
if (parentContext) {
ctx.templates = Object.create(parentContext.templates);
ctx.variables = Object.create(parentContext.variables);
ctx.nextID = parentContext.parentNode! + 1;
ctx.parentNode = parentContext.parentNode!;
ctx.parentNode = parentContext.parentNode || ctx.generateID();
ctx.allowMultipleRoots = true;
ctx.hasParentWidget = true;
ctx.shouldDefineResult = false;
ctx.addLine(`let c${ctx.parentNode} = extra.parentNode;`);
for (let v in parentContext.variables) {
let variable = <any>parentContext.variables[v];
if (variable.id) {
ctx.addLine(`let ${variable.id} = extra.vars.${variable.id}`);
ctx.addLine(`let ${variable.id} = extra.fiber.vars.${variable.id}`);
}
}
}
if (parentContext) {
ctx.addLine(" Object.assign(context, extra.scope);");
ctx.addLine(" Object.assign(context, extra.fiber.scope);");
}
this._compileNode(elem, ctx);
@@ -367,7 +419,7 @@ export class QWeb extends EventBus {
* Generate code from an xml node
*
*/
_compileNode(node: ChildNode, ctx: Context) {
_compileNode(node: ChildNode, ctx: CompilationContext) {
if (!(node instanceof Element)) {
// this is a text node, there are no directive to apply
let text = node.textContent!;
@@ -377,8 +429,17 @@ export class QWeb extends EventBus {
}
text = text.replace(whitespaceRE, " ");
}
if (this.translateFn) {
if ((node.parentNode as any).getAttribute("t-translation") !== "off") {
text = this.translateFn(text);
}
}
if (ctx.parentNode) {
ctx.addLine(`c${ctx.parentNode}.push({text: \`${text}\`});`);
if (node.nodeType === 3) {
ctx.addLine(`c${ctx.parentNode}.push({text: \`${text}\`});`);
} else if (node.nodeType === 8) {
ctx.addLine(`c${ctx.parentNode}.push(h('!', \`${text}\`));`);
}
} else if (ctx.parentTextNode) {
ctx.addLine(`vn${ctx.parentTextNode}.text += \`${text}\`;`);
} else {
@@ -386,17 +447,26 @@ export class QWeb extends EventBus {
// template rendering.
let nodeID = ctx.generateID();
ctx.addLine(`var vn${nodeID} = {text: \`${text}\`};`);
ctx.addLine(`result = vn${nodeID};`);
ctx.rootContext.rootNode = nodeID;
ctx.rootContext.parentTextNode = nodeID;
}
return;
}
if (ctx !== ctx.rootContext) {
ctx = ctx.subContext("currentKey", ctx.currentKey);
}
const firstLetter = node.tagName[0];
if (firstLetter === firstLetter.toUpperCase()) {
// this is a component, we modify in place the xml document to change
// <SomeComponent ... /> to <t t-component="SomeComponent" ... />
node.setAttribute("t-component", node.tagName);
} else if (node.tagName !== "t" && node.hasAttribute("t-component")) {
throw new Error(
`Directive 't-component' can only be used on <t> nodes (used on a <${node.tagName}>)`
);
}
const attributes = (<Element>node).attributes;
@@ -406,7 +476,7 @@ export class QWeb extends EventBus {
fullName: string;
}[] = [];
let withHandlers = false;
const finalizers: typeof validDirectives = [];
// maybe this is not optimal: we iterate on all attributes here, and again
// just after for each directive.
@@ -414,16 +484,26 @@ export class QWeb extends EventBus {
let attrName = attributes[i].name;
if (attrName.startsWith("t-")) {
let dName = attrName.slice(2).split(/-|\./)[0];
if (!(dName in DIRECTIVE_NAMES)) {
if (!(dName in QWeb.DIRECTIVE_NAMES)) {
throw new Error(`Unknown QWeb directive: '${attrName}'`);
}
if (node.tagName !== "t" && (attrName === "t-esc" || attrName === "t-raw")) {
const tNode = document.createElement("t");
tNode.setAttribute(attrName, node.getAttribute(attrName)!);
for (let child of Array.from(node.childNodes)) {
tNode.appendChild(child);
}
node.appendChild(tNode);
node.removeAttribute(attrName);
}
}
}
const DIR_N = DIRECTIVES.length;
const DIR_N = QWeb.DIRECTIVES.length;
const ATTR_N = attributes.length;
let withHandlers = false;
for (let i = 0; i < DIR_N; i++) {
let directive = DIRECTIVES[i];
let directive = QWeb.DIRECTIVES[i];
let fullName;
let value;
for (let j = 0; j < ATTR_N; j++) {
@@ -442,7 +522,11 @@ export class QWeb extends EventBus {
}
}
}
for (let { directive, value, fullName } of validDirectives) {
if (directive.finalize) {
finalizers.push({ directive, value, fullName });
}
if (directive.atNodeEncounter) {
const isDone = directive.atNodeEncounter({
node,
@@ -452,6 +536,9 @@ export class QWeb extends EventBus {
value
});
if (isDone) {
for (let { directive, value, fullName } of finalizers) {
directive.finalize!({ node, qweb: this, ctx, fullName, value });
}
return;
}
}
@@ -497,15 +584,28 @@ export class QWeb extends EventBus {
}
this._compileChildren(node, ctx);
// svg support
// we hadd svg namespace if it is a svg or if it is a g, but only if it is
// the root node. This is the easiest way to support svg sub components:
// they need to have a g tag as root. Otherwise, we would need a complete
// list of allowed svg tags.
const shouldAddNS =
node.nodeName === "svg" || (node.nodeName === "g" && ctx.rootNode === ctx.parentNode);
if (shouldAddNS) {
ctx.rootContext.shouldDefineUtils = true;
ctx.addLine(`utils.addNameSpace(vn${ctx.parentNode});`);
}
for (let { directive, value, fullName } of validDirectives) {
if (directive.finalize) {
directive.finalize({ node, qweb: this, ctx, fullName, value });
}
for (let { directive, value, fullName } of finalizers) {
directive.finalize!({ node, qweb: this, ctx, fullName, value });
}
}
_compileGenericNode(node: ChildNode, ctx: Context, withHandlers: boolean = true): number {
_compileGenericNode(
node: ChildNode,
ctx: CompilationContext,
withHandlers: boolean = true
): number {
// nodeType 1 is generic tag
if (node.nodeType !== 1) {
throw new Error("unsupported node type");
@@ -540,7 +640,11 @@ export class QWeb extends EventBus {
for (let i = 0; i < attributes.length; i++) {
let name = attributes[i].name;
const value = attributes[i].textContent!;
let value = attributes[i].textContent!;
if (this.translateFn && TRANSLATABLE_ATTRS.includes(name)) {
value = this.translateFn(value);
}
// regular attributes
if (!name.startsWith("t-") && !(<Element>node).getAttribute("t-attf-" + name)) {
@@ -568,10 +672,11 @@ export class QWeb extends EventBus {
if (name.startsWith("t-att-")) {
let attName = name.slice(6);
const v = ctx.getValue(value);
let formattedValue = v.id || ctx.formatExpression(v);
let formattedValue = typeof v === "string" ? ctx.formatExpression(v) : v.id;
if (attName === "class") {
formattedValue = `this.utils.toObj(${formattedValue})`;
ctx.rootContext.shouldDefineUtils = true;
formattedValue = `utils.toObj(${formattedValue})`;
if (classObj) {
ctx.addLine(`Object.assign(${classObj}, ${formattedValue})`);
} else {
@@ -625,12 +730,7 @@ export class QWeb extends EventBus {
}
}
let nodeID = ctx.generateID();
let nodeKey: any = (<Element>node).getAttribute("t-key");
if (nodeKey) {
nodeKey = ctx.formatExpression(nodeKey);
} else {
nodeKey = nodeID;
}
let nodeKey = ctx.currentKey || nodeID;
const parts = [`key:${nodeKey}`];
if (attrs.length + tattrs.length > 0) {
parts.push(`attrs:{${attrs.join(",")}}`);
@@ -665,7 +765,7 @@ export class QWeb extends EventBus {
return nodeID;
}
_compileChildren(node: ChildNode, ctx: Context) {
_compileChildren(node: ChildNode, ctx: CompilationContext) {
if (node.childNodes.length > 0) {
for (let child of Array.from(node.childNodes)) {
this._compileNode(child, ctx);
@@ -673,169 +773,3 @@ export class QWeb extends EventBus {
}
}
}
//------------------------------------------------------------------------------
// Compilation Context
//------------------------------------------------------------------------------
export class Context {
nextID: number = 1;
code: string[] = [];
variables: { [key: string]: QWebVar } = {};
escaping: boolean = false;
parentNode: number | null = null;
parentTextNode: number | null = null;
rootNode: number | null = null;
indentLevel: number = 0;
rootContext: Context;
caller: Element | undefined;
shouldDefineOwner: boolean = false;
shouldDefineParent: boolean = false;
shouldDefineQWeb: boolean = false;
shouldDefineUtils: boolean = false;
shouldDefineResult: boolean = false;
shouldProtectContext: boolean = false;
shouldTrackScope: boolean = false;
inLoop: boolean = false;
inPreTag: boolean = false;
templateName: string;
allowMultipleRoots: boolean = false;
hasParentWidget: boolean = false;
scopeVars: any[] = [];
constructor(name?: string) {
this.rootContext = this;
this.templateName = name || "noname";
this.addLine("var h = this.utils.h;");
}
generateID(): number {
const id = this.rootContext.nextID++;
return id;
}
generateCode(): string[] {
const shouldTrackScope = this.shouldTrackScope && this.scopeVars.length;
if (shouldTrackScope) {
// add some vars to scope if needed
for (let scopeVar of this.scopeVars.reverse()) {
let { index, key, indent } = scopeVar;
const prefix = new Array(indent + 2).join(" ");
this.code.splice(index + 1, 0, prefix + `scope.${key} = context.${key};`);
}
this.code.unshift(" const scope = Object.create(null);");
}
if (this.shouldProtectContext) {
this.code.unshift(" context = Object.create(context);");
}
if (this.shouldDefineResult) {
this.code.unshift(" let result;");
}
if (this.shouldDefineOwner) {
// this is necessary to prevent some directives (t-forach for ex) to
// pollute the rendering context by adding some keys in it.
this.code.unshift(" let owner = context;");
}
if (this.shouldDefineParent) {
if (this.hasParentWidget) {
this.code.unshift(" let parent = extra.parent;");
} else {
this.code.unshift(" let parent = context;");
}
}
if (this.shouldDefineQWeb) {
this.code.unshift(" let QWeb = this.constructor;");
}
if (this.shouldDefineUtils) {
this.code.unshift(" let utils = this.utils;");
}
return this.code;
}
withParent(node: number): Context {
if (
!this.allowMultipleRoots &&
this === this.rootContext &&
(this.parentNode || this.parentTextNode)
) {
throw new Error("A template should not have more than one root node");
}
if (!this.rootContext.rootNode) {
this.rootContext.rootNode = node;
}
return this.subContext("parentNode", node);
}
subContext(key: keyof Context, value: any): Context {
const newContext = Object.create(this);
newContext[key] = value;
return newContext;
}
indent() {
this.indentLevel++;
}
dedent() {
this.indentLevel--;
}
addLine(line: string): number {
const prefix = new Array(this.indentLevel + 2).join(" ");
this.code.push(prefix + line);
return this.code.length - 1;
}
addToScope(key: string, expr: string) {
const index = this.addLine(`context.${key} = ${expr};`);
this.rootContext.scopeVars.push({ index, key, indent: this.indentLevel });
}
addIf(condition: string) {
this.addLine(`if (${condition}) {`);
this.indent();
}
addElse() {
this.dedent();
this.addLine("} else {");
this.indent();
}
closeIf() {
this.dedent();
this.addLine("}");
}
getValue(val: any): any {
return val in this.variables ? this.getValue(this.variables[val]) : val;
}
/**
* Prepare an expression for being consumed at render time. Its main job
* is to
* - replace unknown variables by a lookup in the context
* - replace already defined variables by their internal name
*/
formatExpression(expr: string): string {
return compileExpr(expr, this.variables);
}
/**
* Perform string interpolation on the given string. Note that if the whole
* string is an expression, it simply returns it (formatted and enclosed in
* parentheses).
* For instance:
* 'Hello {{x}}!' -> `Hello ${x}`
* '{{x ? 'a': 'b'}}' -> (x ? 'a' : 'b')
*/
interpolate(s: string): string {
let matches = s.match(/\{\{.*?\}\}/g);
if (matches && matches[0].length === s.length) {
return `(${this.formatExpression(s.slice(2, -2))})`;
}
let r = s.replace(/\{\{.*?\}\}/g, s => "${" + this.formatExpression(s.slice(2, -2)) + "}");
return "`" + r + "`";
}
}
-870
View File
@@ -1,870 +0,0 @@
import { QWeb, UTILS } from "./qweb_core";
import { VNode } from "./vdom";
/**
* Owl QWeb Extensions
*
* This file contains the implementation of non standard QWeb directives, added
* by Owl and that will only work on Owl projects:
*
* - t-on
* - t-ref
* - t-transition
* - t-component/t-keepalive
* - t-mounted
* - t-slot
* - t-model
*/
//------------------------------------------------------------------------------
// t-on
//------------------------------------------------------------------------------
// these are pieces of code that will be injected into the event handler if
// modifiers are specified
const MODS_CODE = {
prevent: "e.preventDefault();",
self: "if (e.target !== this.elm) {return}",
stop: "e.stopPropagation();"
};
QWeb.addDirective({
name: "on",
priority: 90,
atNodeCreation({ ctx, fullName, value, nodeID }) {
ctx.rootContext.shouldDefineOwner = true;
const [eventName, ...mods] = fullName.slice(5).split(".");
if (!eventName) {
throw new Error("Missing event name with t-on directive");
}
let extraArgs;
let handlerName = value.replace(/\(.*\)/, function(args) {
extraArgs = args.slice(1, -1);
return "";
});
ctx.addIf(`!context['${handlerName}']`);
ctx.addLine(
`throw new Error('Missing handler \\'' + '${handlerName}' + \`\\' when evaluating template '${ctx.templateName.replace(
/`/g,
"'"
)}'\`)`
);
ctx.closeIf();
let params = extraArgs ? `owner, ${ctx.formatExpression(extraArgs)}` : "owner";
let handler;
if (mods.length > 0) {
handler = `function (e) {`;
handler += mods
.map(function(mod) {
return MODS_CODE[mod];
})
.join("");
handler += `context['${handlerName}'].call(${params}, e);}`;
} else {
handler = `context['${handlerName}'].bind(${params})`;
}
if (extraArgs) {
ctx.addLine(`p${nodeID}.on['${eventName}'] = ${handler};`);
} else {
ctx.addLine(
`extra.handlers['${eventName}' + ${nodeID}] = extra.handlers['${eventName}' + ${nodeID}] || ${handler};`
);
ctx.addLine(`p${nodeID}.on['${eventName}'] = extra.handlers['${eventName}' + ${nodeID}];`);
}
}
});
//------------------------------------------------------------------------------
// t-ref
//------------------------------------------------------------------------------
QWeb.addDirective({
name: "ref",
priority: 95,
atNodeCreation({ ctx, value, addNodeHook }) {
const refKey = `ref${ctx.generateID()}`;
ctx.addLine(`const ${refKey} = ${ctx.interpolate(value)};`);
addNodeHook("create", `context.refs[${refKey}] = n.elm;`);
}
});
//------------------------------------------------------------------------------
// t-transition
//------------------------------------------------------------------------------
UTILS.nextFrame = function(cb: () => void) {
requestAnimationFrame(() => requestAnimationFrame(cb));
};
UTILS.transitionInsert = function(vn: VNode, name: string) {
const elm = <HTMLElement>vn.elm;
// remove potential duplicated vnode that is currently being removed, to
// prevent from having twice the same node in the DOM during an animation
const dup = elm.parentElement && elm.parentElement!.querySelector(`*[data-owl-key='${vn.key}']`);
if (dup) {
dup.remove();
}
elm.classList.add(name + "-enter");
elm.classList.add(name + "-enter-active");
const finalize = () => {
elm.classList.remove(name + "-enter-active");
elm.classList.remove(name + "-enter-to");
};
this.nextFrame(() => {
elm.classList.remove(name + "-enter");
elm.classList.add(name + "-enter-to");
whenTransitionEnd(elm, finalize);
});
};
UTILS.transitionRemove = function(vn: VNode, name: string, rm: () => void) {
const elm = <HTMLElement>vn.elm;
elm.setAttribute("data-owl-key", vn.key!);
elm.classList.add(name + "-leave");
elm.classList.add(name + "-leave-active");
const finalize = () => {
elm.classList.remove(name + "-leave-active");
elm.classList.remove(name + "-leave-to");
rm();
};
this.nextFrame(() => {
elm.classList.remove(name + "-leave");
elm.classList.add(name + "-leave-to");
whenTransitionEnd(elm, finalize);
});
};
function getTimeout(delays: Array<string>, durations: Array<string>): number {
/* istanbul ignore next */
while (delays.length < durations.length) {
delays = delays.concat(delays);
}
return Math.max.apply(
null,
durations.map((d, i) => {
return toMs(d) + toMs(delays[i]);
})
);
}
// Old versions of Chromium (below 61.0.3163.100) formats floating pointer numbers
// in a locale-dependent way, using a comma instead of a dot.
// If comma is not replaced with a dot, the input will be rounded down (i.e. acting
// as a floor function) causing unexpected behaviors
function toMs(s: string): number {
return Number(s.slice(0, -1).replace(",", ".")) * 1000;
}
function whenTransitionEnd(elm: HTMLElement, cb) {
const styles = window.getComputedStyle(elm);
const delays: Array<string> = (styles.transitionDelay || "").split(", ");
const durations: Array<string> = (styles.transitionDuration || "").split(", ");
const timeout: number = getTimeout(delays, durations);
if (timeout > 0) {
elm.addEventListener("transitionend", cb, { once: true });
} else {
cb();
}
}
QWeb.addDirective({
name: "transition",
priority: 96,
atNodeCreation({ value, addNodeHook }) {
let name = value;
const hooks = {
insert: `this.utils.transitionInsert(vn, '${name}');`,
remove: `this.utils.transitionRemove(vn, '${name}', rm);`
};
for (let hookName in hooks) {
addNodeHook(hookName, hooks[hookName]);
}
}
});
//------------------------------------------------------------------------------
// t-component
//------------------------------------------------------------------------------
const T_COMPONENT_MODS_CODE = Object.assign({}, MODS_CODE, {
self: "if (e.target !== vn.elm) {return}"
});
UTILS.defineProxy = function defineProxy(target, source) {
for (let k in source) {
Object.defineProperty(target, k, {
get() {
return source[k];
},
set(val) {
source[k] = val;
}
});
}
};
/**
* The t-component directive is certainly a complicated and hard to maintain piece
* of code. To help you, fellow developer, if you have to maintain it, I offer
* you this advice: Good luck...
*
* Since it is not 'direct' code, but rather code that generates other code, it
* is not easy to understand. To help you, here is a detailed and commented
* explanation of the code generated by the t-component directive for the following
* situation:
* ```xml
* <Child
* t-key="'somestring'"
* flag="state.flag"
* t-transition="fade"/>
* ```
*
* ```js
* // we assign utils on top of the function because it will be useful for
* // each components
* let utils = this.utils;
*
* // this is the virtual node representing the parent div
* let c1 = [], p1 = { key: 1 };
* var vn1 = h("div", p1, c1);
*
* // t-component directive: we start by evaluating the expression given by t-key:
* let key5 = "somestring";
*
* // def3 is the promise that will contain later either the new component
* // creation, or the props update...
* let def3;
*
* // this is kind of tricky: we need here to find if the component was already
* // created by a previous rendering. This is done by checking the internal
* // `cmap` (children map) of the parent component: it maps keys to component ids,
* // and, then, if there is an id, we look into the children list to get the
* // instance
* let w4 =
* key5 in context.__owl__.cmap
* ? context.__owl__.children[context.__owl__.cmap[key5]]
* : false;
*
* // We keep the index of the position of the component in the closure. We push
* // null to reserve the slot, and will replace it later by the component vnode,
* // when it will be ready (do not forget that preparing/rendering a component is
* // asynchronous)
* let _2_index = c1.length;
* c1.push(null);
*
* // we evaluate here the props given to the component. It is done here to be
* // able to easily reference it later, and also, it might be an expensive
* // computation, so it is certainly better to do it only once
* let props4 = { flag: context["state"].flag };
*
* // If we have a component, currently rendering, but not ready yet, we do not want
* // to wait for it to be ready if we can avoid it
* if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) {
* // we check if the props are the same. In that case, we can simply reuse
* // the previous rendering and skip all useless work
* if (utils.shallowEqual(props4, w4.__owl__.renderProps)) {
* def3 = w4.__owl__.renderPromise;
* } else {
* // if the props are not the same, we destroy the component and starts anew.
* // this will be faster than waiting for its rendering, then updating it
* w4.destroy();
* w4 = false;
* }
* }
*
* if (!w4) {
* // in this situation, we need to create a new component. First step is
* // to get a reference to the class, then create an instance with
* // current context as parent, and the props.
* let W4 = context.component && context.components[componentKey4] || QWeb.component[componentKey4];
* if (!W4) {
* throw new Error("Cannot find the definition of component 'child'");
* }
* w4 = new W4(owner, props4);
*
* // Whenever we rerender the parent component, we need to be sure that we
* // are able to find the component instance. To do that, we register it to
* // the parent cmap (children map). Note that the 'template' key is
* // used here, since this is what identify the component from the template
* // perspective.
* context.__owl__.cmap[key5] = w4.__owl__.id;
*
* // __prepare is called, to basically call willStart, then render the
* // component
* def3 = w4.__prepare();
*
* def3 = def3.then(vnode => {
* // we create here a virtual node for the parent (NOT the component). This
* // means that the vdom of the parent will be stopped here, and from
* // the parent's perspective, it simply is a vnode with no children.
* // However, it shares the same dom element with the component root
* // vnode.
* let pvnode = h(vnode.sel, { key: key5 });
*
* // we add hooks to the parent vnode so we can interact with the new
* // component at the proper time
* pvnode.data.hook = {
* insert(vn) {
* // the __mount method will patch the component vdom into the elm vn.elm,
* // then call the mounted hooks. However, suprisingly, the snabbdom
* // patch method actually replace the elm by a new elm, so we need
* // to synchronise the pvnode elm with the resulting elm
* let nvn = w4.__mount(vnode, vn.elm);
* pvnode.elm = nvn.elm;
* // what follows is only present if there are animations on the component
* utils.transitionInsert(vn, "fade");
* },
* remove() {
* // override with empty function to prevent from removing the node
* // directly. It will be removed when destroy is called anyway, which
* // delays the removal if there are animations.
* },
* destroy() {
* // if there are animations, we delay the call to destroy on the
* // component, if not, we call it directly.
* let finalize = () => {
* w4.destroy();
* };
* utils.transitionRemove(vn, "fade", finalize);
* }
* };
* // the pvnode is inserted at the correct position in the div's children
* c1[_2_index] = pvnode;
*
* // we keep here a reference to the parent vnode (representing the
* // component, so we can reuse it later whenever we update the component
* w4.__owl__.pvnode = pvnode;
* });
* } else {
* // this is the 'update' path of the directive.
* // the call to __updateProps is the actual component update
* // Note that we only update the props if we cannot reuse the previous
* // rendering work (in the case it was rendered with the same props)
* def3 = def3 || w4.__updateProps(props4, extra.forceUpdate, extra.patchQueue);
* def3 = def3.then(() => {
* // if component was destroyed in the meantime, we do nothing (so, this
* // means that the parent's element children list will have a null in
* // the component's position, which will cause the pvnode to be removed
* // when it is patched.
* if (w4.__owl__.isDestroyed) {
* return;
* }
* // like above, we register the pvnode to the children list, so it
* // will not be patched out of the dom.
* let pvnode = w4.__owl__.pvnode;
* c1[_2_index] = pvnode;
* });
* }
*
* // we register the deferred here so the parent can coordinate its patch operation
* // with all the children.
* extra.promises.push(def3);
* return vn1;
* ```
*/
QWeb.addDirective({
name: "component",
extraNames: ["props", "keepalive", "asyncroot"],
priority: 100,
atNodeEncounter({ ctx, value, node, qweb }): boolean {
ctx.addLine("//COMPONENT");
ctx.rootContext.shouldDefineOwner = true;
ctx.rootContext.shouldDefineQWeb = true;
ctx.rootContext.shouldDefineParent = true;
ctx.rootContext.shouldDefineUtils = true;
let keepAlive = node.getAttribute("t-keepalive") ? true : false;
let async = node.getAttribute("t-asyncroot") ? true : false;
// t-on- events and t-transition
const events: [string, string[], string, string][] = [];
let transition: string = "";
const attributes = (<Element>node).attributes;
const props: { [key: string]: string } = {};
for (let i = 0; i < attributes.length; i++) {
const name = attributes[i].name;
const value = attributes[i].textContent!;
if (name.startsWith("t-on-")) {
const [eventName, ...mods] = name.slice(5).split(".");
let extraArgs;
let handlerName = value.replace(/\(.*\)/, function(args) {
extraArgs = args.slice(1, -1);
return "";
});
events.push([eventName, mods, handlerName, extraArgs]);
} else if (name === "t-transition") {
transition = value;
} else if (!name.startsWith("t-")) {
if (name !== "class" && name !== "style") {
// this is a prop!
props[name] = ctx.formatExpression(value);
}
}
}
let key = node.getAttribute("t-key");
if (key) {
key = ctx.formatExpression(key);
}
// computing the props string representing the props object
let propStr = Object.keys(props)
.map(k => k + ":" + props[k])
.join(",");
let dummyID = ctx.generateID();
let defID = ctx.generateID();
let componentID = ctx.generateID();
let keyID = key && ctx.generateID();
if (key) {
// we bind a variable to the key (could be a complex expression, so we
// want to evaluate it only once)
ctx.addLine(`let key${keyID} = ${key};`);
}
ctx.addLine(`let def${defID};`);
let templateID = key
? `key${keyID}`
: ctx.inLoop
? `String(-${componentID} - i)`
: String(componentID);
if (ctx.allowMultipleRoots) {
// necessary to prevent collisions
if (!key && ctx.inLoop) {
let id = ctx.generateID();
ctx.addLine(`let template${id} = "_slot_" + String(-${componentID} - i)`);
templateID = `template${id}`;
} else {
templateID = `"_slot_${templateID}"`;
}
}
let ref = node.getAttribute("t-ref");
let refExpr = "";
let refKey: string = "";
if (ref) {
refKey = `ref${ctx.generateID()}`;
ctx.addLine(`const ${refKey} = ${ctx.interpolate(ref)};`);
refExpr = `context.refs[${refKey}] = w${componentID};`;
}
let transitionsInsertCode = "";
if (transition) {
transitionsInsertCode = `utils.transitionInsert(vn, '${transition}');`;
}
let finalizeComponentCode = `w${componentID}.${keepAlive ? "unmount" : "destroy"}();`;
if (ref && !keepAlive) {
finalizeComponentCode += `delete context.refs[${refKey}];`;
}
if (transition) {
finalizeComponentCode = `let finalize = () => {
${finalizeComponentCode}
};
utils.transitionRemove(vn, '${transition}', finalize);`;
}
let createHook = "";
let classAttr = node.getAttribute("class");
let tattClass = node.getAttribute("t-att-class");
let styleAttr = node.getAttribute("style");
let tattStyle = node.getAttribute("t-att-style");
if (tattStyle) {
const attVar = `_${ctx.generateID()}`;
ctx.addLine(`const ${attVar} = ${ctx.formatExpression(tattStyle)};`);
tattStyle = attVar;
}
let classObj = "";
if (classAttr || tattClass || styleAttr || tattStyle || events.length) {
if (classAttr) {
let classDef = classAttr
.trim()
.split(/\s+/)
.map(a => `'${a}':true`)
.join(",");
classObj = `_${ctx.generateID()}`;
ctx.addLine(`let ${classObj} = {${classDef}};`);
}
if (tattClass) {
let tattExpr = ctx.formatExpression(tattClass);
if (tattExpr[0] !== "{" || tattExpr[tattExpr.length - 1] !== "}") {
tattExpr = `utils.toObj(${tattExpr})`;
}
if (classAttr) {
ctx.addLine(`Object.assign(${classObj}, ${tattExpr})`);
} else {
classObj = `_${ctx.generateID()}`;
ctx.addLine(`let ${classObj} = ${tattExpr};`);
}
}
let eventsCode = events
.map(function([eventName, mods, handlerName, extraArgs]) {
let params = "owner";
if (extraArgs) {
if (ctx.inLoop) {
let argId = ctx.generateID();
// we need to evaluate the arguments now, because the handler will
// be set asynchronously later when the widget is ready, and the
// context might be different.
ctx.addLine(`let arg${argId} = ${ctx.formatExpression(extraArgs)};`);
params = `owner, arg${argId}`;
} else {
params = `owner, ${ctx.formatExpression(extraArgs)}`;
}
}
let handler;
if (mods.length > 0) {
handler = `function (e) {`;
handler += mods
.map(function(mod) {
return T_COMPONENT_MODS_CODE[mod];
})
.join("");
handler += `owner['${handlerName}'].call(${params}, e);}`;
} else {
handler = `owner['${handlerName}'].bind(${params})`;
}
return `vn.elm.addEventListener('${eventName}', ${handler});`;
})
.join("");
const styleExpr = tattStyle || (styleAttr ? `'${styleAttr}'` : false);
const styleCode = styleExpr ? `vn.elm.style = ${styleExpr};` : "";
createHook = `vnode.data.hook = {create(_, vn){${styleCode}${eventsCode}}};`;
}
ctx.addLine(
`let w${componentID} = ${templateID} in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[${templateID}]] : false;`
);
if (ctx.parentNode) {
ctx.addLine(`let _${dummyID}_index = c${ctx.parentNode}.length;`);
}
let shouldProxy = false;
if (async) {
ctx.addLine(`const patchQueue${componentID} = [];`);
ctx.addLine(
`c${ctx.parentNode}.push(w${componentID} && w${componentID}.__owl__.pvnode || null);`
);
} else {
if (ctx.parentNode) {
ctx.addLine(`c${ctx.parentNode}.push(null);`);
} else {
let id = ctx.generateID();
ctx.rootContext.rootNode = id;
shouldProxy = true;
ctx.rootContext.shouldDefineResult = true;
ctx.addLine(`let vn${id} = {};`);
ctx.addLine(`result = vn${id};`);
}
}
ctx.addLine(`let props${componentID} = {${propStr}};`);
ctx.addIf(
`w${componentID} && w${componentID}.__owl__.renderPromise && !w${componentID}.__owl__.vnode`
);
ctx.addIf(`utils.shallowEqual(props${componentID}, w${componentID}.__owl__.renderProps)`);
ctx.addLine(`def${defID} = w${componentID}.__owl__.renderPromise;`);
ctx.addElse();
ctx.addLine(`w${componentID}.destroy();`);
ctx.addLine(`w${componentID} = false;`);
ctx.closeIf();
ctx.closeIf();
ctx.addIf(`!w${componentID}`);
// new component
ctx.addLine(`let componentKey${componentID} = ${ctx.interpolate(value)};`);
ctx.addLine(
`let W${componentID} = context.components && context.components[componentKey${componentID}] || QWeb.components[componentKey${componentID}];`
);
// maybe only do this in dev mode...
ctx.addLine(
`if (!W${componentID}) {throw new Error('Cannot find the definition of component "' + componentKey${componentID} + '"')}`
);
if (QWeb.dev) {
ctx.addLine(`utils.validateProps(W${componentID}, props${componentID})`);
}
ctx.addLine(`w${componentID} = new W${componentID}(parent, props${componentID});`);
ctx.addLine(`parent.__owl__.cmap[${templateID}] = w${componentID}.__owl__.id;`);
// SLOTS
const varDefs: string[] = [];
const hasSlots = node.childNodes.length;
if (hasSlots) {
ctx.rootContext.shouldTrackScope = true;
for (let v of Object.values(ctx.variables)) {
if (v["id"]) {
varDefs.push(v["id"]);
}
}
const clone = <Element>node.cloneNode(true);
const slotNodes = clone.querySelectorAll("[t-set]");
const slotId = qweb.nextSlotId++;
ctx.addLine(`w${componentID}.__owl__.slotId = ${slotId};`);
if (slotNodes.length) {
for (let i = 0, length = slotNodes.length; i < length; i++) {
const slotNode = slotNodes[i];
slotNode.parentElement!.removeChild(slotNode);
const key = slotNode.getAttribute("t-set")!;
slotNode.removeAttribute("t-set");
const slotFn = qweb._compile(`slot_${key}_template`, slotNode, ctx);
qweb.slots[`${slotId}_${key}`] = slotFn.bind(qweb);
}
}
if (clone.childNodes.length) {
const t = clone.ownerDocument!.createElement("t");
for (let child of Object.values(clone.childNodes)) {
t.appendChild(child);
}
const slotFn = qweb._compile(`slot_default_template`, t, ctx);
qweb.slots[`${slotId}_default`] = slotFn.bind(qweb);
}
}
let scopeVars = "";
if (hasSlots) {
scopeVars += ctx.scopeVars.length ? `Object.assign({}, scope)` : varDefs.length ? `{}` : "";
if (varDefs.length) {
scopeVars += `, {${varDefs.join(",")}}`;
}
}
ctx.addLine(`def${defID} = w${componentID}.__prepare(${scopeVars});`);
// hack: specify empty remove hook to prevent the node from being removed from the DOM
let registerCode = `c${ctx.parentNode}[_${dummyID}_index]=pvnode;`;
if (shouldProxy) {
registerCode = `utils.defineProxy(vn${ctx.rootNode}, pvnode);`;
}
ctx.addLine(
`def${defID} = def${defID}.then(vnode=>{${createHook}let pvnode=h(vnode.sel, {key: ${templateID}, hook: {insert(vn) {let nvn=w${componentID}.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;${refExpr}${transitionsInsertCode}},remove() {},destroy(vn) {${finalizeComponentCode}}}});${registerCode}w${componentID}.__owl__.pvnode = pvnode;});`
);
ctx.addElse();
// need to update component
const patchQueueCode = async ? `patchQueue${componentID}` : "extra.patchQueue";
if (QWeb.dev) {
ctx.addLine(`utils.validateProps(w${componentID}.constructor, props${componentID})`);
}
ctx.addLine(
`def${defID} = def${defID} || w${componentID}.__updateProps(props${componentID}, extra.forceUpdate, ${patchQueueCode}${scopeVars &&
", " + scopeVars});`
);
let keepAliveCode = "";
if (keepAlive) {
keepAliveCode = `pvnode.data.hook.insert = vn => {vn.elm.parentNode.replaceChild(w${componentID}.el,vn.elm);vn.elm=w${componentID}.el;w${componentID}.__remount();};`;
}
ctx.addLine(
`def${defID} = def${defID}.then(()=>{if (w${componentID}.__owl__.isDestroyed) {return};${
tattStyle ? `w${componentID}.el.style=${tattStyle};` : ""
}let pvnode=w${componentID}.__owl__.pvnode;${keepAliveCode}${registerCode}});`
);
ctx.closeIf();
if (classObj) {
ctx.addLine(`w${componentID}.__owl__.classObj=${classObj};`);
}
if (async) {
ctx.addLine(
`def${defID}.then(w${componentID}.__applyPatchQueue.bind(w${componentID}, patchQueue${componentID}));`
);
} else {
ctx.addLine(`extra.promises.push(def${defID});`);
}
if (node.hasAttribute("t-if") || node.hasAttribute("t-else") || node.hasAttribute("t-elif")) {
ctx.closeIf();
}
return true;
}
});
//------------------------------------------------------------------------------
// Prop validation helper
//------------------------------------------------------------------------------
/**
* Validate the component props (or next props) against the (static) props
* description. This is potentially an expensive operation: it may needs to
* visit recursively the props and all the children to check if they are valid.
* This is why it is only done in 'dev' mode.
*/
UTILS.validateProps = function(Widget, props: Object) {
const propsDef = (<any>Widget).props;
if (propsDef instanceof Array) {
// list of strings (prop names)
for (let i = 0, l = propsDef.length; i < l; i++) {
const propName = propsDef[i];
if (propName[propName.length - 1] === "?") {
// optional prop
break;
}
if (!props[propName]) {
throw new Error(`Missing props '${propsDef[i]}' (component '${Widget.name}')`);
}
}
for (let key in props) {
if (!propsDef.includes(key) && !propsDef.includes(key + "?")) {
throw new Error(`Unknown prop '${key}' given to component '${Widget.name}'`);
}
}
} else if (propsDef) {
// propsDef is an object now
for (let propName in propsDef) {
if (props[propName] === undefined) {
if (propsDef[propName] && !propsDef[propName].optional) {
throw new Error(`Missing props '${propName}' (component '${Widget.name}')`);
} else {
break;
}
}
let isValid = isValidProp(props[propName], propsDef[propName]);
if (!isValid) {
throw new Error(`Props '${propName}' of invalid type in component '${Widget.name}'`);
}
}
for (let propName in props) {
if (!(propName in propsDef)) {
throw new Error(`Unknown prop '${propName}' given to component '${Widget.name}'`);
}
}
}
};
/**
* Check if an invidual prop value matches its (static) prop definition
*/
function isValidProp(prop, propDef): boolean {
if (propDef === true) {
return true;
}
if (typeof propDef === "function") {
// Check if a value is constructed by some Constructor. Note that there is a
// slight abuse of language: we want to consider primitive values as well.
//
// So, even though 1 is not an instance of Number, we want to consider that
// it is valid.
if (typeof prop === "object") {
return prop instanceof propDef;
}
return typeof prop === propDef.name.toLowerCase();
} else if (propDef instanceof Array) {
// If this code is executed, this means that we want to check if a prop
// matches at least one of its descriptor.
let result = false;
for (let i = 0, iLen = propDef.length; i < iLen; i++) {
result = result || isValidProp(prop, propDef[i]);
}
return result;
}
// propsDef is an object
let result = isValidProp(prop, propDef.type);
if (propDef.type === Array) {
for (let i = 0, iLen = prop.length; i < iLen; i++) {
result = result && isValidProp(prop[i], propDef.element);
}
}
if (propDef.type === Object) {
const shape = propDef.shape;
for (let key in shape) {
result = result && isValidProp(prop[key], shape[key]);
}
}
return result;
}
//------------------------------------------------------------------------------
// t-mounted
//------------------------------------------------------------------------------
QWeb.addDirective({
name: "mounted",
priority: 97,
atNodeCreation({ ctx, fullName, value, nodeID, addNodeHook }) {
ctx.rootContext.shouldDefineOwner = true;
const eventName = fullName.slice(5);
if (!eventName) {
throw new Error("Missing event name with t-on directive");
}
let extraArgs;
let handler = value.replace(/\(.*\)/, function(args) {
extraArgs = args.slice(1, -1);
return "";
});
let error = `(function () {throw new Error('Missing handler \\'' + '${handler}' + \`\\' when evaluating template '${ctx.templateName.replace(
/`/g,
"'"
)}'\`)})()`;
if (extraArgs) {
ctx.addLine(
`extra.mountedHandlers[${nodeID}] = (context['${handler}'] || ${error}).bind(owner, ${ctx.formatExpression(
extraArgs
)});`
);
} else {
ctx.addLine(
`extra.mountedHandlers[${nodeID}] = extra.mountedHandlers[${nodeID}] || (context['${handler}'] || ${error}).bind(owner);`
);
}
addNodeHook("insert", `if (context.__owl__.isMounted) { extra.mountedHandlers[${nodeID}](); }`);
}
});
//------------------------------------------------------------------------------
// t-slot
//------------------------------------------------------------------------------
QWeb.addDirective({
name: "slot",
priority: 80,
atNodeEncounter({ ctx, value }): boolean {
const slotKey = ctx.generateID();
ctx.rootContext.shouldDefineOwner = true;
ctx.addLine(`const slot${slotKey} = this.slots[context.__owl__.slotId + '_' + '${value}'];`);
ctx.addIf(`slot${slotKey}`);
ctx.addLine(
`slot${slotKey}(context.__owl__.parent, Object.assign({}, extra, {parentNode: c${
ctx.parentNode
}, vars: extra.vars, parent: owner}));`
);
ctx.closeIf();
return true;
}
});
//------------------------------------------------------------------------------
// t-model
//------------------------------------------------------------------------------
UTILS.toNumber = function(val: string): number | string {
const n = parseFloat(val);
return isNaN(n) ? val : n;
};
QWeb.addDirective({
name: "model",
priority: 42,
atNodeCreation({ ctx, nodeID, value, node, fullName }) {
const type = node.getAttribute("type");
let handler;
let event = fullName.includes(".lazy") ? "change" : "input";
if (node.tagName === "select") {
ctx.addLine(`p${nodeID}.props = {value: context.state['${value}']};`);
event = "change";
handler = `(ev) => {context.state['${value}'] = ev.target.value}`;
} else if (type === "checkbox") {
ctx.addLine(`p${nodeID}.props = {checked: context.state['${value}']};`);
handler = `(ev) => {context.state['${value}'] = ev.target.checked}`;
} else if (type === "radio") {
const nodeValue = node.getAttribute("value")!;
ctx.addLine(`p${nodeID}.props = {checked:context.state['${value}'] === '${nodeValue}'};`);
handler = `(ev) => {context.state['${value}'] = ev.target.value}`;
event = "click";
} else {
ctx.addLine(`p${nodeID}.props = {value: context.state['${value}']};`);
const trimCode = fullName.includes(".trim") ? ".trim()" : "";
let valueCode = `ev.target.value${trimCode}`;
if (fullName.includes(".number")) {
ctx.rootContext.shouldDefineUtils = true;
valueCode = `utils.toNumber(${valueCode})`;
}
handler = `(ev) => {context.state['${value}'] = ${valueCode}}`;
}
ctx.addLine(
`extra.handlers['${event}' + ${nodeID}] = extra.handlers['${event}' + ${nodeID}] || (${handler});`
);
ctx.addLine(`p${nodeID}.on['${event}'] = extra.handlers['${event}' + ${nodeID}];`);
}
});
+48
View File
@@ -0,0 +1,48 @@
import { Component } from "../component/component";
import { xml } from "../tags";
import { Destination, RouterEnv } from "./router";
type Props = Destination;
export class Link<Env extends RouterEnv> extends Component<Env, Props> {
static template = xml`
<a t-att-class="{'router-link-active': isActive }"
t-att-href="href"
t-on-click="navigate">
<t t-slot="default"/>
</a>
`;
href: string = this.env.router.destToPath(this.props);
async willUpdateProps(nextProps) {
this.href = this.env.router.destToPath(nextProps);
}
get isActive() {
if (this.env.router.mode === "hash") {
return (<any>document.location).hash === this.href;
}
return (<any>document.location).pathname === this.href;
}
navigate(ev) {
// don't redirect with control keys
if (ev.metaKey || ev.altKey || ev.ctrlKey || ev.shiftKey) {
return;
}
// don't redirect on right click
if (ev.button !== undefined && ev.button !== 0) {
return;
}
// don't redirect if `target="_blank"`
if (ev.currentTarget && ev.currentTarget.getAttribute) {
const target = ev.currentTarget.getAttribute("target");
if (/\b_blank\b/i.test(target)) {
return;
}
}
ev.preventDefault();
this.env.router.navigate(this.props);
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Component } from "../component/component";
import { xml } from "../tags";
export class RouteComponent extends Component<any, {}> {
static template = xml`
<t>
<t
t-if="routeComponent"
t-component="routeComponent"
t-key="env.router.currentRouteName"
t-props="env.router.currentParams" />
</t>
`;
get routeComponent(): any {
return this.env.router.currentRoute && this.env.router.currentRoute.component;
}
}
+275
View File
@@ -0,0 +1,275 @@
import { Env } from "../component/component";
import { QWeb } from "../qweb/index";
import { shallowEqual } from "../utils";
type NavigationGuard = (info: {
env: Env;
to: Route | null;
from: Route | null;
}) => boolean | Destination;
export interface Route {
name: string;
path: string;
component?: any;
redirect?: Destination;
params: string[];
beforeRouteEnter?: NavigationGuard;
}
export type RouteParams = { [key: string]: string | number };
export interface RouterEnv extends Env {
router: Router;
}
export interface Destination {
path?: string;
to?: string;
params?: RouteParams;
}
interface PositiveMatchResult {
type: "match";
route: Route;
params: RouteParams;
}
interface NegativeMatchResult {
type: "nomatch";
}
interface CancelledMatch {
type: "cancelled";
}
type MatchResult = PositiveMatchResult | NegativeMatchResult | CancelledMatch;
interface Options {
mode: Router["mode"];
}
const paramRegexp = /\{\{(.*?)\}\}/;
export class Router {
currentRoute: Route | null = null;
currentParams: RouteParams | null = null;
mode: "history" | "hash";
routes: { [id: string]: Route };
routeIds: string[];
env: RouterEnv;
constructor(env: Env, routes: Partial<Route>[], options: Options = { mode: "history" }) {
env.router = this;
this.mode = options.mode;
this.env = env as RouterEnv;
this.routes = {};
this.routeIds = [];
let nextId = 1;
for (let partialRoute of routes) {
if (!partialRoute.name) {
partialRoute.name = "__route__" + nextId++;
}
if (partialRoute.component) {
QWeb.registerComponent("__component__" + partialRoute.name, partialRoute.component);
}
if (partialRoute.redirect) {
this.validateDestination(partialRoute.redirect);
}
partialRoute.params = partialRoute.path ? findParams(partialRoute.path) : [];
this.routes[partialRoute.name] = partialRoute as Route;
this.routeIds.push(partialRoute.name);
}
}
//--------------------------------------------------------------------------
// Public API
//--------------------------------------------------------------------------
async start() {
(this as any)._listener = ev => this._navigate(this.currentPath(), ev);
window.addEventListener("popstate", (this as any)._listener);
if (this.mode === "hash") {
window.addEventListener("hashchange", (this as any)._listener);
}
const result = await this.matchAndApplyRules(this.currentPath());
if (result.type === "match") {
this.currentRoute = result.route;
this.currentParams = result.params;
const currentPath = this.routeToPath(result.route, result.params);
if (currentPath !== this.currentPath()) {
this.setUrlFromPath(currentPath);
}
}
}
async navigate(to: Destination): Promise<boolean> {
const path = this.destToPath(to);
return this._navigate(path);
}
async _navigate(path: string, ev?: any): Promise<boolean> {
const initialName = this.currentRouteName;
const initialParams = this.currentParams;
const result = await this.matchAndApplyRules(path);
if (result.type === "match") {
const finalPath = this.routeToPath(result.route, result.params);
const isPopStateEvent = ev && ev instanceof PopStateEvent;
if (!isPopStateEvent) {
this.setUrlFromPath(finalPath);
}
this.currentRoute = result.route;
this.currentParams = result.params;
} else if (result.type === "nomatch") {
this.currentRoute = null;
this.currentParams = null;
}
const didChange =
this.currentRouteName !== initialName || !shallowEqual(this.currentParams, initialParams);
if (didChange) {
this.env.qweb.forceUpdate();
return true;
}
return false;
}
destToPath(dest: Destination): string {
this.validateDestination(dest);
return dest.path || this.routeToPath(this.routes[dest.to!], dest.params!);
}
get currentRouteName(): string | null {
return this.currentRoute && this.currentRoute.name;
}
//--------------------------------------------------------------------------
// Private helpers
//--------------------------------------------------------------------------
private setUrlFromPath(path: string) {
const separator = this.mode === "hash" ? location.pathname : "";
const url = location.origin + separator + path;
if (url !== window.location.href) {
window.history.pushState({}, path, url);
}
}
private validateDestination(dest: Destination) {
if ((!dest.path && !dest.to) || (dest.path && dest.to)) {
throw new Error(`Invalid destination: ${JSON.stringify(dest)}`);
}
}
private routeToPath(route: Route, params: RouteParams): string {
const path = route.path;
const parts = path.split("/");
const l = parts.length;
for (let i = 0; i < l; i++) {
const part = parts[i];
const match = part.match(paramRegexp);
if (match) {
const key = match[1].split(".")[0];
parts[i] = <string>params[key];
}
}
const prefix = this.mode === "hash" ? "#" : "";
return prefix + parts.join("/");
}
private currentPath(): string {
let result = this.mode === "history" ? window.location.pathname : window.location.hash.slice(1);
return result || "/";
}
private match(path: string): MatchResult {
for (let routeId of this.routeIds) {
let route = this.routes[routeId];
let params = this.getRouteParams(route, path);
if (params) {
return {
type: "match",
route: route,
params: params
};
}
}
return { type: "nomatch" };
}
private async matchAndApplyRules(path: string): Promise<MatchResult> {
const result = this.match(path);
if (result.type === "match") {
return this.applyRules(result);
}
return result;
}
private async applyRules(matchResult: PositiveMatchResult): Promise<MatchResult> {
const route = matchResult.route;
if (route.redirect) {
const path = this.destToPath(route.redirect);
return this.matchAndApplyRules(path);
}
if (route.beforeRouteEnter) {
const result = await route.beforeRouteEnter({
env: this.env,
from: this.currentRoute,
to: route
});
if (result === false) {
return { type: "cancelled" };
} else if (result !== true) {
// we want to navigate to another destination
const path = this.destToPath(result);
return this.matchAndApplyRules(path);
}
}
return matchResult;
}
private getRouteParams(route: Route, path: string): RouteParams | false {
if (route.path === "*") {
return {};
}
if (path.startsWith("#")) {
path = path.slice(1);
}
const descrParts = route.path.split("/");
const targetParts = path.split("/");
const l = descrParts.length;
if (l !== targetParts.length) {
return false;
}
const result = {};
for (let i = 0; i < l; i++) {
const descr = descrParts[i];
let target: string | number = targetParts[i];
const match = descr.match(paramRegexp);
if (match) {
const [key, suffix] = match[1].split(".");
if (suffix === "number") {
target = parseInt(target, 10);
}
result[key] = target;
} else if (descr !== target) {
return false;
}
}
return result;
}
}
function findParams(str: string): string[] {
const globalParamRegexp = /\{\{(.*?)\}\}/g;
const result: string[] = [];
let m;
do {
m = globalParamRegexp.exec(str);
if (m) {
result.push(m[1].split(".")[0]);
}
} while (m);
return result;
}
+92 -242
View File
@@ -1,17 +1,20 @@
import { Component, Env } from "./component";
import { EventBus } from "./event_bus";
import { Observer } from "./observer";
import { Component } from "./component/component";
import { Env } from "./component/component";
import { Context, useContextWithCB } from "./context";
import { onWillUpdateProps } from "./hooks";
/**
* Owl Store
*
* We have here:
* - a Store class
* - the ConnectedComponent class
* - useStore hook
* - useDispatch hook
* - useGetters hook
*
* The Owl store is our answer to the problem of managing complex state across
* components. The main idea is that the store owns some state, allow external
* code to modify it through actions/mutations, and for each state changes,
* code to modify it through actions, and for each state changes,
* connected component will be notified, and updated if necessary.
*
* Note that this code is partly inspired by VueX and React/Redux
@@ -21,67 +24,36 @@ import { Observer } from "./observer";
// Store Definition
//------------------------------------------------------------------------------
type Mutation = ({ state, commit, getters }, ...payload: any) => void;
type Action = ({ commit, state, dispatch, env, getters }, ...payload: any) => void;
type Getter = ({ state, getters }, payload) => any;
export type Action = ({ state, dispatch, env, getters }, ...payload: any) => any;
export type Getter = ({ state: any, getters }, payload?) => any;
interface StoreConfig {
env?: Env;
state?: any;
actions?: { [name: string]: Action };
getters?: { [name: string]: Getter };
mutations?: { [name: string]: Mutation };
}
interface StoreOption {
debug?: boolean;
}
export class Store extends EventBus {
state: any;
export class Store extends Context {
actions: any;
mutations: any;
_commitLevel: number = 0;
history: any[] = [];
debug: boolean;
env: any;
observer: Observer;
getters: { [name: string]: (payload?) => any };
_gettersCache: { [name: string]: {} };
_updateId: number = 1;
updateFunctions: { [key: number]: (() => boolean)[] };
constructor(config: StoreConfig, options: StoreOption = {}) {
super();
this.debug = options.debug || false;
this.state = config.state || {};
constructor(config: StoreConfig) {
super(config.state);
this.actions = config.actions;
this.mutations = config.mutations;
this.env = config.env;
this.observer = new Observer();
this.observer.notifyCB = this.__notifyComponents.bind(this);
this.observer.allowMutations = false;
this.observer.observe(this.state);
this.getters = {};
this._gettersCache = {};
if (this.debug) {
this.history.push({ state: this.state });
}
const cTypes = ["undefined", "number", "string"];
for (let entry of Object.entries(config.getters || {})) {
const name: string = entry[0];
const func: (...any) => any = entry[1];
this.getters[name] = payload => {
if (this._commitLevel === 0 && cTypes.indexOf(typeof payload) >= 0) {
this._gettersCache[name] = this._gettersCache[name] || {};
this._gettersCache[name][payload] =
this._gettersCache[name][payload] ||
func({ state: this.state, getters: this.getters }, payload);
return this._gettersCache[name][payload];
}
return func({ state: this.state, getters: this.getters }, payload);
this.updateFunctions = [];
if (config.getters) {
const firstArg = {
state: this.state,
getters: this.getters
};
for (let g in config.getters) {
this.getters[g] = config.getters[g].bind(this, firstArg);
}
}
}
@@ -91,7 +63,6 @@ export class Store extends EventBus {
}
const result = this.actions[action](
{
commit: this.commit.bind(this),
dispatch: this.dispatch.bind(this),
env: this.env,
state: this.state,
@@ -99,208 +70,87 @@ export class Store extends EventBus {
},
...payload
);
if (result instanceof Promise) {
return new Promise((resolve, reject) => {
result.then(() => resolve());
result.catch(reject);
});
}
}
commit(type: string, ...payload: any): any {
if (!this.mutations[type]) {
throw new Error(`[Error] mutation ${type} is undefined`);
}
this._commitLevel++;
this.observer.allowMutations = true;
const res = this.mutations[type].call(
null,
{
commit: this.commit.bind(this),
state: this.state,
getters: this.getters
},
...payload
);
if (this._commitLevel === 1) {
this.observer.allowMutations = false;
if (this.debug) {
this.history.push({
state: this.state,
mutation: type,
payload: [...payload]
});
}
}
this._commitLevel--;
return res;
}
/**
* Instead of using trigger to emit an update event, we actually implement
* our own function to do that. The reason is that we need to be smarter than
* a simple trigger function: we need to wait for parent components to be
* done before doing children components. The reason is that if an update
* as an effect of destroying a children, we do not want to call the
* mapStoreToProps function of the child, nor rendering it.
*
* This method is not optimal if we have a bunch of asynchronous components:
* we wait sequentially for each component to be completed before updating the
* next. However, the only things that matters is that children are updated
* after their parents. So, this could be optimized by being smarter, and
* updating all widgets concurrently, except for parents/children.
*/
async __notifyComponents() {
this._updateId++;
const current = this._updateId;
this._gettersCache = {};
const subs = this.subscriptions.update || [];
for (let i = 0, iLen = subs.length; i < iLen; i++) {
const sub = subs[i];
const shouldCallback = sub.owner ? sub.owner.__owl__.isMounted : true;
if (shouldCallback) {
await sub.callback.call(sub.owner, current);
}
}
return result;
}
}
//------------------------------------------------------------------------------
// Connect function
//------------------------------------------------------------------------------
function revNumber<T extends Object>(o: T): number {
if (o !== null && typeof o === "object" && (<any>o).__owl__) {
return (<any>o).__owl__.rev;
}
return 0;
interface SelectorOptions {
store?: Store;
isEqual?: (a: any, b: any) => boolean;
onUpdate?: (result: any) => any;
}
function deepRevNumber<T extends Object>(o: T): number {
if (o !== null && typeof o === "object" && (<any>o).__owl__) {
return (<any>o).__owl__.deepRev;
const isStrictEqual = (a, b) => a === b;
export function useStore(selector, options: SelectorOptions = {}): any {
const component: Component<any, any> = Component.current!;
const componentId = component.__owl__.id;
const store = options.store || (component.env.store as Store);
if (!(store instanceof Store)) {
throw new Error(`No store found when connecting '${component.constructor.name}'`);
}
return 0;
}
type HashFunction = (a: any, b: any) => number;
export class ConnectedComponent<T extends Env, P, S> extends Component<T, P, S> {
deep: boolean = true;
getStore(env) {
return env.store;
let result = selector(store.state, component.props);
const hashFn = store.observer.revNumber.bind(store.observer);
let revNumber = hashFn(result);
const isEqual = options.isEqual || isStrictEqual;
if (!store.updateFunctions[componentId]) {
store.updateFunctions[componentId] = [];
}
hashFunction: HashFunction = ({ storeProps }, options) => {
let refFunction = this.deep ? deepRevNumber : revNumber;
if ("__owl__" in storeProps) {
return refFunction(storeProps);
}
const { currentStoreProps } = options;
let hash = 0;
for (let key in storeProps) {
const val = storeProps[key];
const hashVal = refFunction(val);
if (hashVal === 0) {
if (val !== currentStoreProps[key]) {
options.didChange = true;
}
} else {
hash += hashVal;
function selectCompareUpdate(state, props): boolean {
const oldResult = result;
result = selector(state, props);
const newRevNumber = hashFn(result);
if ((newRevNumber > 0 && revNumber !== newRevNumber) || !isEqual(oldResult, result)) {
revNumber = newRevNumber;
if (options.onUpdate) {
options.onUpdate(result);
}
return true;
}
return hash;
return false;
}
store.updateFunctions[componentId].push(function(): boolean {
return selectCompareUpdate(store!.state, component.props);
});
useContextWithCB(store, component, function(): Promise<void> | void {
let shouldRender = false;
for (let fn of store.updateFunctions[componentId]) {
shouldRender = fn() || shouldRender;
}
if (shouldRender) {
return component.render();
}
});
onWillUpdateProps(props => {
selectCompareUpdate(store.state, props);
});
const __destroy = component.__destroy;
component.__destroy = parent => {
delete store.updateFunctions[componentId];
__destroy.call(component, parent);
};
static mapStoreToProps(storeState, ownProps, getters) {
return {};
if (typeof result !== "object") {
return result;
}
constructor(parent, props?: any) {
super(parent, props);
const store = this.getStore(this.env);
const ownProps = Object.assign({}, props || {});
const storeProps = (<any>this.constructor).mapStoreToProps(
store.state,
ownProps,
store.getters
);
const mergedProps = Object.assign({}, props || {}, storeProps);
this.props = mergedProps;
(<any>this.__owl__).ownProps = ownProps;
(<any>this.__owl__).currentStoreProps = storeProps;
(<any>this.__owl__).store = store;
(<any>this.__owl__).storeHash = this.hashFunction(
{
state: store.state,
storeProps: storeProps,
revNumber,
deepRevNumber
},
{
currentStoreProps: storeProps
}
);
}
/**
* We do not use the mounted hook here for a subtle reason: we want the
* updates to be called for the parents before the children. However,
* if we use the mounted hook, this will be done in the reverse order.
*/
__callMounted() {
(<any>this.__owl__).store.on("update", this, this.__checkUpdate);
super.__callMounted();
}
willUnmount() {
(<any>this.__owl__).store.off("update", this);
super.willUnmount();
}
async __checkUpdate(updateId) {
if (updateId === (<any>this.__owl__).currentUpdateId) {
return;
return new Proxy(result, {
get(target, k) {
return result[k];
},
set(target, k, v) {
throw new Error("Store state should only be modified through actions");
}
const ownProps = (<any>this.__owl__).ownProps;
const storeProps = (<any>this.constructor).mapStoreToProps(
(<any>this.__owl__).store.state,
ownProps,
(<any>this.__owl__).store.getters
);
const options: any = {
currentStoreProps: (<any>this.__owl__).currentStoreProps
};
const storeHash = this.hashFunction(
{
state: (<any>this.__owl__).store.state,
storeProps: storeProps,
revNumber,
deepRevNumber
},
options
);
let didChange = options.didChange;
if (storeHash !== (<any>this.__owl__).storeHash) {
didChange = true;
(<any>this.__owl__).storeHash = storeHash;
}
if (didChange) {
(<any>this.__owl__).currentStoreProps = storeProps;
await this.__updateProps(ownProps, false);
}
}
__updateProps(nextProps, forceUpdate, patchQueue?: any[]) {
const __owl__ = <any>this.__owl__;
__owl__.currentUpdateId = __owl__.store._updateId;
if (__owl__.ownProps !== nextProps) {
__owl__.currentStoreProps = (<any>this.constructor).mapStoreToProps(
__owl__.store.state,
nextProps,
__owl__.store.getters
);
}
__owl__.ownProps = nextProps;
const mergedProps = Object.assign({}, nextProps, __owl__.currentStoreProps);
return super.__updateProps(mergedProps, forceUpdate, patchQueue);
}
});
}
export function useDispatch(store?: Store): Store["dispatch"] {
store = store || (Component.current!.env.store as Store);
return store.dispatch.bind(store);
}
export function useGetters(store?: Store): Store["getters"] {
store = store || (Component.current!.env.store as Store);
return store.getters;
}
+27
View File
@@ -0,0 +1,27 @@
import { QWeb } from "./qweb/index";
/**
* Owl Tags
*
* We have here a (very) small collection of tag functions:
*
* - xml
*
* The plan is to add a few other tags such as css, globalcss.
*/
/**
* XML tag helper for defining templates. With this, one can simply define
* an inline template with just the template xml:
* ```js
* class A extends Component {
* static template = xml`<div>some template</div>`;
* }
* ```
*/
export function xml(strings, ...args) {
const name = `__template__${QWeb.nextId++}`;
const value = String.raw(strings, ...args);
QWeb.registerTemplate(name, value);
return name;
}
+13 -6
View File
@@ -5,12 +5,12 @@
*
* - whenReady
* - loadJS
* - loadTemplates
* - loadFile
* - escape
* - debounce
*/
export function whenReady(fn) {
export function whenReady(fn?: any) {
return new Promise(function(resolve) {
if (document.readyState !== "loading") {
resolve();
@@ -43,14 +43,12 @@ export function loadJS(url: string): Promise<void> {
return promise;
}
export async function loadTemplates(url: string): Promise<string> {
export async function loadFile(url: string): Promise<string> {
const result = await fetch(url);
if (!result.ok) {
throw new Error("Error while fetching xml templates");
}
let templates = await result.text();
templates = templates.replace(/<!--[\s\S]*?-->/g, "");
return templates;
return await result.text();
}
export function escape(str: string | number | undefined): string {
@@ -95,3 +93,12 @@ export function debounce(func: Function, wait: number, immediate?: boolean): Fun
}
};
}
export function shallowEqual(p1, p2): boolean {
for (let k in p1) {
if (p1[k] !== p2[k]) {
return false;
}
}
return true;
}
+27
View File
@@ -0,0 +1,27 @@
import { VNode, h } from "./vdom";
const parser = new DOMParser();
export function htmlToVDOM(html: string): VNode[] {
const doc = parser.parseFromString(html, "text/html");
const result: VNode[] = [];
for (let child of doc.body.childNodes) {
result.push(htmlToVNode(child));
}
return result;
}
function htmlToVNode(node: ChildNode): VNode {
if (!(node instanceof Element)) {
return { text: node.textContent! } as VNode;
}
const attrs = {};
for (let attr of node.attributes) {
attrs[attr.name] = attr.textContent;
}
const children: VNode[] = [];
for (let c of node.childNodes) {
children.push(htmlToVNode(c));
}
return h((node as Element).tagName, { attrs }, children);
}
+9
View File
@@ -0,0 +1,9 @@
import { attrsModule, classModule, eventListenersModule, propsModule } from "./modules";
import { init } from "./vdom";
//------------------------------------------------------------------------------
// patch
//------------------------------------------------------------------------------
export { h, VNode } from "./vdom";
export const patch = init([eventListenersModule, attrsModule, propsModule, classModule]);
+234
View File
@@ -0,0 +1,234 @@
import { Module, VNode, VNodeData } from "./vdom";
//------------------------------------------------------------------------------
// module/props.ts
//------------------------------------------------------------------------------
function updateProps(oldVnode: VNode, vnode: VNode): void {
var key: string,
cur: any,
old: any,
elm = vnode.elm,
oldProps = (oldVnode.data as VNodeData).props,
props = (vnode.data as VNodeData).props;
if (!oldProps && !props) return;
if (oldProps === props) return;
oldProps = oldProps || {};
props = props || {};
for (key in oldProps) {
if (!props[key]) {
delete (elm as any)[key];
}
}
for (key in props) {
cur = props[key];
old = oldProps[key];
if (old !== cur && (key !== "value" || (elm as any)[key] !== cur)) {
(elm as any)[key] = cur;
}
}
}
export const propsModule = {
create: updateProps,
update: updateProps
} as Module;
//------------------------------------------------------------------------------
// module/eventlisteners.ts
//------------------------------------------------------------------------------
function invokeHandler(handler: any, vnode?: VNode, event?: Event): void {
if (typeof handler === "function") {
// call function handler
handler.call(vnode, event, vnode);
} else if (typeof handler === "object") {
// call handler with arguments
if (typeof handler[0] === "function") {
// special case for single argument for performance
if (handler.length === 2) {
handler[0].call(vnode, handler[1], event, vnode);
} else {
var args = handler.slice(1);
args.push(event);
args.push(vnode);
handler[0].apply(vnode, args);
}
} else {
// call multiple handlers
for (let i = 0, iLen = handler.length; i < iLen; i++) {
invokeHandler(handler[i], vnode, event);
}
}
}
}
function handleEvent(event: Event, vnode: VNode) {
var name = event.type,
on = (vnode.data as VNodeData).on;
// call event handler(s) if exists
if (on && on[name]) {
invokeHandler(on[name], vnode, event);
}
}
function createListener() {
return function handler(event: Event) {
handleEvent(event, (handler as any).vnode);
};
}
function updateEventListeners(oldVnode: VNode, vnode?: VNode): void {
var oldOn = (oldVnode.data as VNodeData).on,
oldListener = (oldVnode as any).listener,
oldElm: Element = oldVnode.elm as Element,
on = vnode && (vnode.data as VNodeData).on,
elm: Element = (vnode && vnode.elm) as Element,
name: string;
// optimization for reused immutable handlers
if (oldOn === on) {
return;
}
// remove existing listeners which no longer used
if (oldOn && oldListener) {
// if element changed or deleted we remove all existing listeners unconditionally
if (!on) {
for (name in oldOn) {
// remove listener if element was changed or existing listeners removed
oldElm.removeEventListener(name, oldListener, false);
}
} else {
for (name in oldOn) {
// remove listener if existing listener removed
if (!on[name]) {
oldElm.removeEventListener(name, oldListener, false);
}
}
}
}
// add new listeners which has not already attached
if (on) {
// reuse existing listener or create new
var listener = ((vnode as any).listener = (oldVnode as any).listener || createListener());
// update vnode for listener
listener.vnode = vnode;
// if element changed or added we add all needed listeners unconditionally
if (!oldOn) {
for (name in on) {
// add listener if element was changed or new listeners added
elm.addEventListener(name, listener, false);
}
} else {
for (name in on) {
// add listener if new listener added
if (!oldOn[name]) {
elm.addEventListener(name, listener, false);
}
}
}
}
}
export const eventListenersModule = {
create: updateEventListeners,
update: updateEventListeners,
destroy: updateEventListeners
} as Module;
//------------------------------------------------------------------------------
// attributes.ts
//------------------------------------------------------------------------------
const xlinkNS = "http://www.w3.org/1999/xlink";
const xmlNS = "http://www.w3.org/XML/1998/namespace";
const colonChar = 58;
const xChar = 120;
function updateAttrs(oldVnode: VNode, vnode: VNode): void {
var key: string,
elm: Element = vnode.elm as Element,
oldAttrs = (oldVnode.data as VNodeData).attrs,
attrs = (vnode.data as VNodeData).attrs;
if (!oldAttrs && !attrs) return;
if (oldAttrs === attrs) return;
oldAttrs = oldAttrs || {};
attrs = attrs || {};
// update modified attributes, add new attributes
for (key in attrs) {
const cur = attrs[key];
const old = oldAttrs[key];
if (old !== cur) {
if (cur === true) {
elm.setAttribute(key, "");
} else if (cur === false) {
elm.removeAttribute(key);
} else {
if (key.charCodeAt(0) !== xChar) {
elm.setAttribute(key, cur);
} else if (key.charCodeAt(3) === colonChar) {
// Assume xml namespace
elm.setAttributeNS(xmlNS, key, cur);
} else if (key.charCodeAt(5) === colonChar) {
// Assume xlink namespace
elm.setAttributeNS(xlinkNS, key, cur);
} else {
elm.setAttribute(key, cur);
}
}
}
}
// remove removed attributes
// use `in` operator since the previous `for` iteration uses it (.i.e. add even attributes with undefined value)
// the other option is to remove all attributes with value == undefined
for (key in oldAttrs) {
if (!(key in attrs)) {
elm.removeAttribute(key);
}
}
}
export const attrsModule = {
create: updateAttrs,
update: updateAttrs
} as Module;
//------------------------------------------------------------------------------
// class.ts
//------------------------------------------------------------------------------
function updateClass(oldVnode: VNode, vnode: VNode): void {
var cur: any,
name: string,
elm: Element,
oldClass = (oldVnode.data as VNodeData).class,
klass = (vnode.data as VNodeData).class;
if (!oldClass && !klass) return;
if (oldClass === klass) return;
oldClass = oldClass || {};
klass = klass || {};
elm = vnode.elm as Element;
for (name in oldClass) {
if (!klass[name]) {
elm.classList.remove(name);
}
}
for (name in klass) {
cur = klass[name];
if (cur !== oldClass[name]) {
(elm.classList as any)[cur ? "add" : "remove"](name);
}
}
}
export const classModule = { create: updateClass, update: updateClass } as Module;
+33 -305
View File
@@ -27,6 +27,21 @@ declare global {
}
}
type Props = Record<string, any>;
type Attrs = Record<string, string | number | boolean>;
type On = { [N in keyof HTMLElementEventMap]?: (ev: HTMLElementEventMap[N]) => void } & {
[event: string]: EventListener;
};
export interface Module {
pre: PreHook;
create: CreateHook;
update: UpdateHook;
destroy: DestroyHook;
remove: RemoveHook;
post: PostHook;
}
//------------------------------------------------------------------------------
// vnode.ts
//------------------------------------------------------------------------------
@@ -41,7 +56,7 @@ export interface VNode {
key: Key | undefined;
}
interface VNodeData {
export interface VNodeData {
props?: Props;
attrs?: Attrs;
on?: On;
@@ -86,7 +101,7 @@ function isVnode(vnode: any): vnode is VNode {
type KeyToIndexMap = { [key: string]: number };
type ArraysOf<T> = { [K in keyof T]: (T[K])[] };
type ArraysOf<T> = { [K in keyof T]: T[K][] };
type ModuleHooks = ArraysOf<Module>;
@@ -161,18 +176,12 @@ export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
}
vnode.elm = api.createComment(vnode.text as string);
} else if (sel !== undefined) {
// Parse selector
const hashIdx = sel.indexOf("#");
const dotIdx = sel.indexOf(".", hashIdx);
const hash = hashIdx > 0 ? hashIdx : sel.length;
const dot = dotIdx > 0 ? dotIdx : sel.length;
const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel;
const elm = (vnode.elm =
isDef(data) && isDef((i = (data as VNodeData).ns))
? api.createElementNS(i, tag)
: api.createElement(tag));
if (hash < dot) elm.setAttribute("id", sel.slice(hash + 1, dot));
if (dotIdx > 0) elm.setAttribute("class", sel.slice(dot + 1).replace(/\./g, " "));
const elm =
vnode.elm ||
(vnode.elm =
isDef(data) && isDef((i = (data as VNodeData).ns))
? api.createElementNS(i, sel)
: api.createElement(sel));
for (i = 0, iLen = cbs.create.length; i < iLen; ++i) cbs.create[i](emptyNode, vnode);
if (array(children)) {
for (i = 0, iLen = children.length; i < iLen; ++i) {
@@ -454,10 +463,6 @@ interface DOMAPI {
nextSibling: (node: Node) => Node;
tagName: (elm: Element) => string;
setTextContent: (node: Node, text: string | null) => void;
getTextContent: (node: Node) => string | null;
isElement: (node: Node) => node is Element;
isText: (node: Node) => node is Text;
isComment: (node: Node) => node is Comment;
}
function createElement(tagName: any): HTMLElement {
@@ -504,22 +509,6 @@ function setTextContent(node: Node, text: string | null): void {
node.textContent = text;
}
function getTextContent(node: Node): string | null {
return node.textContent;
}
function isElement(node: Node): node is Element {
return node.nodeType === 1;
}
function isText(node: Node): node is Text {
return node.nodeType === 3;
}
function isComment(node: Node): node is Comment {
return node.nodeType === 8;
}
const htmlDomApi = {
createElement,
createElementNS,
@@ -531,11 +520,7 @@ const htmlDomApi = {
parentNode,
nextSibling,
tagName,
setTextContent,
getTextContent,
isElement,
isText,
isComment
setTextContent
} as DOMAPI;
//------------------------------------------------------------------------------
@@ -573,13 +558,19 @@ type VNodeChildElement = VNode | string | number | undefined | null;
type ArrayOrElement<T> = T | T[];
type VNodeChildren = ArrayOrElement<VNodeChildElement>;
function addNS(data: any, children: VNodes | undefined, sel: string | undefined): void {
export function addNS(data: any, children: VNodes | undefined, sel: string | undefined): void {
if (sel === "dummy") {
// we do not need to add the namespace on dummy elements, they come from a
// subcomponent, which will handle the namespace itself
return;
}
data.ns = "http://www.w3.org/2000/svg";
if (sel !== "foreignObject" && children !== undefined) {
for (let i = 0, iLen = children.length; i < iLen; ++i) {
let childData = children[i].data;
const child = children[i];
let childData = child.data;
if (childData !== undefined) {
addNS(childData, (children[i] as VNode).children as VNodes, children[i].sel);
addNS(childData, (child as VNode).children as VNodes, child.sel);
}
}
}
@@ -621,268 +612,5 @@ export function h(sel: any, b?: any, c?: any): VNode {
children[i] = vnode(undefined, undefined, undefined, children[i], undefined);
}
}
if (
sel[0] === "s" &&
sel[1] === "v" &&
sel[2] === "g" &&
(sel.length === 3 || sel[3] === "." || sel[3] === "#")
) {
addNS(data, children, sel);
}
return vnode(sel, data, children, text, undefined);
}
//------------------------------------------------------------------------------
// module/props.ts
//------------------------------------------------------------------------------
type Props = Record<string, any>;
function updateProps(oldVnode: VNode, vnode: VNode): void {
var key: string,
cur: any,
old: any,
elm = vnode.elm,
oldProps = (oldVnode.data as VNodeData).props,
props = (vnode.data as VNodeData).props;
if (!oldProps && !props) return;
if (oldProps === props) return;
oldProps = oldProps || {};
props = props || {};
for (key in oldProps) {
if (!props[key]) {
delete (elm as any)[key];
}
}
for (key in props) {
cur = props[key];
old = oldProps[key];
if (old !== cur && (key !== "value" || (elm as any)[key] !== cur)) {
(elm as any)[key] = cur;
}
}
}
export const propsModule = {
create: updateProps,
update: updateProps
} as Module;
//------------------------------------------------------------------------------
// module/module.ts
//------------------------------------------------------------------------------
interface Module {
pre: PreHook;
create: CreateHook;
update: UpdateHook;
destroy: DestroyHook;
remove: RemoveHook;
post: PostHook;
}
//------------------------------------------------------------------------------
// module/eventlisteners.ts
//------------------------------------------------------------------------------
type On = { [N in keyof HTMLElementEventMap]?: (ev: HTMLElementEventMap[N]) => void } & {
[event: string]: EventListener;
};
function invokeHandler(handler: any, vnode?: VNode, event?: Event): void {
if (typeof handler === "function") {
// call function handler
handler.call(vnode, event, vnode);
} else if (typeof handler === "object") {
// call handler with arguments
if (typeof handler[0] === "function") {
// special case for single argument for performance
if (handler.length === 2) {
handler[0].call(vnode, handler[1], event, vnode);
} else {
var args = handler.slice(1);
args.push(event);
args.push(vnode);
handler[0].apply(vnode, args);
}
} else {
// call multiple handlers
for (let i = 0, iLen = handler.length; i < iLen; i++) {
invokeHandler(handler[i], vnode, event);
}
}
}
}
function handleEvent(event: Event, vnode: VNode) {
var name = event.type,
on = (vnode.data as VNodeData).on;
// call event handler(s) if exists
if (on && on[name]) {
invokeHandler(on[name], vnode, event);
}
}
function createListener() {
return function handler(event: Event) {
handleEvent(event, (handler as any).vnode);
};
}
function updateEventListeners(oldVnode: VNode, vnode?: VNode): void {
var oldOn = (oldVnode.data as VNodeData).on,
oldListener = (oldVnode as any).listener,
oldElm: Element = oldVnode.elm as Element,
on = vnode && (vnode.data as VNodeData).on,
elm: Element = (vnode && vnode.elm) as Element,
name: string;
// optimization for reused immutable handlers
if (oldOn === on) {
return;
}
// remove existing listeners which no longer used
if (oldOn && oldListener) {
// if element changed or deleted we remove all existing listeners unconditionally
if (!on) {
for (name in oldOn) {
// remove listener if element was changed or existing listeners removed
oldElm.removeEventListener(name, oldListener, false);
}
} else {
for (name in oldOn) {
// remove listener if existing listener removed
if (!on[name]) {
oldElm.removeEventListener(name, oldListener, false);
}
}
}
}
// add new listeners which has not already attached
if (on) {
// reuse existing listener or create new
var listener = ((vnode as any).listener = (oldVnode as any).listener || createListener());
// update vnode for listener
listener.vnode = vnode;
// if element changed or added we add all needed listeners unconditionally
if (!oldOn) {
for (name in on) {
// add listener if element was changed or new listeners added
elm.addEventListener(name, listener, false);
}
} else {
for (name in on) {
// add listener if new listener added
if (!oldOn[name]) {
elm.addEventListener(name, listener, false);
}
}
}
}
}
export const eventListenersModule = {
create: updateEventListeners,
update: updateEventListeners,
destroy: updateEventListeners
} as Module;
//------------------------------------------------------------------------------
// attributes.ts
//------------------------------------------------------------------------------
type Attrs = Record<string, string | number | boolean>;
const xlinkNS = "http://www.w3.org/1999/xlink";
const xmlNS = "http://www.w3.org/XML/1998/namespace";
const colonChar = 58;
const xChar = 120;
function updateAttrs(oldVnode: VNode, vnode: VNode): void {
var key: string,
elm: Element = vnode.elm as Element,
oldAttrs = (oldVnode.data as VNodeData).attrs,
attrs = (vnode.data as VNodeData).attrs;
if (!oldAttrs && !attrs) return;
if (oldAttrs === attrs) return;
oldAttrs = oldAttrs || {};
attrs = attrs || {};
// update modified attributes, add new attributes
for (key in attrs) {
const cur = attrs[key];
const old = oldAttrs[key];
if (old !== cur) {
if (cur === true) {
elm.setAttribute(key, "");
} else if (cur === false) {
elm.removeAttribute(key);
} else {
if (key.charCodeAt(0) !== xChar) {
elm.setAttribute(key, cur);
} else if (key.charCodeAt(3) === colonChar) {
// Assume xml namespace
elm.setAttributeNS(xmlNS, key, cur);
} else if (key.charCodeAt(5) === colonChar) {
// Assume xlink namespace
elm.setAttributeNS(xlinkNS, key, cur);
} else {
elm.setAttribute(key, cur);
}
}
}
}
// remove removed attributes
// use `in` operator since the previous `for` iteration uses it (.i.e. add even attributes with undefined value)
// the other option is to remove all attributes with value == undefined
for (key in oldAttrs) {
if (!(key in attrs)) {
elm.removeAttribute(key);
}
}
}
export const attrsModule = {
create: updateAttrs,
update: updateAttrs
} as Module;
//------------------------------------------------------------------------------
// class.ts
//------------------------------------------------------------------------------
function updateClass(oldVnode: VNode, vnode: VNode): void {
var cur: any,
name: string,
elm: Element,
oldClass = (oldVnode.data as VNodeData).class,
klass = (vnode.data as VNodeData).class;
if (!oldClass && !klass) return;
if (oldClass === klass) return;
oldClass = oldClass || {};
klass = klass || {};
elm = vnode.elm as Element;
for (name in oldClass) {
if (!klass[name]) {
elm.classList.remove(name);
}
}
for (name in klass) {
cur = klass[name];
if (cur !== oldClass[name]) {
(elm.classList as any)[cur ? "add" : "remove"](name);
}
}
}
const classModule = { create: updateClass, update: updateClass } as Module;
//------------------------------------------------------------------------------
// patch
//------------------------------------------------------------------------------
export const patch = init([eventListenersModule, attrsModule, propsModule, classModule]);
+116 -62
View File
@@ -3,43 +3,45 @@
exports[`animations t-transition combined with component 1`] = `
"function anonymous(context,extra
) {
let utils = this.utils;
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
let owner = context;
var h = this.utils.h;
var h = this.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
//COMPONENT
let def3;
let w4 = 4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[4]] : false;
let _2_index = c1.length;
c1.push(null);
let props4 = {};
if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) {
if (utils.shallowEqual(props4, w4.__owl__.renderProps)) {
def3 = w4.__owl__.renderPromise;
} else {
w4.destroy();
w4 = false;
}
let k4 = \`__5__\`;
let w3 = k4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k4]] : false;
let props3 = {};
if (w3 && w3.__owl__.currentFiber && !w3.__owl__.vnode) {
w3.destroy();
w3 = false;
}
if (!w4) {
let componentKey4 = \`Child\`;
let W4 = context.components && context.components[componentKey4] || QWeb.components[componentKey4];
if (!W4) {throw new Error('Cannot find the definition of component \\"' + componentKey4 + '\\"')}
w4 = new W4(parent, props4);
parent.__owl__.cmap[4] = w4.__owl__.id;
def3 = w4.__prepare();
def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;utils.transitionInsert(vn, 'chimay');},remove() {},destroy(vn) {let finalize = () => {
w4.destroy();
};
utils.transitionRemove(vn, 'chimay', finalize);}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;});
if (w3) {
w3.__updateProps(props3, extra.fiber, undefined, undefined);
let pvnode = w3.__owl__.pvnode;
c1.push(pvnode);
} else {
def3 = def3 || w4.__updateProps(props4, extra.forceUpdate, extra.patchQueue);
def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;});
let componentKey3 = \`Child\`;
let W3 = context.constructor.components[componentKey3] || QWeb.components[componentKey3]|| context['Child'];
if (!W3) {throw new Error('Cannot find the definition of component \\"' + componentKey3 + '\\"')}
w3 = new W3(parent, props3);
const __patch3 = w3.__patch;
w3.__patch = fiber => {__patch3.call(w3, fiber); if(!w3.__owl__.transitionInserted){w3.__owl__.transitionInserted = true;utils.transitionInsert(w3.__owl__.vnode, 'chimay');}};
parent.__owl__.cmap[k4] = w3.__owl__.id;
let def2 = w3.__prepare(extra.fiber, undefined, undefined);
let pvnode = h('dummy', {key: k4, hook: {remove() {},destroy(vn) {let finalize = () => {
w3.destroy();
};
delete w3.__owl__.transitionInserted;
utils.transitionRemove(vn, 'chimay', finalize);}}});
const fiber = w3.__owl__.currentFiber;
def2.then(function () { if (fiber.isCompleted) { return; } const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
c1.push(pvnode);
w3.__owl__.pvnode = pvnode;
}
extra.promises.push(def3);
w3.__owl__.parentLastFiberId = extra.fiber.id;
return vn1;
}"
`;
@@ -47,44 +49,94 @@ exports[`animations t-transition combined with component 1`] = `
exports[`animations t-transition combined with t-component and t-if 1`] = `
"function anonymous(context,extra
) {
let utils = this.utils;
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
let owner = context;
var h = this.utils.h;
var h = this.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
if (context['state'].display) {
//COMPONENT
let def3;
let w4 = 4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[4]] : false;
let _2_index = c1.length;
c1.push(null);
let props4 = {};
if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) {
if (utils.shallowEqual(props4, w4.__owl__.renderProps)) {
def3 = w4.__owl__.renderPromise;
} else {
w4.destroy();
w4 = false;
}
let k4 = \`__5__\`;
let w3 = k4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k4]] : false;
let props3 = {};
if (w3 && w3.__owl__.currentFiber && !w3.__owl__.vnode) {
w3.destroy();
w3 = false;
}
if (!w4) {
let componentKey4 = \`Child\`;
let W4 = context.components && context.components[componentKey4] || QWeb.components[componentKey4];
if (!W4) {throw new Error('Cannot find the definition of component \\"' + componentKey4 + '\\"')}
w4 = new W4(parent, props4);
parent.__owl__.cmap[4] = w4.__owl__.id;
def3 = w4.__prepare();
def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;utils.transitionInsert(vn, 'chimay');},remove() {},destroy(vn) {let finalize = () => {
w4.destroy();
};
utils.transitionRemove(vn, 'chimay', finalize);}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;});
if (w3) {
w3.__updateProps(props3, extra.fiber, undefined, undefined);
let pvnode = w3.__owl__.pvnode;
c1.push(pvnode);
} else {
def3 = def3 || w4.__updateProps(props4, extra.forceUpdate, extra.patchQueue);
def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;});
let componentKey3 = \`Child\`;
let W3 = context.constructor.components[componentKey3] || QWeb.components[componentKey3]|| context['Child'];
if (!W3) {throw new Error('Cannot find the definition of component \\"' + componentKey3 + '\\"')}
w3 = new W3(parent, props3);
const __patch3 = w3.__patch;
w3.__patch = fiber => {__patch3.call(w3, fiber); if(!w3.__owl__.transitionInserted){w3.__owl__.transitionInserted = true;utils.transitionInsert(w3.__owl__.vnode, 'chimay');}};
parent.__owl__.cmap[k4] = w3.__owl__.id;
let def2 = w3.__prepare(extra.fiber, undefined, undefined);
let pvnode = h('dummy', {key: k4, hook: {remove() {},destroy(vn) {let finalize = () => {
w3.destroy();
};
delete w3.__owl__.transitionInserted;
utils.transitionRemove(vn, 'chimay', finalize);}}});
const fiber = w3.__owl__.currentFiber;
def2.then(function () { if (fiber.isCompleted) { return; } const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
c1.push(pvnode);
w3.__owl__.pvnode = pvnode;
}
extra.promises.push(def3);
w3.__owl__.parentLastFiberId = extra.fiber.id;
}
return vn1;
}"
`;
exports[`animations t-transition combined with t-component, remove and re-add before transitionend 1`] = `
"function anonymous(context,extra
) {
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
let owner = context;
var h = this.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
if (context['state'].flag) {
//COMPONENT
let k4 = \`__5__\`;
let w3 = k4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k4]] : false;
let props3 = {};
if (w3 && w3.__owl__.currentFiber && !w3.__owl__.vnode) {
w3.destroy();
w3 = false;
}
if (w3) {
w3.__updateProps(props3, extra.fiber, undefined, undefined);
let pvnode = w3.__owl__.pvnode;
c1.push(pvnode);
} else {
let componentKey3 = \`Child\`;
let W3 = context.constructor.components[componentKey3] || QWeb.components[componentKey3]|| context['Child'];
if (!W3) {throw new Error('Cannot find the definition of component \\"' + componentKey3 + '\\"')}
w3 = new W3(parent, props3);
const __patch3 = w3.__patch;
w3.__patch = fiber => {__patch3.call(w3, fiber); if(!w3.__owl__.transitionInserted){w3.__owl__.transitionInserted = true;utils.transitionInsert(w3.__owl__.vnode, 'chimay');}};
parent.__owl__.cmap[k4] = w3.__owl__.id;
let def2 = w3.__prepare(extra.fiber, undefined, undefined);
let pvnode = h('dummy', {key: k4, hook: {remove() {},destroy(vn) {let finalize = () => {
w3.destroy();
};
delete w3.__owl__.transitionInserted;
utils.transitionRemove(vn, 'chimay', finalize);}}});
const fiber = w3.__owl__.currentFiber;
def2.then(function () { if (fiber.isCompleted) { return; } const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
c1.push(pvnode);
w3.__owl__.pvnode = pvnode;
}
w3.__owl__.parentLastFiberId = extra.fiber.id;
}
return vn1;
}"
@@ -93,15 +145,16 @@ exports[`animations t-transition combined with t-component and t-if 1`] = `
exports[`animations t-transition with no delay/duration 1`] = `
"function anonymous(context,extra
) {
var h = this.utils.h;
let utils = this.constructor.utils;
var h = this.h;
let c1 = [], p1 = {key:1};
var vn1 = h('span', p1, c1);
p1.hook = {
insert: vn => {
this.utils.transitionInsert(vn, 'jupiler');
utils.transitionInsert(vn, 'jupiler');
},
remove: (vn, rm) => {
this.utils.transitionRemove(vn, 'jupiler', rm);
utils.transitionRemove(vn, 'jupiler', rm);
},
};
c1.push({text: \`blue\`});
@@ -112,15 +165,16 @@ exports[`animations t-transition with no delay/duration 1`] = `
exports[`animations t-transition, on a simple node (insert) 1`] = `
"function anonymous(context,extra
) {
var h = this.utils.h;
let utils = this.constructor.utils;
var h = this.h;
let c1 = [], p1 = {key:1};
var vn1 = h('span', p1, c1);
p1.hook = {
insert: vn => {
this.utils.transitionInsert(vn, 'chimay');
utils.transitionInsert(vn, 'chimay');
},
remove: (vn, rm) => {
this.utils.transitionRemove(vn, 'chimay', rm);
utils.transitionRemove(vn, 'chimay', rm);
},
};
c1.push({text: \`blue\`});
File diff suppressed because it is too large Load Diff
@@ -1,44 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`props validation props are validated in dev mode (code snapshot) 1`] = `
"function anonymous(context,extra
) {
let utils = this.utils;
let QWeb = this.constructor;
let parent = context;
let owner = context;
var h = this.utils.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
//COMPONENT
let def3;
let w4 = 4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[4]] : false;
let _2_index = c1.length;
c1.push(null);
let props4 = {message:1};
if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) {
if (utils.shallowEqual(props4, w4.__owl__.renderProps)) {
def3 = w4.__owl__.renderPromise;
} else {
w4.destroy();
w4 = false;
}
}
if (!w4) {
let componentKey4 = \`Child\`;
let W4 = context.components && context.components[componentKey4] || QWeb.components[componentKey4];
if (!W4) {throw new Error('Cannot find the definition of component \\"' + componentKey4 + '\\"')}
utils.validateProps(W4, props4)
w4 = new W4(parent, props4);
parent.__owl__.cmap[4] = w4.__owl__.id;
def3 = w4.__prepare();
def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove() {},destroy(vn) {w4.destroy();}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;});
} else {
utils.validateProps(w4.constructor, props4)
def3 = def3 || w4.__updateProps(props4, extra.forceUpdate, extra.patchQueue);
def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;});
}
extra.promises.push(def3);
return vn1;
}"
`;
-17
View File
@@ -1,17 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`connecting a component to store connecting a component to a local store 1`] = `"<div></div>"`;
exports[`connecting a component to store connecting a component to a local store 2`] = `"<div><span>hello</span></div>"`;
exports[`connecting a component to store connecting a component works 1`] = `"<div></div>"`;
exports[`connecting a component to store connecting a component works 2`] = `"<div><span>hello</span></div>"`;
exports[`connecting a component to store deep and shallow connecting a component 1`] = `"<div><span>Kasteel</span></div>"`;
exports[`connecting a component to store deep and shallow connecting a component 2`] = `"<div><span>Kasteel</span></div>"`;
exports[`connecting a component to store deep and shallow connecting a component 3`] = `"<div><span>Bertinchamps</span></div>"`;
exports[`connecting a component to store deep and shallow connecting a component 4`] = `"<div><span>Kasteel</span></div>"`;
@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`various scenarios scenarios with async store updates and some components events 1`] = `"<div><button>Do stuff</button><div><span>Attachment 100</span><span>Name: text.txt</span></div></div>"`;
exports[`various scenarios scenarios with async store updates and some components events 2`] = `"<div><button>Do stuff</button></div>"`;
+100 -75
View File
@@ -1,13 +1,15 @@
import { Component, Env } from "../src/component";
import { QWeb } from "../src/qweb_core";
import { Component, Env } from "../src/component/component";
import { QWeb } from "../src/qweb/index";
import { useState, useRef } from "../src/hooks";
import { xml } from "../src/tags";
import {
makeDeferred,
makeTestFixture,
makeTestEnv,
nextTick,
patchNextFrame,
renderToDOM,
unpatchNextFrame
unpatchNextFrame,
nextTick
} from "./helpers";
//------------------------------------------------------------------------------
@@ -29,6 +31,7 @@ let cssEl: HTMLElement;
beforeEach(() => {
fixture = makeTestFixture();
env = makeTestEnv();
Component.env = env;
qweb = new QWeb();
});
@@ -36,7 +39,7 @@ afterEach(() => {
fixture.remove();
});
class Widget extends Component<any, any, any> {}
class Widget extends Component<any, any> {}
//------------------------------------------------------------------------------
// Tests
@@ -106,9 +109,9 @@ describe("animations", () => {
`<div><span t-if="!state.hide" t-transition="chimay">blue</span></div>`
);
class TestWidget extends Widget {
state = { hide: false };
state = useState({ hide: false });
}
const widget = new TestWidget(env);
const widget = new TestWidget();
// insert widget into the DOM
let def = makeDeferred();
@@ -148,9 +151,10 @@ describe("animations", () => {
`<div><span t-ref="span" t-transition="chimay">blue</span></div>`
);
class TestWidget extends Widget {
state = { hide: false };
state = useState({ hide: false });
span = useRef("span");
}
const widget = new TestWidget(env);
const widget = new TestWidget();
// insert widget into the DOM
let def = makeDeferred();
@@ -163,7 +167,7 @@ describe("animations", () => {
});
await widget.mount(fixture);
spanNode = widget.el!.children[0];
expect(widget.refs.span).toBe(spanNode);
expect(widget.span.el).toBe(spanNode);
expect(spanNode.className).toBe("chimay-enter chimay-enter-active");
await def; // wait for the mocked repaint to be done
spanNode.dispatchEvent(new Event("transitionend")); // mock end of css transition
@@ -175,11 +179,11 @@ describe("animations", () => {
env.qweb.addTemplate("Parent", `<div><Child t-transition="chimay"/></div>`);
env.qweb.addTemplate("Child", `<span>blue</span>`);
class Parent extends Widget {
components = { Child: Child };
}
class Child extends Widget {}
const widget = new Parent(env);
class Parent extends Widget {
static components = { Child: Child };
}
const widget = new Parent();
let def = makeDeferred();
var spanNode;
@@ -214,12 +218,12 @@ describe("animations", () => {
`<div><t t-if="state.display" t-component="Child" t-transition="chimay"/></div>`
);
env.qweb.addTemplate("Child", `<span>blue</span>`);
class Parent extends Widget {
components = { Child: Child };
state = { display: true };
}
class Child extends Widget {}
const widget = new Parent(env);
class Parent extends Widget {
static components = { Child: Child };
state = useState({ display: true });
}
const widget = new Parent();
let def = makeDeferred();
var spanNode;
@@ -250,11 +254,11 @@ describe("animations", () => {
widget.state.display = false;
patchNextFrame(cb => {
expect(fixture.innerHTML).toBe(
'<div><span class="chimay-leave chimay-leave-active" data-owl-key="4">blue</span></div>'
'<div><span class="chimay-leave chimay-leave-active" data-owl-key="__5__">blue</span></div>'
);
cb();
expect(fixture.innerHTML).toBe(
'<div><span class="chimay-leave-active chimay-leave-to" data-owl-key="4">blue</span></div>'
'<div><span class="chimay-leave-active chimay-leave-to" data-owl-key="__5__">blue</span></div>'
);
def.resolve();
});
@@ -263,28 +267,6 @@ describe("animations", () => {
expect(fixture.innerHTML).toBe("<div></div>");
});
test("t-transition combined with t-mounted", async () => {
env.qweb.addTemplate(
"TestWidget",
`<div><span t-if="state.flag" t-mounted="f" t-transition="chimay">blue</span></div>`
);
class TestWidget extends Widget {
state = { flag: false };
f() {}
}
const widget = new TestWidget(env);
widget.f = jest.fn();
await widget.mount(fixture);
patchNextFrame(cb => cb());
expect(widget.f).toHaveBeenCalledTimes(0);
widget.state.flag = true;
await nextTick();
expect(widget.f).toHaveBeenCalledTimes(1);
unpatchNextFrame();
});
test("t-transition, remove and re-add before transitionend", async () => {
expect.assertions(11);
@@ -297,16 +279,14 @@ describe("animations", () => {
</templates>`
);
class Parent extends Widget {
constructor(parent) {
super(parent);
this.state = { flag: false };
}
state = useState({ flag: false });
toggle() {
this.state.flag = !this.state.flag;
}
}
const widget = new Parent(env);
const widget = new Parent();
await widget.mount(fixture);
let button = widget.el!.querySelector("button");
@@ -343,32 +323,23 @@ describe("animations", () => {
});
test("t-transition combined with t-component, remove and re-add before transitionend", async () => {
expect.assertions(11);
expect.assertions(12);
env.qweb.addTemplates(
`<templates>
<div t-name="Parent">
<button t-on-click="toggle">Toggle</button>
<t t-if="state.flag" t-component="Child" t-transition="chimay"/>
</div>
<span t-name="Child">blue</span>
</templates>`
);
class Child extends Widget {}
class Child extends Widget {
static template = xml`<span>blue</span>`;
}
class Parent extends Widget {
components = { Child };
constructor(parent) {
super(parent);
this.state = { flag: false };
}
toggle() {
this.state.flag = !this.state.flag;
}
static template = xml`
<div t-name="Parent">
<t t-if="state.flag" t-component="Child" t-transition="chimay"/>
</div>`;
static components = { Child };
state = useState({ flag: false });
}
const widget = new Parent(env);
const widget = new Parent();
await widget.mount(fixture);
let button = widget.el!.querySelector("button");
expect(env.qweb.templates[Parent.template].fn.toString()).toMatchSnapshot();
let def = makeDeferred();
let phase = "enter";
@@ -381,24 +352,78 @@ describe("animations", () => {
def.resolve();
});
// click display the span
button!.click();
// display the span
widget.state.flag = true;
await def; // wait for the mocked repaint to be done
widget.el!.querySelector("span")!.dispatchEvent(new Event("transitionend")); // mock end of css transition
expect(fixture.innerHTML).toBe('<div><button>Toggle</button><span class="">blue</span></div>');
expect(fixture.innerHTML).toBe('<div><span class="">blue</span></div>');
// click to remove the span, and click again to re-add it before transitionend
def = makeDeferred();
phase = "leave";
button!.click();
widget.state.flag = false;
await def; // wait for the mocked repaint to be done
def = makeDeferred();
phase = "enter";
button!.click();
widget.state.flag = true;
await def; // wait for the mocked repaint to be done
widget.el!.querySelector("span")!.dispatchEvent(new Event("transitionend")); // mock end of css transition
expect(fixture.innerHTML).toBe('<div><button>Toggle</button><span class="">blue</span></div>');
expect(fixture.innerHTML).toBe('<div><span class="" data-owl-key="__5__">blue</span></div>');
});
test("transitionInsert is called the correct amount of times", async () => {
const oldTransitionInsert = QWeb.utils.transitionInsert;
QWeb.utils.transitionInsert = jest.fn(oldTransitionInsert);
class Child extends Widget {
static template = xml`<span>blue</span>`;
}
class Parent extends Widget {
static template = xml`
<div t-name="Parent">
<Child t-if="state.flag" t-transition="chimay"/>
</div>`;
static components = { Child };
state = useState({ flag: false });
}
patchNextFrame(cb => cb());
const widget = new Parent();
await widget.mount(fixture);
widget.state.flag = true;
await nextTick();
widget.el!.querySelector("span")!.dispatchEvent(new Event("transitionend"));
expect(fixture.innerHTML).toBe('<div><span class="">blue</span></div>');
expect(QWeb.utils.transitionInsert).toBeCalledTimes(1);
widget.state.flag = false;
await nextTick();
expect(fixture.innerHTML).toBe(
'<div><span class="chimay-leave-active chimay-leave-to" data-owl-key="__5__">blue</span></div>'
);
expect(QWeb.utils.transitionInsert).toBeCalledTimes(1);
widget.state.flag = true;
await nextTick();
expect(fixture.innerHTML).toBe(
'<div><span class="chimay-enter-active chimay-enter-to" data-owl-key="__5__">blue</span></div>'
);
expect(QWeb.utils.transitionInsert).toBeCalledTimes(2);
widget.state.flag = false;
await nextTick();
widget.state.flag = true;
await nextTick();
expect(QWeb.utils.transitionInsert).toBeCalledTimes(3);
widget.el!.querySelector("span")!.dispatchEvent(new Event("transitionend"));
expect(fixture.innerHTML).toBe('<div><span class="" data-owl-key="__5__">blue</span></div>');
QWeb.utils.transitionInsert = oldTransitionInsert;
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`props validation props are validated in dev mode (code snapshot) 1`] = `
"function anonymous(context,extra
) {
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
let owner = context;
var h = this.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
//COMPONENT
let k4 = \`__5__\`;
let w3 = k4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k4]] : false;
let props3 = {message:1};
if (w3 && w3.__owl__.currentFiber && !w3.__owl__.vnode) {
w3.destroy();
w3 = false;
}
if (w3) {
w3.__updateProps(props3, extra.fiber, undefined, undefined);
let pvnode = w3.__owl__.pvnode;
c1.push(pvnode);
} else {
let componentKey3 = \`Child\`;
let W3 = context.constructor.components[componentKey3] || QWeb.components[componentKey3]|| context['Child'];
if (!W3) {throw new Error('Cannot find the definition of component \\"' + componentKey3 + '\\"')}
w3 = new W3(parent, props3);
parent.__owl__.cmap[k4] = w3.__owl__.id;
let def2 = w3.__prepare(extra.fiber, undefined, undefined);
let pvnode = h('dummy', {key: k4, hook: {remove() {},destroy(vn) {w3.destroy();}}});
const fiber = w3.__owl__.currentFiber;
def2.then(function () { if (fiber.isCompleted) { return; } const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
c1.push(pvnode);
w3.__owl__.pvnode = pvnode;
}
w3.__owl__.parentLastFiberId = extra.fiber.id;
return vn1;
}"
`;
File diff suppressed because it is too large Load Diff
+843
View File
@@ -0,0 +1,843 @@
import { Component, Env } from "../../src/component/component";
import { makeTestFixture, makeTestEnv, nextTick } from "../helpers";
import { useState } from "../../src/hooks";
import { QWeb } from "../../src/qweb";
import { xml } from "../../src/tags";
//------------------------------------------------------------------------------
// Setup and helpers
//------------------------------------------------------------------------------
let fixture: HTMLElement;
let env: Env;
let dev: boolean = false;
beforeEach(() => {
fixture = makeTestFixture();
env = makeTestEnv();
Component.env = env;
dev = QWeb.dev;
QWeb.dev = true;
});
afterEach(() => {
fixture.remove();
QWeb.dev = dev;
});
class Widget extends Component<any, any> {}
//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
describe("props validation", () => {
test("validation is only done in dev mode", async () => {
class TestWidget extends Widget {
static props = ["message"];
static template = xml`<div>hey</div>`;
}
class Parent extends Widget {
static components = { TestWidget };
static template = xml`<div><TestWidget /></div>`;
}
let error;
QWeb.dev = true;
try {
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe(`Missing props 'message' (component 'TestWidget')`);
error = undefined;
QWeb.dev = false;
try {
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
});
test("props: list of strings", async () => {
class TestWidget extends Widget {
static props = ["message"];
static template = xml`<div>hey</div>`;
}
class Parent extends Widget {
static components = { TestWidget };
static template = xml`<div><TestWidget /></div>`;
}
let error;
try {
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe(`Missing props 'message' (component 'TestWidget')`);
});
test("validate simple types", async () => {
const Tests = [
{ type: Number, ok: 1, ko: "1" },
{ type: Boolean, ok: true, ko: "1" },
{ type: String, ok: "1", ko: 1 },
{ type: Object, ok: {}, ko: "1" },
{ type: Date, ok: new Date(), ko: "1" },
{ type: Function, ok: () => {}, ko: "1" }
];
let props;
class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="p"/></div>`;
get p() {
return props.p;
}
}
for (let test of Tests) {
let TestWidget = class extends Widget {
static template = xml`<div>hey</div>`;
static props = { p: test.type };
};
Parent.components = { TestWidget };
let error;
props = {};
try {
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe(`Missing props 'p' (component '_a')`);
error = undefined;
props = { p: test.ok };
try {
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
props = { p: test.ko };
try {
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Invalid Prop 'p' in component '_a'");
}
});
test("validate simple types, alternate form", async () => {
const Tests = [
{ type: Number, ok: 1, ko: "1" },
{ type: Boolean, ok: true, ko: "1" },
{ type: String, ok: "1", ko: 1 },
{ type: Object, ok: {}, ko: "1" },
{ type: Date, ok: new Date(), ko: "1" },
{ type: Function, ok: () => {}, ko: "1" }
];
let props;
class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="p"/></div>`;
get p() {
return props.p;
}
}
for (let test of Tests) {
let TestWidget = class extends Component<any, any> {
static props = { p: { type: test.type } };
static template = xml`<div>hey</div>`;
};
Parent.components = { TestWidget };
let error;
props = {};
try {
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe(`Missing props 'p' (component '_a')`);
error = undefined;
props = { p: test.ok };
try {
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
props = { p: test.ko };
try {
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Invalid Prop 'p' in component '_a'");
}
});
test("can validate a prop with multiple types", async () => {
class TestWidget extends Component<any, any> {
static template = xml`<div>hey</div>`;
static props = { p: [String, Boolean] };
}
class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="p"/></div>`;
static components = { TestWidget };
get p() {
return props.p;
}
}
let error;
let props;
try {
props = { p: "string" };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
try {
props = { p: true };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
try {
props = { p: 1 };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'");
});
test("can validate an optional props", async () => {
class TestWidget extends Component<any, any> {
static template = xml`<div>hey</div>`;
static props = { p: { type: String, optional: true } };
}
class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="p"/></div>`;
static components = { TestWidget };
get p() {
return props.p;
}
}
let error;
let props;
try {
props = { p: "key" };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
try {
props = {};
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
try {
props = { p: 1 };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'");
});
test("can validate an array with given primitive type", async () => {
class TestWidget extends Component<any, any> {
static template = xml`<div>hey</div>`;
static props = { p: { type: Array, element: String } };
}
class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="p"/></div>`;
static components = { TestWidget };
get p() {
return props.p;
}
}
let error;
let props;
try {
props = { p: [] };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
try {
props = { p: ["string"] };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
try {
props = { p: [1] };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
error = undefined;
try {
props = { p: ["string", 1] };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
});
test("can validate an array with multiple sub element types", async () => {
class TestWidget extends Component<any, any> {
static template = xml`<div>hey</div>`;
static props = { p: { type: Array, element: [String, Boolean] } };
}
class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="p"/></div>`;
static components = { TestWidget };
get p() {
return props.p;
}
}
let error;
let props;
try {
props = { p: [] };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
try {
props = { p: ["string"] };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
try {
props = { p: [false, true, "string"] };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
try {
props = { p: [true, 1] };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'");
});
test("can validate an object with simple shape", async () => {
class TestWidget extends Component<any, any> {
static template = xml`<div>hey</div>`;
static props = {
p: { type: Object, shape: { id: Number, url: String } }
};
}
class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="p"/></div>`;
static components = { TestWidget };
get p() {
return props.p;
}
}
let error;
let props;
try {
props = { p: { id: 1, url: "url" } };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
try {
props = { p: { id: 1, url: "url", extra: true } };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Invalid prop 'p' in component TestWidget (unknown prop 'extra')");
try {
props = { p: { id: "1", url: "url" } };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'");
error = undefined;
try {
props = { p: { id: 1 } };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'");
});
test("can validate recursively complicated prop def", async () => {
class TestWidget extends Component<any, any> {
static template = xml`<div>hey</div>`;
static props = {
p: {
type: Object,
shape: {
id: Number,
url: [Boolean, { type: Array, element: Number }]
}
}
};
}
class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="p"/></div>`;
static components = { TestWidget };
get p() {
return props.p;
}
}
let error;
let props;
try {
props = { p: { id: 1, url: true } };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
try {
props = { p: { id: 1, url: [12] } };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
try {
props = { p: { id: 1, url: [12, true] } };
const p = new Parent();
await p.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'");
});
test("can validate optional attributes in nested sub props", () => {
class TestComponent extends Component<any, any> {
static props = {
myprop: {
type: Array,
element: {
type: Object,
shape: {
num: { type: Number, optional: true }
}
}
}
};
}
let error;
try {
QWeb.utils.validateProps(TestComponent, { myprop: [{}] });
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
try {
QWeb.utils.validateProps(TestComponent, { myprop: [{ a: 1 }] });
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe(
"Invalid prop 'myprop' in component TestComponent (unknown prop 'a')"
);
});
test("can validate with a custom validator", () => {
class TestComponent extends Component<any, any> {
static props = {
size: {
validate: e => ["small", "medium", "large"].includes(e)
}
};
}
let error;
try {
QWeb.utils.validateProps(TestComponent, { size: "small" });
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
try {
QWeb.utils.validateProps(TestComponent, { size: "abcdef" });
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Invalid Prop 'size' in component 'TestComponent'");
});
test("can validate with a custom validator, and a type", () => {
const validator = jest.fn(n => 0 <= n && n <= 10);
class TestComponent extends Component<any, any> {
static props = {
n: {
type: Number,
validate: validator
}
};
}
let error;
try {
QWeb.utils.validateProps(TestComponent, { n: 3 });
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
expect(validator).toBeCalledTimes(1);
try {
QWeb.utils.validateProps(TestComponent, { n: "str" });
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Invalid Prop 'n' in component 'TestComponent'");
expect(validator).toBeCalledTimes(1);
error = null;
try {
QWeb.utils.validateProps(TestComponent, { n: 100 });
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Invalid Prop 'n' in component 'TestComponent'");
expect(validator).toBeCalledTimes(2);
});
test("props are validated in dev mode (code snapshot)", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="App">
<Child message="1"/>
</div>
<div t-name="Child"><t t-esc="props.message"/></div>
</templates>`);
class Child extends Widget {
static props = ["message"];
}
class App extends Widget {
static components = { Child };
}
const app = new App();
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div>1</div></div>");
// need to make sure there are 2 call to update props. one at component
// creation, and one at update time.
expect(env.qweb.templates.App.fn.toString()).toMatchSnapshot();
});
test("props: list of strings with optional props", async () => {
class TestWidget extends Widget {
static props = ["message", "someProp?"];
}
expect(() => {
QWeb.utils.validateProps(TestWidget, { someProp: 1 });
}).toThrow();
expect(() => {
QWeb.utils.validateProps(TestWidget, { message: 1 });
}).not.toThrow();
});
test("props: can be defined with a boolean", async () => {
class TestWidget extends Widget {
static props = { message: true };
}
expect(() => {
QWeb.utils.validateProps(TestWidget, {});
}).toThrow();
});
test("props with type array, and no element", async () => {
class TestWidget extends Widget {
static props = { myprop: { type: Array } };
}
expect(() => {
QWeb.utils.validateProps(TestWidget, { myprop: [1] });
}).not.toThrow();
expect(() => {
QWeb.utils.validateProps(TestWidget, { myprop: 1 });
}).toThrow(`Invalid Prop 'myprop' in component 'TestWidget'`);
});
test("props with type object, and no shape", async () => {
class TestWidget extends Widget {
static props = { myprop: { type: Object } };
}
expect(() => {
QWeb.utils.validateProps(TestWidget, { myprop: { a: 3 } });
}).not.toThrow();
expect(() => {
QWeb.utils.validateProps(TestWidget, { myprop: false });
}).toThrow(`Invalid Prop 'myprop' in component 'TestWidget'`);
});
test("props: extra props cause an error", async () => {
class TestWidget extends Widget {
static props = ["message"];
}
expect(() => {
QWeb.utils.validateProps(TestWidget, { message: 1, flag: true });
}).toThrow();
});
test("props: extra props cause an error, part 2", async () => {
class TestWidget extends Widget {
static props = { message: true };
}
expect(() => {
QWeb.utils.validateProps(TestWidget, { message: 1, flag: true });
}).toThrow();
});
test("props: optional prop do not cause an error", async () => {
class TestWidget extends Widget {
static props = ["message?"];
}
expect(() => {
QWeb.utils.validateProps(TestWidget, { message: 1 });
}).not.toThrow();
});
test("optional prop do not cause an error if value is undefined", async () => {
class TestWidget extends Widget {
static props = { message: { type: String, optional: true } };
}
expect(() => {
QWeb.utils.validateProps(TestWidget, { message: undefined });
}).not.toThrow();
expect(() => {
QWeb.utils.validateProps(TestWidget, { message: null });
}).toThrow();
});
test("missing required boolean prop causes an error", async () => {
class TestWidget extends Widget {
static props = ["p"];
static template = xml`<span><t t-if="props.p">hey</t></span>`;
}
class App extends Widget {
static template = xml`<div><TestWidget/></div>`;
static components = { TestWidget };
}
const w = new App(undefined, {});
let error;
try {
await w.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Missing props 'p' (component 'TestWidget')");
});
test("props are validated whenever component is updated", async () => {
let error;
class TestWidget extends Component<any, any> {
static props = { p: { type: Number } };
static template = xml`<div><t t-esc="props.p"/></div>`;
async __updateProps() {
try {
await Component.prototype.__updateProps.apply(this, arguments);
} catch (e) {
error = e;
}
}
}
class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="state.p"/></div>`;
static components = { TestWidget };
state: any = useState({ p: 1 });
}
const w = new Parent();
await w.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div>1</div></div>");
w.state.p = undefined;
await nextTick();
expect(error).toBeDefined();
expect(error.message).toBe("Missing props 'p' (component 'TestWidget')");
});
test("default values are applied before validating props at update", async () => {
class TestWidget extends Component<any, any> {
static props = { p: { type: Number } };
static template = xml`<div><t t-esc="props.p"/></div>`;
static defaultProps = { p: 4 };
}
class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="state.p"/></div>`;
static components = { TestWidget };
state: any = useState({ p: 1 });
}
const w = new Parent();
await w.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div>1</div></div>");
w.state.p = undefined;
await nextTick();
expect(fixture.innerHTML).toBe("<div><div>4</div></div>");
});
});
describe("default props", () => {
test("can set default values", async () => {
class TestWidget extends Component<any, any> {
static defaultProps = { p: 4 };
static template = xml`<div><t t-esc="props.p"/></div>`;
}
class Parent extends Component<any, any> {
static template = xml`<div><TestWidget /></div>`;
static components = { TestWidget };
}
const w = new Parent();
await w.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div>4</div></div>");
});
test("default values are also set whenever component is updated", async () => {
class TestWidget extends Widget {
static template = xml`<div><t t-esc="props.p"/></div>`;
static defaultProps = { p: 4 };
}
class Parent extends Widget {
static template = xml`<div><TestWidget p="state.p"/></div>`;
static components = { TestWidget };
state: any = useState({ p: 1 });
}
const w = new Parent();
await w.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div>1</div></div>");
w.state.p = undefined;
await nextTick();
expect(fixture.innerHTML).toBe("<div><div>4</div></div>");
});
test("can set default required boolean values", async () => {
class TestWidget extends Widget {
static props = ["p", "q"];
static defaultProps = { p: true, q: false };
static template = xml`<span><t t-if="props.p">hey</t><t t-if="!props.q">hey</t></span>`;
}
class App extends Widget {
static template = xml`<div><TestWidget/></div>`;
static components = { TestWidget };
}
const w = new App(undefined, {});
await w.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>heyhey</span></div>");
});
});
+339
View File
@@ -0,0 +1,339 @@
import { makeDeferred, makeTestEnv, makeTestFixture, nextTick } from "./helpers";
import { Component } from "../src/component/component";
import { Context, useContext } from "../src/context";
import { xml } from "../src/tags";
import { useState } from "../src/hooks";
//------------------------------------------------------------------------------
// Setup and helpers
//------------------------------------------------------------------------------
// We create before each test:
// - fixture: a div, appended to the DOM, intended to be the target of dom
// manipulations. Note that it is removed after each test.
// - a test env, necessary to create components, that is set as env
let fixture: HTMLElement;
beforeEach(() => {
fixture = makeTestFixture();
Component.env = makeTestEnv();
});
afterEach(() => {
fixture.remove();
});
//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
describe("Context", () => {
test("very simple use, with initial value", async () => {
const testContext = new Context({ value: 123 });
class Test extends Component<any, any> {
static template = xml`<div><t t-esc="contextObj.value"/></div>`;
contextObj = useContext(testContext);
}
const test = new Test();
await test.mount(fixture);
expect(fixture.innerHTML).toBe("<div>123</div>");
});
test("useContext hook is reactive, for one component", async () => {
const testContext = new Context({ value: 123 });
class Test extends Component<any, any> {
static template = xml`<div><t t-esc="contextObj.value"/></div>`;
contextObj = useContext(testContext);
}
const test = new Test();
await test.mount(fixture);
expect(fixture.innerHTML).toBe("<div>123</div>");
test.contextObj.value = 321;
await nextTick();
expect(fixture.innerHTML).toBe("<div>321</div>");
});
test("two components can subscribe to same context", async () => {
const testContext = new Context({ value: 123 });
class Child extends Component<any, any> {
static template = xml`<span><t t-esc="contextObj.value"/></span>`;
contextObj = useContext(testContext);
}
class Parent extends Component<any, any> {
static template = xml`<div><Child /><Child /></div>`;
static components = { Child };
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>123</span><span>123</span></div>");
testContext.state.value = 321;
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>321</span><span>321</span></div>");
});
test("two async components are updated in parallel", async () => {
const testContext = new Context({ value: 123 });
const def = makeDeferred();
const steps: string[] = [];
class Child extends Component<any, any> {
static template = xml`<span><t t-esc="contextObj.value"/></span>`;
contextObj = useContext(testContext);
async render() {
steps.push("render");
await def;
return super.render();
}
}
class Parent extends Component<any, any> {
static template = xml`<div><Child /><Child /></div>`;
static components = { Child };
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>123</span><span>123</span></div>");
testContext.state.value = 321;
await nextTick();
expect(steps).toEqual(["render", "render"]);
expect(fixture.innerHTML).toBe("<div><span>123</span><span>123</span></div>");
def.resolve();
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>321</span><span>321</span></div>");
});
test("two async components on two levels are updated (mostly) in parallel", async () => {
const testContext = new Context({ value: 123 });
const def = makeDeferred();
const steps: string[] = [];
class SlowComp extends Component<any, any> {
static template = xml`<p><t t-esc="props.value"/></p>`;
willUpdateProps() {
return def;
}
}
class Child extends Component<any, any> {
static template = xml`<span><SlowComp value="contextObj.value"/></span>`;
static components = { SlowComp };
contextObj = useContext(testContext);
render() {
steps.push("render");
return super.render();
}
}
class Parent extends Component<any, any> {
static template = xml`<div><Child /><Child /></div>`;
static components = { Child };
}
class App extends Component<any, any> {
static template = xml`<div><Child /><Parent /></div>`;
static components = { Child, Parent };
}
const app = new App();
await app.mount(fixture);
expect(fixture.innerHTML).toBe(
"<div><span><p>123</p></span><div><span><p>123</p></span><span><p>123</p></span></div></div>"
);
testContext.state.value = 321;
await nextTick();
expect(steps).toEqual(["render"]);
expect(fixture.innerHTML).toBe(
"<div><span><p>123</p></span><div><span><p>123</p></span><span><p>123</p></span></div></div>"
);
def.resolve();
await nextTick();
// we need to wait for an extra tick because it could happen (even though it
// is rare) that the second batch of renderings is not done yet, because
// the initial promise has been given to the macrotask queue, so a small
// delay happens.
await nextTick();
expect(steps).toEqual(["render", "render", "render"]);
expect(fixture.innerHTML).toBe(
"<div><span><p>321</p></span><div><span><p>321</p></span><span><p>321</p></span></div></div>"
);
});
test("one components can subscribe twice to same context", async () => {
const testContext = new Context({ a: 1, b: 2 });
const steps: string[] = [];
class Child extends Component<any, any> {
static template = xml`<span><t t-esc="contextObj1.a"/><t t-esc="contextObj2.b"/></span>`;
contextObj1 = useContext(testContext);
contextObj2 = useContext(testContext);
__render(fiber) {
steps.push("child");
return super.__render(fiber);
}
}
class Parent extends Component<any, any> {
static template = xml`<div><Child /></div>`;
static components = { Child };
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>12</span></div>");
expect(steps).toEqual(["child"]);
testContext.state.a = 3;
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>32</span></div>");
expect(steps).toEqual(["child", "child"]);
});
test("parent and children subscribed to same context", async () => {
const testContext = new Context({ a: 123, b: 321 });
const steps: string[] = [];
class Child extends Component<any, any> {
static template = xml`<span><t t-esc="contextObj.a"/></span>`;
contextObj = useContext(testContext);
__render(fiber) {
steps.push("child");
return super.__render(fiber);
}
}
class Parent extends Component<any, any> {
static template = xml`<div><Child /><t t-esc="contextObj.b"/></div>`;
static components = { Child };
contextObj = useContext(testContext);
__render(fiber) {
steps.push("parent");
return super.__render(fiber);
}
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>123</span>321</div>");
expect(steps).toEqual(["parent", "child"]);
parent.contextObj.a = 124;
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>124</span>321</div>");
// we only want one render from the child here, not two
expect(steps).toEqual(["parent", "child", "parent", "child"]);
});
test("destroyed component is inactive", async () => {
const testContext = new Context({ a: 123 });
const steps: string[] = [];
class Child extends Component<any, any> {
static template = xml`<span><t t-esc="contextObj.a"/></span>`;
contextObj = useContext(testContext);
__render(fiber) {
steps.push("child");
return super.__render(fiber);
}
}
class Parent extends Component<any, any> {
static template = xml`<div><Child t-if="state.flag"/></div>`;
static components = { Child };
state = useState({ flag: true });
__render(fiber) {
steps.push("parent");
return super.__render(fiber);
}
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>123</span></div>");
expect(steps).toEqual(["parent", "child"]);
expect(testContext.subscriptions.update.length).toBe(1);
parent.state.flag = false;
await nextTick();
expect(fixture.innerHTML).toBe("<div></div>");
expect(steps).toEqual(["parent", "child", "parent"]);
// kind of whitebox...
// we make sure we do not have any pending subscriptions to the 'update'
// event
expect(testContext.subscriptions.update.length).toBe(0);
});
test("destroyed component before being mounted is inactive", async () => {
const testContext = new Context({ a: 123 });
class Child extends Component<any, any> {
static template = xml`<span><t t-esc="contextObj.a"/></span>`;
contextObj = useContext(testContext);
willStart() {
return makeDeferred();
}
}
class Parent extends Component<any, any> {
static template = xml`<div><Child t-if="state.flag"/></div>`;
static components = { Child };
state = useState({ flag: true });
}
const parent = new Parent();
const prom = parent.mount(fixture);
await nextTick(); // wait for Child to be instantiated
expect(testContext.subscriptions.update.length).toBe(1);
parent.state.flag = false;
await prom;
expect(fixture.innerHTML).toBe("<div></div>");
// kind of whitebox...
// we make sure we do not have any pending subscriptions to the 'update'
// event
expect(testContext.subscriptions.update.length).toBe(0);
});
test("concurrent renderings", async () => {
const testContext = new Context({ x: { n: 1 }, key: "x" });
const def = makeDeferred();
let stateC;
class ComponentC extends Component<any, any> {
static template = xml`<span><t t-esc="context[props.key].n"/><t t-esc="state.x"/></span>`;
context = useContext(testContext);
state = useState({ x: "a" });
constructor(parent, props) {
super(parent, props);
stateC = this.state;
}
}
class ComponentB extends Component<any, any> {
static components = { ComponentC };
static template = xml`<p><ComponentC key="props.key"/></p>`;
willUpdateProps() {
return def;
}
}
class ComponentA extends Component<any, any> {
static components = { ComponentB };
static template = xml`<div><ComponentB key="context.key"/></div>`;
context = useContext(testContext);
}
const component = new ComponentA();
await component.mount(fixture);
expect(fixture.innerHTML).toBe("<div><p><span>1a</span></p></div>");
testContext.state.key = "y";
testContext.state.y = { n: 2 };
delete testContext.state.x;
await nextTick();
expect(fixture.innerHTML).toBe("<div><p><span>1a</span></p></div>");
stateC.x = "b";
await nextTick();
expect(fixture.innerHTML).toBe("<div><p><span>1a</span></p></div>");
def.resolve();
await nextTick();
expect(fixture.innerHTML).toBe("<div><p><span>2b</span></p></div>");
});
});
@@ -1,4 +1,4 @@
import { EventBus } from "../src/event_bus";
import { EventBus } from "../../src/core/event_bus";
describe("event bus behaviour", () => {
test("can subscribe and be notified", () => {
+432
View File
@@ -0,0 +1,432 @@
import { Observer } from "../../src/core/observer";
import { nextMicroTick } from "../helpers";
describe("observer", () => {
test("properly observe objects", () => {
const observer = new Observer();
const obj: any = observer.observe({});
expect(typeof obj).toBe("object");
expect(observer.revNumber(obj)).toBe(1);
expect(observer.rev).toBe(1);
const obj2: any = observer.observe({ a: 1 });
expect(observer.revNumber(obj2)).toBe(1);
expect(observer.revNumber(obj)).toBe(1);
expect(observer.rev).toBe(1);
obj2.a = 2;
expect(observer.revNumber(obj)).toBe(1);
expect(observer.revNumber(obj2)).toBe(2);
expect(observer.rev).toBe(2);
expect(obj2).toEqual({
a: 2
});
});
test("properly handle null or undefined", () => {
const observer = new Observer();
const obj: any = observer.observe({ a: null, b: undefined });
expect(observer.revNumber(obj)).toBe(1);
expect(observer.rev).toBe(1);
obj.a = 3;
expect(observer.revNumber(obj)).toBe(2);
expect(observer.rev).toBe(2);
obj.b = 5;
expect(observer.revNumber(obj)).toBe(3);
expect(observer.rev).toBe(3);
obj.a = null;
obj.b = undefined;
expect(observer.revNumber(obj)).toBe(5);
expect(observer.rev).toBe(5);
expect(obj).toEqual({
a: null,
b: undefined
});
});
test("properly handle dates", () => {
const observer = new Observer();
const date = new Date();
const obj: any = observer.observe({ date });
expect(observer.revNumber(obj)).toBe(1);
expect(observer.rev).toBe(1);
expect(typeof obj.date.getFullYear()).toBe("number");
expect(obj.date).toBe(date);
obj.date = new Date();
expect(observer.revNumber(obj)).toBe(2);
expect(observer.rev).toBe(2);
expect(obj.date).not.toBe(date);
});
test("can change values in array", () => {
const observer = new Observer();
const obj: any = observer.observe({ arr: [1, 2] });
expect(Array.isArray(obj.arr)).toBe(true);
expect(observer.revNumber(obj.arr)).toBe(1);
expect(observer.rev).toBe(1);
obj.arr[0] = "nope";
expect(observer.revNumber(obj.arr)).toBe(2);
expect(observer.revNumber(obj)).toBe(2);
expect(observer.rev).toBe(2);
});
test("various object property changes", () => {
const observer = new Observer();
const obj: any = observer.observe({ a: 1 });
expect(observer.revNumber(obj)).toBe(1);
expect(observer.rev).toBe(1);
obj.a = 2;
expect(observer.revNumber(obj)).toBe(2);
expect(observer.rev).toBe(2);
// same value again
obj.a = 2;
expect(observer.revNumber(obj)).toBe(2);
expect(observer.rev).toBe(2);
obj.a = 3;
expect(observer.revNumber(obj)).toBe(3);
expect(observer.rev).toBe(3);
});
test("properly observe arrays", () => {
const observer = new Observer();
const arr: any = observer.observe([]);
expect(Array.isArray(arr)).toBe(true);
expect(arr.length).toBe(0);
expect(observer.revNumber(arr)).toBe(1);
expect(observer.rev).toBe(1);
arr.push(1);
expect(observer.revNumber(arr)).toBe(2);
expect(observer.rev).toBe(2);
expect(arr.length).toBe(1);
expect(arr).toEqual([1]);
arr.splice(1, 0, "hey");
expect(observer.revNumber(arr)).toBe(3);
expect(observer.rev).toBe(3);
expect(arr).toEqual([1, "hey"]);
expect(arr.length).toBe(2);
arr.unshift("lindemans");
//it generates 3 primitive operations
expect(observer.revNumber(arr)).toBe(6);
expect(observer.rev).toBe(6);
expect(arr).toEqual(["lindemans", 1, "hey"]);
expect(arr.length).toBe(3);
arr.reverse();
//it generates 2 primitive operations
expect(observer.revNumber(arr)).toBe(8);
expect(observer.rev).toBe(8);
expect(arr).toEqual(["hey", 1, "lindemans"]);
expect(arr.length).toBe(3);
arr.pop(); // one set, one delete
expect(observer.revNumber(arr)).toBe(10);
expect(observer.rev).toBe(10);
expect(arr).toEqual(["hey", 1]);
expect(arr.length).toBe(2);
arr.shift(); // 2 sets, 1 delete
expect(observer.revNumber(arr)).toBe(13);
expect(observer.rev).toBe(13);
expect(arr).toEqual([1]);
expect(arr.length).toBe(1);
});
test("object pushed into arrays are observed", () => {
const observer = new Observer();
const arr: any = observer.observe([]);
expect(observer.rev).toBe(1);
arr.push({ kriek: 5 });
expect(observer.rev).toBe(2);
expect(observer.revNumber(arr)).toBe(2);
expect(observer.revNumber(arr[0])).toBe(2);
arr[0].kriek = 6;
expect(observer.rev).toBe(3);
expect(observer.revNumber(arr)).toBe(3);
expect(observer.revNumber(arr[0])).toBe(3);
});
test("set new property on observed object", async () => {
const observer = new Observer();
observer.notifyCB = jest.fn();
const state: any = observer.observe({ a: 1 });
expect(observer.notifyCB).toBeCalledTimes(0);
expect(observer.rev).toBe(1);
expect(observer.revNumber(state)).toBe(1);
state.b = 8;
await nextMicroTick();
expect(observer.notifyCB).toBeCalledTimes(1);
expect(observer.rev).toBe(2);
expect(observer.revNumber(state)).toBe(2);
expect(state.b).toBe(8);
});
test("delete property from observed object", async () => {
const observer = new Observer();
observer.notifyCB = jest.fn();
const state: any = observer.observe({ a: 1, b: 8 });
observer.observe(state);
expect(observer.notifyCB).toBeCalledTimes(0);
expect(observer.rev).toBe(1);
expect(observer.revNumber(state)).toBe(1);
delete state.b;
await nextMicroTick();
expect(observer.notifyCB).toBeCalledTimes(1);
expect(observer.rev).toBe(2);
expect(observer.revNumber(state)).toBe(2);
expect(state).toEqual({ a: 1 });
});
test("set element in observed array", async () => {
const observer = new Observer();
observer.notifyCB = jest.fn();
const state: any = observer.observe(["a"]);
expect(observer.rev).toBe(1);
expect(observer.revNumber(state)).toBe(1);
expect(observer.notifyCB).toBeCalledTimes(0);
state[1] = "b";
await nextMicroTick();
expect(observer.rev).toBe(2);
expect(observer.revNumber(state)).toBe(2);
expect(observer.notifyCB).toBeCalledTimes(1);
expect(state).toEqual(["a", "b"]);
});
test("properly observe arrays in object", () => {
const observer = new Observer();
const state: any = observer.observe({ arr: [] });
expect(observer.rev).toBe(1);
expect(observer.revNumber(state.arr)).toBe(1);
expect(state.arr.length).toBe(0);
state.arr.push(1);
expect(observer.rev).toBe(2);
expect(observer.revNumber(state.arr)).toBe(2);
expect(state.arr.length).toBe(1);
});
test("properly observe objects in array", () => {
const observer = new Observer();
const state: any = observer.observe({ arr: [{ something: 1 }] });
observer.observe(state);
expect(observer.rev).toBe(1);
expect(observer.revNumber(state.arr)).toBe(1);
expect(observer.revNumber(state.arr[0])).toBe(1);
state.arr[0].something = 2;
expect(observer.rev).toBe(2);
expect(observer.revNumber(state.arr)).toBe(2);
expect(observer.revNumber(state.arr[0])).toBe(2);
});
test("properly observe objects in object", () => {
const observer = new Observer();
const state: any = observer.observe({ a: { b: 1 } });
expect(observer.rev).toBe(1);
expect(observer.revNumber(state)).toBe(1);
expect(observer.revNumber(state.a)).toBe(1);
state.a.b = 2;
expect(observer.rev).toBe(2);
expect(observer.revNumber(state)).toBe(2);
expect(observer.revNumber(state.a)).toBe(2);
});
test("reobserve new object values", () => {
const observer = new Observer();
const obj: any = observer.observe({ a: 1 });
expect(observer.rev).toBe(1);
expect(observer.revNumber(obj)).toBe(1);
obj.a = { b: 2 };
expect(observer.rev).toBe(2);
expect(observer.revNumber(obj)).toBe(2);
expect(observer.revNumber(obj.a)).toBe(2);
obj.a.b = 3;
expect(observer.rev).toBe(3);
expect(observer.revNumber(obj)).toBe(3);
expect(observer.revNumber(obj.a)).toBe(3);
});
test("deep observe misc changes", () => {
const observer = new Observer();
const state: any = observer.observe({ o: { a: 1 }, arr: [1], n: 13 });
expect(observer.revNumber(state)).toBe(1);
state.o.a = 2;
expect(observer.rev).toBe(2);
expect(observer.revNumber(state)).toBe(2);
state.arr.push(2);
expect(observer.rev).toBe(3);
expect(observer.revNumber(state)).toBe(3);
state.n = 155;
expect(observer.rev).toBe(4);
expect(observer.revNumber(state)).toBe(4);
});
test("properly handle already observed state", () => {
const observer = new Observer();
const obj1: any = observer.observe({ a: 1 });
const obj2: any = observer.observe({ b: 1 });
expect(observer.revNumber(obj1)).toBe(1);
expect(observer.revNumber(obj2)).toBe(1);
obj1.a = 2;
obj2.b = 3;
expect(observer.revNumber(obj1)).toBe(2);
expect(observer.revNumber(obj2)).toBe(2);
obj2.b = obj1;
expect(observer.revNumber(obj1)).toBe(2);
expect(observer.revNumber(obj2)).toBe(3);
});
test("can set a property more than once", () => {
const observer = new Observer();
const obj: any = observer.observe({});
expect(observer.revNumber(obj)).toBe(1);
expect(observer.rev).toBe(1);
obj.aku = "always finds annoying problems";
expect(observer.revNumber(obj)).toBe(2);
expect(observer.rev).toBe(2);
obj.aku = "always finds good problems";
expect(observer.revNumber(obj)).toBe(3);
expect(observer.rev).toBe(3);
});
test("properly handle swapping elements", () => {
const observer = new Observer();
const obj: any = observer.observe({ a: { arr: [] }, b: 1 });
// swap a and b
const b = obj.b;
obj.b = obj.a;
obj.a = b;
expect(observer.rev).toBe(3);
// push something into array to make sure it works
obj.b.arr.push("blanche");
expect(observer.rev).toBe(4);
});
test("properly handle assigning observed obj containing array", () => {
const observer = new Observer();
const obj: any = observer.observe({ a: { arr: [], val: "test" } });
expect(observer.rev).toBe(1);
obj.a = { ...obj.a, val: "test2" };
expect(observer.rev).toBe(2);
// push something into array to make sure it works
obj.a.arr.push("blanche");
expect(observer.rev).toBe(3);
});
test("accept cycles in observed state", () => {
const observer = new Observer();
let obj1: any = {};
let obj2: any = { b: obj1, key: 1 };
obj1.a = obj2;
obj1 = observer.observe(obj1);
obj2 = obj1.a;
expect(observer.revNumber(obj1)).toBe(1);
expect(observer.revNumber(obj2)).toBe(1);
obj2.key = 3;
expect(observer.revNumber(obj1)).toBe(2);
expect(observer.revNumber(obj2)).toBe(2);
});
test("call callback when state is changed", async () => {
const observer = new Observer();
observer.notifyCB = jest.fn();
const obj: any = observer.observe({ a: 1, b: { c: 2 }, d: [{ e: 3 }], f: 4 });
expect(observer.notifyCB).toBeCalledTimes(0);
obj.a = 2;
await nextMicroTick();
expect(observer.notifyCB).toBeCalledTimes(1);
obj.b.c = 3;
await nextMicroTick();
expect(observer.notifyCB).toBeCalledTimes(2);
obj.d[0].e = 5;
await nextMicroTick();
expect(observer.notifyCB).toBeCalledTimes(3);
obj.a = 111;
obj.f = 222;
await nextMicroTick();
expect(observer.notifyCB).toBeCalledTimes(5);
});
test("throw error when state is mutated in object if allowMutation=false", async () => {
const observer = new Observer();
observer.allowMutations = false;
const obj: any = observer.observe({ a: 1 });
expect(() => {
obj.a = 2;
}).toThrow('Observed state cannot be changed here! (key: "a", val: "2")');
});
test("throw error when state is mutated in array if allowMutation=false", async () => {
const observer = new Observer();
observer.allowMutations = false;
const obj: any = observer.observe({ a: [1] });
expect(() => {
obj.a.push(2);
}).toThrow('Observed state cannot be changed here! (key: "1", val: "2")');
});
});
+128 -71
View File
@@ -6,35 +6,9 @@
*/
import * as fs from "fs";
const LINK_REGEXP = /\[([^\[]+)\]\(([^\)]+)\)/g;
const HEADING_REGEXP = /\n(#+\s*)(.*)/g;
// files to be checked
function getFiles(): string[] {
const DOCFILES = fs.readdirSync("doc").map(f => `doc/${f}`);
const MAINREADME = "README.md";
return DOCFILES.concat(MAINREADME);
}
test("All markdown links work", () => {
let linkNumber = 0;
let invalidLinkNumber = 0;
const files = getFiles();
const data = readDocData(files);
for (let file of data) {
for (let link of file.links) {
// DEBUG: uncomment next line
// console.warn(`Checking "${link.name}" in "${file.name}"`);
linkNumber++;
if (!isLinkValid(link, file, data)) {
console.warn(`Invalid Link: "${link.name}" in "${file.name}"`);
invalidLinkNumber++;
}
}
}
expect(invalidLinkNumber).toBe(0);
expect(linkNumber).toBeGreaterThan(10);
});
//--------------------------------------------------------------------------
// Helpers
//--------------------------------------------------------------------------
interface MarkDownLink {
name: string;
@@ -48,34 +22,127 @@ interface MarkDownSection {
interface FileData {
name: string;
path: string[];
fullName: string;
links: MarkDownLink[];
sections: MarkDownSection[];
}
function isLinkValid(link: MarkDownLink, current: FileData, files: FileData[]): boolean {
const LINK_REGEXP = /\[([^\[]+)\]\(([^\)]+)\)/g;
const HEADING_REGEXP = /\n(#+\s*)(.*)/g;
export function addMardownData(fileData): void {
const sep = fileData.path.length > 0 ? "/" : "";
const fullName = fileData.path.join("/") + sep + fileData.name;
const content = fs.readFileSync(fullName, { encoding: "utf8" });
let m;
// get links info
do {
m = LINK_REGEXP.exec(content);
if (m) {
fileData.links.push({ name: m[0], link: m[2] });
}
} while (m);
// get sections info
do {
m = HEADING_REGEXP.exec(content);
if (m) {
fileData.sections.push({ name: m[0], slug: slugify(m[2]) });
}
} while (m);
}
/**
* Returns a list of FileData corresponding to all files that need to be
* validated.
*/
function getFiles(path: string[] = []): FileData[] {
if (path.length === 0) {
const baseFiles: FileData[] = [
{ name: "README.md", path: [], links: [], sections: [], fullName: "README.md" },
{ name: "roadmap.md", path: [], links: [], sections: [], fullName: "roadmap.md" }
];
const rest = getFiles(["doc"]);
const result = baseFiles.concat(rest);
result.forEach(addMardownData);
return result;
}
const files = fs.readdirSync(path.join("/"), { withFileTypes: true }).map(f => {
if (f.isDirectory()) {
return getFiles(path.concat(f.name));
}
const fullName = path.join("/") + (path.length > 0 ? "/" : "") + f.name;
return [
{
name: f.name,
path,
links: [],
sections: [],
fullName
}
];
});
return Array.prototype.concat(...files);
}
const LOCAL_FILES = ["LICENSE"];
export function isLinkValid(link: MarkDownLink, current: FileData, files: FileData[]): boolean {
if (link.link.startsWith("http")) {
// no check on external links
return true;
}
// Step 1: extract path, name, hash
// path = ['doc', 'architecture]
// name = 'rendering.md'
// hash = 'blabla' (or '' if no hash)
const parts = link.link.split("#");
const currentParts = current.name.split("/");
const path = currentParts.length > 1 ? currentParts[0] + "/" : "";
const fullName = path + parts[0];
if (parts.length === 1) {
// no # in url
if (parts[0].endsWith(".md")) {
// it is a local md file
if (!files.find(f => f.name === fullName)) {
return false;
const hash = parts[1] || "";
let name;
let path;
if (parts[0]) {
let temp = parts[0].split("/");
name = temp[temp.length - 1];
temp.splice(-1);
path = current.path.slice();
for (let elem of temp) {
if (elem === "..") {
path.splice(-1);
} else if (elem !== ".") {
path.push(elem);
}
}
} else {
const file = parts[0] === "" ? current : files.find(f => f.name === fullName);
if (!file) {
return false;
}
if (!file.sections.find(s => s.slug === parts[1])) {
// there are no file name, so this is a relative link to the current file
name = current.name;
path = current.path;
}
// Step 2: build normalized link file name
const linkFullName = path.join("/") + (path.length > 0 ? "/" : "") + name;
// Step 3: check link name against white list of local files
if (LOCAL_FILES.includes(linkFullName)) {
return true;
}
// Step 4: check if there is a matching file
let target: FileData | undefined = files.find(f => f.fullName === linkFullName);
if (!target) {
return false;
}
// Step 5: if necessary, check if there is a corresponding link inside the target
// link name
if (hash) {
if (!target.sections.find(s => s.slug === hash)) {
return false;
}
}
return true;
}
// adapted from https://medium.com/@mhagemann/the-ultimate-way-to-slugify-a-url-string-in-javascript-b8e4a0d849e1
function slugify(str) {
const a = "àáäâãåăæçèéëêǵḧìíïîḿńǹñòóöôœøṕŕßśșțùúüûǘẃẍÿź·_,:;";
@@ -94,33 +161,23 @@ function slugify(str) {
.replace(/-+$/, ""); // Trim - from end of text
}
function readDocData(files: string[]): FileData[] {
const result: FileData[] = [];
//--------------------------------------------------------------------------
// Test
//--------------------------------------------------------------------------
for (let file of files) {
const fileData: FileData = {
name: file,
links: [],
sections: []
};
const content = fs.readFileSync(file, { encoding: "utf8" });
let m;
// get links info
do {
m = LINK_REGEXP.exec(content);
if (m) {
fileData.links.push({ name: m[0], link: m[2] });
test("All markdown links work", () => {
let linkNumber = 0;
let invalidLinkNumber = 0;
const data = getFiles();
for (let file of data) {
for (let link of file.links) {
linkNumber++;
if (!isLinkValid(link, file, data)) {
console.warn(`Invalid Link: "${link.name}" in "${file.name}"`);
invalidLinkNumber++;
}
} while (m);
// get sections info
do {
m = HEADING_REGEXP.exec(content);
if (m) {
fileData.sections.push({ name: m[0], slug: slugify(m[2]) });
}
} while (m);
result.push(fileData);
}
}
return result;
}
expect(invalidLinkNumber).toBe(0);
expect(linkNumber).toBeGreaterThan(10);
});
+51 -13
View File
@@ -1,15 +1,48 @@
import { Env } from "../src/component";
import { EvalContext, QWeb, UTILS } from "../src/qweb_core";
import { Env } from "../src/component/component";
import { scheduler } from "../src/component/scheduler";
import { EvalContext, QWeb } from "../src/qweb/qweb";
import { CompilationContext } from "../src/qweb/compilation_context";
import { patch } from "../src/vdom";
import "../src/qweb_directives";
import "../src/qweb_extensions";
import "../src/qweb/base_directives";
import "../src/qweb/extensions";
import "../src/component/directive";
// modifies scheduler to make it faster to test components
scheduler.requestAnimationFrame = function(callback: FrameRequestCallback) {
setTimeout(callback, 1);
return 1;
};
// Some static cleanup
let nextSlotId;
let slots;
let nextId;
let TEMPLATES;
beforeEach(() => {
nextSlotId = QWeb.nextSlotId;
CompilationContext.nextID = 1;
slots = Object.assign({}, QWeb.slots);
nextId = QWeb.nextId;
TEMPLATES = Object.assign({}, QWeb.TEMPLATES);
});
afterEach(() => {
QWeb.nextSlotId = nextSlotId;
QWeb.slots = slots;
QWeb.nextId = nextId;
QWeb.TEMPLATES = TEMPLATES;
});
// helpers
export function nextMicroTick(): Promise<void> {
return Promise.resolve();
}
export function nextTick(): Promise<void> {
return new Promise(resolve => setTimeout(resolve));
export async function nextTick(): Promise<void> {
return new Promise(function(resolve) {
setTimeout(() => scheduler.requestAnimationFrame(() => resolve()));
});
}
export function makeTestFixture() {
@@ -77,11 +110,16 @@ export function renderToDOM(
* Note that the result of renderToString is guaranteed to be the same as the
* one from QWeb.
*/
export function renderToString(qweb: QWeb, t: string, context: EvalContext = {}): string {
const node = renderToDOM(qweb, t, context);
export function renderToString(
qweb: QWeb,
t: string,
context: EvalContext = {},
extra?: any
): string {
const node = renderToDOM(qweb, t, context, extra);
const result = node instanceof Text ? node.textContent! : node.outerHTML;
if (result !== qweb.renderToString(t, context)) {
throw new Error("HTML string returned by renderToString helper does not match QWeb render");
if (result !== qweb.renderToString(t, context, extra)) {
throw new Error("HTML string returned by renderToString helper does not match QWeb render");
}
return result;
}
@@ -90,15 +128,15 @@ export function renderToString(qweb: QWeb, t: string, context: EvalContext = {})
// is useful for animations tests, as we hook before repaints to trigger
// animations (thanks to requestAnimationFrame). Patching nextFrame allows to
// simulate calls to this hook. One must not forget to unpatch afterwards.
let nextFrame = UTILS.nextFrame;
let nextFrame = QWeb.utils.nextFrame;
export function patchNextFrame(f: Function) {
UTILS.nextFrame = (cb: () => void) => {
QWeb.utils.nextFrame = (cb: () => void) => {
setTimeout(() => f(cb));
};
}
export function unpatchNextFrame() {
UTILS.nextFrame = nextFrame;
QWeb.utils.nextFrame = nextFrame;
}
export async function editInput(input: HTMLInputElement | HTMLTextAreaElement, value: string) {
+595
View File
@@ -0,0 +1,595 @@
import { makeTestEnv, makeTestFixture, nextTick } from "./helpers";
import { Component, Env } from "../src/component/component";
import {
useState,
onMounted,
onWillUnmount,
useRef,
onPatched,
onWillPatch,
onWillStart,
onWillUpdateProps,
useSubEnv
} from "../src/hooks";
import { xml } from "../src/tags";
//------------------------------------------------------------------------------
// Setup and helpers
//------------------------------------------------------------------------------
// We create before each test:
// - fixture: a div, appended to the DOM, intended to be the target of dom
// manipulations. Note that it is removed after each test.
// - env: a WEnv, necessary to create new components
let fixture: HTMLElement;
let env: Env;
beforeEach(() => {
fixture = makeTestFixture();
env = makeTestEnv();
Component.env = env;
});
afterEach(() => {
fixture.remove();
});
//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
describe("hooks", () => {
test("can use a state hook", async () => {
class Counter extends Component<any, any> {
static template = xml`<div><t t-esc="counter.value"/></div>`;
counter = useState({ value: 42 });
}
const counter = new Counter();
await counter.mount(fixture);
expect(fixture.innerHTML).toBe("<div>42</div>");
counter.counter.value = 3;
await nextTick();
expect(fixture.innerHTML).toBe("<div>3</div>");
});
test("can use onMounted, onWillUnmount", async () => {
const steps: string[] = [];
function useMyHook() {
onMounted(() => {
steps.push("mounted");
});
onWillUnmount(() => {
steps.push("willunmount");
});
}
class MyComponent extends Component<any, any> {
static template = xml`<div>hey</div>`;
constructor() {
super();
useMyHook();
}
}
const component = new MyComponent();
await component.mount(fixture);
expect(component).not.toHaveProperty("mounted");
expect(component).not.toHaveProperty("willUnmount");
expect(fixture.innerHTML).toBe("<div>hey</div>");
expect(steps).toEqual(["mounted"]);
component.unmount();
expect(fixture.innerHTML).toBe("");
expect(steps).toEqual(["mounted", "willunmount"]);
});
test("can use onMounted, onWillUnmount, part 2", async () => {
const steps: string[] = [];
function useMyHook() {
onMounted(() => {
steps.push("mounted");
});
onWillUnmount(() => {
steps.push("willunmount");
});
}
class MyComponent extends Component<any, any> {
static template = xml`<div>hey</div>`;
constructor(parent, props) {
super(parent, props);
useMyHook();
}
}
class Parent extends Component<any, any> {
static template = xml`<div><MyComponent t-if="state.flag"/></div>`;
static components = { MyComponent };
state = useState({ flag: true });
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div>hey</div></div>");
expect(steps).toEqual(["mounted"]);
parent.state.flag = false;
await nextTick();
expect(fixture.innerHTML).toBe("<div></div>");
expect(steps).toEqual(["mounted", "willunmount"]);
});
test("mounted, willUnmount, onMounted, onWillUnmount order", async () => {
const steps: string[] = [];
function useMyHook() {
onMounted(() => {
steps.push("hook:mounted");
});
onWillUnmount(() => {
steps.push("hook:willunmount");
});
}
class MyComponent extends Component<any, any> {
static template = xml`<div>hey</div>`;
constructor() {
super();
useMyHook();
}
mounted() {
steps.push("comp:mounted");
}
willUnmount() {
steps.push("comp:willunmount");
}
}
const component = new MyComponent();
await component.mount(fixture);
expect(fixture.innerHTML).toBe("<div>hey</div>");
component.unmount();
expect(fixture.innerHTML).toBe("");
expect(steps).toEqual(["comp:mounted", "hook:mounted", "hook:willunmount", "comp:willunmount"]);
});
test("mounted, willUnmount, onMounted, onWillUnmount order, part 2", async () => {
const steps: string[] = [];
function useMyHook() {
onMounted(() => {
steps.push("hook:mounted");
});
onWillUnmount(() => {
steps.push("hook:willunmount");
});
}
class MyComponent extends Component<any, any> {
static template = xml`<div>hey</div>`;
constructor(parent, props) {
super(parent, props);
useMyHook();
}
mounted() {
steps.push("comp:mounted");
}
willUnmount() {
steps.push("comp:willunmount");
}
}
class Parent extends Component<any, any> {
static template = xml`<div><MyComponent t-if="state.flag"/></div>`;
static components = { MyComponent };
state = useState({ flag: true });
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div>hey</div></div>");
parent.state.flag = false;
await nextTick();
expect(fixture.innerHTML).toBe("<div></div>");
expect(steps).toEqual(["comp:mounted", "hook:mounted", "hook:willunmount", "comp:willunmount"]);
});
test("two different call to mounted/willunmount should work", async () => {
const steps: string[] = [];
function useMyHook(i) {
onMounted(() => {
steps.push("hook:mounted" + i);
});
onWillUnmount(() => {
steps.push("hook:willunmount" + i);
});
}
class MyComponent extends Component<any, any> {
static template = xml`<div>hey</div>`;
constructor() {
super();
useMyHook(1);
useMyHook(2);
}
}
const component = new MyComponent();
await component.mount(fixture);
expect(fixture.innerHTML).toBe("<div>hey</div>");
component.unmount();
expect(fixture.innerHTML).toBe("");
expect(steps).toEqual([
"hook:mounted1",
"hook:mounted2",
"hook:willunmount2",
"hook:willunmount1"
]);
});
test("useRef hook", async () => {
class Counter extends Component<any, any> {
static template = xml`<div><button t-ref="button"><t t-esc="value"/></button></div>`;
button = useRef("button");
value = 0;
increment() {
this.value++;
(this.button.el as HTMLButtonElement).innerHTML = String(this.value);
}
}
const counter = new Counter();
expect(counter.button.el).toBe(null);
await counter.mount(fixture);
expect(fixture.innerHTML).toBe("<div><button>0</button></div>");
expect(counter.button.el).not.toBe(null);
expect(counter.button.el).toBe(fixture.querySelector("button"));
counter.increment();
await nextTick();
expect(fixture.innerHTML).toBe("<div><button>1</button></div>");
});
test("useRef hook is null if ref is removed ", async () => {
expect.assertions(4);
class TestRef extends Component<any, any> {
static template = xml`<div><span t-if="state.flag" t-ref="span">owl</span></div>`;
spanRef = useRef("span");
state = useState({ flag: true });
willPatch() {
expect(this.spanRef.el).not.toBeNull();
}
patched() {
expect(this.spanRef.el).toBeNull();
}
}
const component = new TestRef();
await component.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>owl</span></div>");
component.state.flag = false;
await nextTick();
expect(fixture.innerHTML).toBe("<div></div>");
});
test("t-refs on widget are components", async () => {
class WidgetB extends Component<any, any> {
static template = xml`<div>b</div>`;
}
class WidgetC extends Component<any, any> {
static template = xml`<div class="outer-div">Hello<WidgetB t-ref="mywidgetb" /></div>`;
static components = { WidgetB };
ref = useRef("mywidgetb");
}
const widget = new WidgetC();
expect(widget.ref.comp).toBe(null);
expect(widget.ref.el).toBe(null);
await widget.mount(fixture);
expect(widget.ref.comp).toBeInstanceOf(WidgetB);
expect(widget.ref.el).toEqual(fixture.querySelector(".outer-div > div"));
});
test("t-refs are bound at proper timing", async () => {
expect.assertions(2);
class Widget extends Component<any, any> {
static template = xml`<div>widget</div>`;
}
class ParentWidget extends Component<any, any> {
static template = xml`
<div>
<t t-foreach="state.list" t-as="elem" t-ref="child" t-key="elem" t-component="Widget"/>
</div>
`;
static components = { Widget };
state = useState({ list: <any>[] });
child = useRef("child");
willPatch() {
expect(this.child.comp).toBeNull();
}
patched() {
expect(this.child.comp).not.toBeNull();
}
}
const parent = new ParentWidget();
await parent.mount(fixture);
parent.state.list.push(1);
await nextTick();
});
test("t-refs are bound at proper timing (2)", async () => {
expect.assertions(10);
class Widget extends Component<any, any> {
static template = xml`<div>widget</div>`;
}
class ParentWidget extends Component<any, any> {
static template = xml`
<div>
<t t-if="state.child1" t-ref="child1" t-component="Widget"/>
<t t-if="state.child2" t-ref="child2" t-component="Widget"/>
</div>`;
static components = { Widget };
state = useState({ child1: true, child2: false });
child1 = useRef("child1");
child2 = useRef("child2");
count = 0;
mounted() {
expect(this.child1.comp).toBeDefined();
expect(this.child2.comp).toBeNull();
}
willPatch() {
if (this.count === 0) {
expect(this.child1.comp).toBeDefined();
expect(this.child2.comp).toBeNull();
}
if (this.count === 1) {
expect(this.child1.comp).toBeDefined();
expect(this.child2.comp).toBeDefined();
}
}
patched() {
if (this.count === 0) {
expect(this.child1.comp).toBeDefined();
expect(this.child2.comp).toBeDefined();
}
if (this.count === 1) {
expect(this.child1.comp).toBeNull();
expect(this.child2.comp).toBeDefined();
}
this.count++;
}
}
const parent = new ParentWidget();
await parent.mount(fixture);
parent.state.child2 = true;
await nextTick();
parent.state.child1 = false;
await nextTick();
});
test("can use onPatched, onWillPatch", async () => {
const steps: string[] = [];
function useMyHook() {
onWillPatch(() => {
steps.push("willPatch");
});
onPatched(() => {
steps.push("patched");
});
}
class MyComponent extends Component<any, any> {
static template = xml`<div><t t-if="state.flag">hey</t></div>`;
state = useState({ flag: true });
constructor() {
super();
useMyHook();
}
}
const component = new MyComponent();
await component.mount(fixture);
expect(component).not.toHaveProperty("patched");
expect(component).not.toHaveProperty("willPatch");
expect(steps).toEqual([]);
expect(fixture.innerHTML).toBe("<div>hey</div>");
component.state.flag = false;
await nextTick();
expect(fixture.innerHTML).toBe("<div></div>");
expect(steps).toEqual(["willPatch", "patched"]);
});
test("patched, willPatch, onPatched, onWillPatch order", async () => {
const steps: string[] = [];
function useMyHook() {
onPatched(() => {
steps.push("hook:patched");
});
onWillPatch(() => {
steps.push("hook:willPatch");
});
}
class MyComponent extends Component<any, any> {
static template = xml`<div><t t-if="state.flag">hey</t></div>`;
state = useState({ flag: true });
constructor() {
super();
useMyHook();
}
willPatch() {
steps.push("comp:willPatch");
}
patched() {
steps.push("comp:patched");
}
}
const component = new MyComponent();
await component.mount(fixture);
expect(fixture.innerHTML).toBe("<div>hey</div>");
component.state.flag = false;
await nextTick();
expect(steps).toEqual(["hook:willPatch", "comp:willPatch", "comp:patched", "hook:patched"]);
});
test("two different call to willPatch/patched should work", async () => {
const steps: string[] = [];
function useMyHook(i) {
onPatched(() => {
steps.push("hook:patched" + i);
});
onWillPatch(() => {
steps.push("hook:willPatch" + i);
});
}
class MyComponent extends Component<any, any> {
static template = xml`<div>hey<t t-esc="state.value"/></div>`;
state = useState({ value: 1 });
constructor() {
super();
useMyHook(1);
useMyHook(2);
}
}
const component = new MyComponent();
await component.mount(fixture);
expect(fixture.innerHTML).toBe("<div>hey1</div>");
component.state.value++;
await nextTick();
expect(fixture.innerHTML).toBe("<div>hey2</div>");
expect(steps).toEqual(["hook:willPatch2", "hook:willPatch1", "hook:patched1", "hook:patched2"]);
});
describe("autofocus hook", () => {
function useAutofocus(name) {
let ref = useRef(name);
let isInDom = false;
function updateFocus() {
if (!isInDom && ref.el) {
isInDom = true;
ref.el.focus();
} else if (isInDom && !ref.el) {
isInDom = false;
}
}
onPatched(updateFocus);
onMounted(updateFocus);
}
test("simple input", async () => {
class SomeComponent extends Component<any, any> {
static template = xml`
<div>
<input t-ref="input1"/>
<input t-ref="input2"/>
</div>`;
constructor() {
super();
useAutofocus("input2");
}
}
const component = new SomeComponent();
await component.mount(fixture);
expect(fixture.innerHTML).toBe("<div><input><input></div>");
const input2 = fixture.querySelectorAll("input")[1];
expect(input2).toBe(document.activeElement);
});
test("input in a t-if", async () => {
class SomeComponent extends Component<any, any> {
static template = xml`
<div>
<input t-ref="input1"/>
<t t-if="state.flag"><input t-ref="input2"/></t>
</div>`;
state = useState({ flag: false });
constructor() {
super();
useAutofocus("input2");
}
}
const component = new SomeComponent();
await component.mount(fixture);
expect(fixture.innerHTML).toBe("<div><input></div>");
expect(document.activeElement).toBe(document.body);
component.state.flag = true;
await nextTick();
const input2 = fixture.querySelectorAll("input")[1];
expect(input2).toBe(document.activeElement);
});
});
test("can use sub env", async () => {
class TestComponent extends Component<any, any> {
static template = xml`<div><t t-esc="env.val"/></div>`;
constructor() {
super();
useSubEnv({ val: 3 });
}
}
const component = new TestComponent();
await component.mount(fixture);
expect(fixture.innerHTML).toBe("<div>3</div>");
expect(env).not.toHaveProperty("val");
expect(component.env).toHaveProperty("val");
});
test("parent and child env", async () => {
class Child extends Component<any, any> {
static template = xml`<div><t t-esc="env.val"/></div>`;
constructor(parent, props) {
super(parent, props);
useSubEnv({ val: 5 });
}
}
class Parent extends Component<any, any> {
static template = xml`<div><t t-esc="env.val"/><Child/></div>`;
static components = { Child };
constructor() {
super();
useSubEnv({ val: 3 });
}
}
const component = new Parent();
await component.mount(fixture);
expect(fixture.innerHTML).toBe("<div>3<div>5</div></div>");
});
test("can use onWillStart, onWillUpdateProps", async () => {
const steps: string[] = [];
function useMyHook() {
onWillStart(() => {
steps.push("onWillStart");
});
onWillUpdateProps(nextProps => {
expect(nextProps).toEqual({ value: 2 });
steps.push("onWillUpdateProps");
});
}
class MyComponent extends Component<any, any> {
static template = xml`<span><t t-esc="props.value"/></span>`;
constructor(parent, props) {
super(parent, props);
useMyHook();
}
}
class App extends Component<any, any> {
static template = xml`<div><MyComponent value="state.value"/></div>`;
static components = { MyComponent };
state = useState({ value: 1 });
}
const app = new App();
await app.mount(fixture);
expect(app).not.toHaveProperty("willStart");
expect(app).not.toHaveProperty("willUpdateProps");
expect(fixture.innerHTML).toBe("<div><span>1</span></div>");
expect(steps).toEqual(["onWillStart"]);
app.state.value = 2;
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>2</span></div>");
expect(steps).toEqual(["onWillStart", "onWillUpdateProps"]);
});
});
+188
View File
@@ -0,0 +1,188 @@
import { AsyncRoot } from "../../src/misc/async_root";
import { useState } from "../../src/hooks";
import { xml } from "../../src/tags";
import { makeDeferred, makeTestFixture, makeTestEnv, nextTick } from "../helpers";
import { Component } from "../../src/component/component";
//------------------------------------------------------------------------------
// Setup and helpers
//------------------------------------------------------------------------------
// We create before each test:
// - fixture: a div, appended to the DOM, intended to be the target of dom
// manipulations. Note that it is removed after each test.
// - a test env, necessary to create components, that is set as env
let fixture: HTMLElement;
beforeEach(() => {
fixture = makeTestFixture();
Component.env = makeTestEnv();
});
afterEach(() => {
fixture.remove();
});
describe("Asyncroot", () => {
test("delayed component with AsyncRoot component", async () => {
let def;
class Child extends Component<any, any> {
static template = xml`<span><t t-esc="props.val"/></span>`;
}
class AsyncChild extends Child {
willUpdateProps() {
return def;
}
}
class Parent extends Component<any, any> {
static template = xml`
<div>
<button t-on-click="updateApp">Update App State</button>
<div class="children">
<Child val="state.val"/>
<AsyncRoot>
<AsyncChild val="state.val"/>
</AsyncRoot>
</div>
</div>`;
static components = { Child, AsyncChild, AsyncRoot };
state = useState({ val: 0 });
updateApp() {
this.state.val++;
}
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>0</span><span>0</span>");
// click on button to increment Parent counter
def = makeDeferred();
fixture.querySelector("button")!.click();
await nextTick();
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>1</span><span>0</span>");
def.resolve();
await nextTick();
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>1</span><span>1</span>");
});
test("fast component with AsyncRoot", async () => {
let def;
class Child extends Component<any, any> {
static template = xml`<span><t t-esc="props.val"/></span>`;
}
class AsyncChild extends Child {
willUpdateProps() {
return def;
}
}
class Parent extends Component<any, any> {
static template = xml`
<div>
<button t-on-click="updateApp">Update App State</button>
<div class="children">
<AsyncRoot>
<Child val="state.val"/>
</AsyncRoot>
<AsyncChild val="state.val"/>
</div>
</div>`;
static components = { Child, AsyncChild, AsyncRoot };
state = useState({ val: 0 });
updateApp() {
this.state.val++;
}
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>0</span><span>0</span>");
// click on button to increment Parent counter
def = makeDeferred();
fixture.querySelector("button")!.click();
await nextTick();
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>1</span><span>0</span>");
def.resolve();
await nextTick();
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>1</span><span>1</span>");
});
test("asyncroot component: mixed re-renderings", async () => {
let def;
class Child extends Component<any, any> {
static template = xml`
<span t-on-click="increment">
<t t-esc="state.val"/>/<t t-esc="props.val"/>
</span>`;
state = useState({ val: 0 });
increment() {
this.state.val++;
}
}
class AsyncChild extends Child {
willUpdateProps() {
return def;
}
}
class Parent extends Component<any, any> {
static template = xml`
<div>
<button t-on-click="updateApp">Update App State</button>
<div class="children">
<Child val="state.val"/>
<AsyncRoot>
<AsyncChild val="state.val"/>
</AsyncRoot>
</div>
</div>`;
static components = { Child, AsyncChild, AsyncRoot };
state = useState({ val: 0 });
updateApp() {
this.state.val++;
}
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>0/0</span><span>0/0</span>");
// click on button to increment Parent counter
def = makeDeferred();
fixture.querySelector("button")!.click();
await nextTick();
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>0/1</span><span>0/0</span>");
// click on each Child to increment their local counter
const children = parent.el!.querySelectorAll("span");
children[0]!.click();
await nextTick();
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>1/1</span><span>0/0</span>");
children[1]!.click();
await nextTick();
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>1/1</span><span>0/0</span>");
// finalize first re-rendering (coming from the props update)
def.resolve();
await nextTick();
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>1/1</span><span>1/1</span>");
});
});
-394
View File
@@ -1,394 +0,0 @@
import { Observer } from "../src/observer";
import { nextMicroTick } from "./helpers";
describe("observer", () => {
test("properly observe objects", () => {
const observer = new Observer();
const obj: any = {};
observer.observe(obj);
expect(obj.__owl__.rev).toBe(1);
expect(observer.rev).toBe(1);
const ob2: any = { a: 1 };
observer.observe(ob2);
expect(ob2.__owl__.rev).toBe(1);
ob2.a = 2;
expect(observer.rev).toBe(2);
expect(ob2.__owl__.rev).toBe(2);
ob2.b = 3;
expect(observer.rev).toBe(2);
expect(ob2.__owl__.rev).toBe(2);
observer.set(ob2, "b", 4);
expect(observer.rev).toBe(3);
expect(ob2.__owl__.rev).toBe(3);
});
test("properly handle null or undefined", () => {
const observer = new Observer();
const obj: any = { a: null, b: undefined };
observer.observe(obj);
expect(obj.__owl__.rev).toBe(1);
expect(observer.rev).toBe(1);
obj.a = 3;
expect(obj.__owl__.rev).toBe(2);
obj.b = 5;
expect(obj.__owl__.rev).toBe(3);
obj.a = null;
obj.b = undefined;
expect(obj.__owl__.rev).toBe(5);
});
test("can change values in array", () => {
const observer = new Observer();
const obj: any = { arr: [1, 2] };
observer.observe(obj);
expect(obj.arr.__owl__.rev).toBe(1);
expect(observer.rev).toBe(1);
obj.arr[0] = "nope";
expect(obj.arr.__owl__.rev).toBe(1);
expect(observer.rev).toBe(1);
observer.set(obj.arr, 0, "yep");
expect(obj.arr.__owl__.rev).toBe(2);
expect(observer.rev).toBe(2);
});
test("various object property changes", () => {
const observer = new Observer();
const obj: any = { a: 1 };
observer.observe(obj);
expect(obj.__owl__.rev).toBe(1);
obj.a = 2;
expect(observer.rev).toBe(2);
expect(obj.__owl__.rev).toBe(2);
// same value again
obj.a = 2;
expect(observer.rev).toBe(2);
expect(obj.__owl__.rev).toBe(2);
obj.a = 3;
expect(observer.rev).toBe(3);
expect(obj.__owl__.rev).toBe(3);
});
test("properly observe arrays", () => {
const observer = new Observer();
const arr: any = [];
observer.observe(arr);
expect(arr.__owl__.rev).toBe(1);
expect(observer.rev).toBe(1);
expect(arr.length).toBe(0);
arr.push(1);
expect(arr.__owl__.rev).toBe(2);
expect(observer.rev).toBe(2);
expect(arr.length).toBe(1);
arr.splice(1, 0, "hey");
expect(arr.__owl__.rev).toBe(3);
expect(observer.rev).toBe(3);
expect(arr.length).toBe(2);
arr.unshift("lindemans");
expect(arr.__owl__.rev).toBe(4);
arr.reverse();
expect(arr.__owl__.rev).toBe(5);
arr.pop();
expect(arr.__owl__.rev).toBe(6);
arr.shift();
expect(arr.__owl__.rev).toBe(7);
arr.sort();
expect(arr.__owl__.rev).toBe(8);
expect(arr).toEqual([1]);
});
test("object pushed into arrays are observed", () => {
const observer = new Observer();
const arr: any = [];
observer.observe(arr);
expect(observer.rev).toBe(1);
arr.push({ kriek: 5 });
expect(observer.rev).toBe(2);
expect(arr.__owl__.rev).toBe(2);
expect(arr[0].__owl__.rev).toBe(2);
arr[0].kriek = 6;
expect(observer.rev).toBe(3);
expect(arr.__owl__.rev).toBe(2);
expect(arr[0].__owl__.rev).toBe(3);
});
test("set new property on observed object", async () => {
const observer = new Observer();
observer.notifyCB = jest.fn();
const state: any = { a: 1 };
observer.observe(state);
expect(state.__owl__.rev).toBe(1);
expect(observer.rev).toBe(1);
expect(observer.notifyCB).toBeCalledTimes(0);
Observer.set(state, "b", 8);
await nextMicroTick();
expect(state.__owl__.rev).toBe(2);
expect(observer.rev).toBe(2);
expect(observer.notifyCB).toBeCalledTimes(1);
expect(state.b).toBe(8);
});
test("delete property from observed object", async () => {
const observer = new Observer();
observer.notifyCB = jest.fn();
const state: any = { a: 1, b: 8 };
observer.observe(state);
expect(state.__owl__.rev).toBe(1);
expect(observer.rev).toBe(1);
expect(observer.notifyCB).toBeCalledTimes(0);
Observer.delete(state, "b");
await nextMicroTick();
expect(state.__owl__.rev).toBe(2);
expect(observer.rev).toBe(2);
expect(observer.notifyCB).toBeCalledTimes(1);
expect(state).toEqual({ a: 1 });
});
test("set element in observed array", async () => {
const observer = new Observer();
observer.notifyCB = jest.fn();
const state: any = ["a"];
observer.observe(state);
expect(state.__owl__.rev).toBe(1);
expect(observer.rev).toBe(1);
expect(observer.notifyCB).toBeCalledTimes(0);
Observer.set(state, 1, "b");
await nextMicroTick();
expect(state.__owl__.rev).toBe(2);
expect(observer.rev).toBe(2);
expect(observer.notifyCB).toBeCalledTimes(1);
expect(state).toEqual(["a", "b"]);
});
test("properly observe arrays in object", () => {
const observer = new Observer();
const state: any = { arr: [] };
observer.observe(state);
expect(state.arr.__owl__.rev).toBe(1);
expect(observer.rev).toBe(1);
expect(state.arr.length).toBe(0);
state.arr.push(1);
expect(state.arr.__owl__.rev).toBe(2);
expect(observer.rev).toBe(2);
expect(state.arr.length).toBe(1);
});
test("properly observe objects in array", () => {
const observer = new Observer();
const state: any = { arr: [{ something: 1 }] };
observer.observe(state);
expect(state.arr.__owl__.rev).toBe(1);
expect(state.arr[0].__owl__.rev).toBe(1);
state.arr[0].something = 2;
expect(state.arr.__owl__.rev).toBe(1);
expect(state.arr[0].__owl__.rev).toBe(2);
});
test("properly observe objects in object", () => {
const observer = new Observer();
const state: any = { a: { b: 1 } };
observer.observe(state);
expect(state.__owl__.rev).toBe(1);
expect(state.a.__owl__.rev).toBe(1);
state.a.b = 2;
expect(state.__owl__.rev).toBe(1);
expect(state.a.__owl__.rev).toBe(2);
});
test("reobserve new object values", () => {
const observer = new Observer();
const obj: any = { a: 1 };
observer.observe(obj);
expect(obj.__owl__.rev).toBe(1);
obj.a = { b: 2 };
expect(observer.rev).toBe(2);
expect(obj.__owl__.rev).toBe(2);
// we start at 2 because it is the new observer rev number
expect(obj.a.__owl__.rev).toBe(2);
obj.a.b = 3;
expect(observer.rev).toBe(3);
expect(obj.__owl__.rev).toBe(2);
expect(obj.a.__owl__.rev).toBe(3);
});
test("deep observe misc changes", () => {
const observer = new Observer();
const state: any = { o: { a: 1 }, arr: [1], n: 13 };
observer.observe(state);
expect(state.__owl__.rev).toBe(1);
expect(state.__owl__.deepRev).toBe(1);
state.o.a = 2;
expect(observer.rev).toBe(2);
expect(state.__owl__.rev).toBe(1);
expect(state.__owl__.deepRev).toBe(2);
state.arr.push(2);
expect(state.__owl__.rev).toBe(1);
expect(state.__owl__.deepRev).toBe(3);
state.n = 155;
expect(state.__owl__.rev).toBe(2);
expect(state.__owl__.deepRev).toBe(4);
});
test("properly handle already observed state", () => {
const observer = new Observer();
const obj1: any = { a: 1 };
const obj2: any = { b: 1 };
observer.observe(obj1);
observer.observe(obj2);
expect(obj1.__owl__.rev).toBe(1);
expect(obj2.__owl__.rev).toBe(1);
obj1.a = 2;
obj2.b = 3;
expect(obj1.__owl__.rev).toBe(2);
expect(obj2.__owl__.rev).toBe(2);
obj2.b = obj1;
expect(obj1.__owl__.rev).toBe(2);
expect(obj2.__owl__.rev).toBe(3);
});
test("can set a property more than once", () => {
const observer = new Observer();
const obj: any = {};
observer.observe(obj);
expect(obj.__owl__.rev).toBe(1);
expect(observer.rev).toBe(1);
expect(obj.__owl__.deepRev).toBe(1);
observer.set(obj, "aku", "always finds annoying problems");
expect(observer.rev).toBe(2);
expect(obj.__owl__.rev).toBe(2);
expect(obj.__owl__.deepRev).toBe(2);
observer.set(obj, "aku", "always finds good problems");
expect(observer.rev).toBe(3);
expect(obj.__owl__.rev).toBe(3);
expect(obj.__owl__.deepRev).toBe(3);
});
test("properly handle swapping elements", () => {
const observer = new Observer();
const obj: any = { a: { arr: [] }, b: 1 };
observer.observe(obj);
// swap a and b
const b = obj.b;
obj.b = obj.a;
obj.a = b;
expect(observer.rev).toBe(3);
// push something into array to make sure it works
obj.b.arr.push("blanche");
expect(observer.rev).toBe(4);
});
test("properly handle assigning observed obj containing array", () => {
const observer = new Observer();
const obj: any = { a: { arr: [], val: "test" } };
observer.observe(obj);
obj.a = { ...obj.a, val: "test2" };
expect(observer.rev).toBe(2);
// push something into array to make sure it works
obj.a.arr.push("blanche");
expect(observer.rev).toBe(3);
});
test("accept cycles in observed state", () => {
const observer = new Observer();
const obj1: any = {};
const obj2: any = { b: obj1, key: 1 };
obj1.a = obj2;
observer.observe(obj1);
expect(obj1.__owl__.rev).toBe(1);
expect(obj2.__owl__.rev).toBe(1);
obj2.key = 3;
expect(obj1.__owl__.rev).toBe(1);
expect(obj2.__owl__.rev).toBe(2);
});
test("call callback when state is changed", async () => {
const observer = new Observer();
observer.notifyCB = jest.fn();
const obj: any = { a: 1, b: { c: 2 }, d: [{ e: 3 }], f: 4 };
observer.observe(obj);
expect(observer.notifyCB).toBeCalledTimes(0);
obj.a = 2;
await nextMicroTick();
expect(observer.notifyCB).toBeCalledTimes(1);
obj.b.c = 3;
await nextMicroTick();
expect(observer.notifyCB).toBeCalledTimes(2);
obj.d[0].e = 5;
await nextMicroTick();
expect(observer.notifyCB).toBeCalledTimes(3);
obj.a = 111;
obj.f = 222;
await nextMicroTick();
expect(observer.notifyCB).toBeCalledTimes(4);
});
test("throw error when state is mutated in object if allowMutation=false", async () => {
const observer = new Observer();
observer.allowMutations = false;
const obj: any = { a: 1 };
observer.observe(obj);
expect(() => {
obj.a = 2;
}).toThrow('Observed state cannot be changed here! (key: "a", val: "2")');
});
test("throw error when state is mutated in array if allowMutation=false", async () => {
const observer = new Observer();
observer.allowMutations = false;
const obj: any = { a: [1] };
observer.observe(obj);
expect(() => {
obj.a.push(2);
}).toThrow("Array cannot be changed here");
});
});
-335
View File
@@ -1,335 +0,0 @@
import { Component, Env } from "../src/component";
import { makeTestFixture, makeTestEnv } from "./helpers";
import { QWeb, UTILS } from "../src/qweb_core";
//------------------------------------------------------------------------------
// Setup and helpers
//------------------------------------------------------------------------------
let fixture: HTMLElement;
let env: Env;
let dev: boolean = false;
beforeEach(() => {
fixture = makeTestFixture();
env = makeTestEnv();
dev = QWeb.dev;
QWeb.dev = true;
});
afterEach(() => {
fixture.remove();
QWeb.dev = dev;
});
class Widget extends Component<any, any, any> {}
//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
describe("props validation", () => {
test("validation is only done in dev mode", async () => {
class TestWidget extends Widget {
static props = ["message"];
}
QWeb.dev = true;
expect(() => {
new TestWidget(env);
}).toThrow();
QWeb.dev = false;
expect(() => {
new TestWidget(env);
}).not.toThrow();
});
test("props: list of strings", async () => {
class TestWidget extends Widget {
static props = ["message"];
}
expect(() => {
new TestWidget(env);
}).toThrow("Missing props 'message' (component 'TestWidget')");
});
test("validate simple types", async () => {
const Tests = [
{ type: Number, ok: 1, ko: "1" },
{ type: Boolean, ok: true, ko: "1" },
{ type: String, ok: "1", ko: 1 },
{ type: Object, ok: {}, ko: "1" },
{ type: Date, ok: new Date(), ko: "1" },
{ type: Function, ok: () => {}, ko: "1" }
];
for (let test of Tests) {
let TestWidget = class extends Widget {
static props = { p: test.type };
};
expect(() => {
new TestWidget(env);
}).toThrow("Missing props 'p'");
expect(() => {
new TestWidget(env, { p: test.ok });
}).not.toThrow();
expect(() => {
new TestWidget(env, { p: test.ko });
}).toThrow("Props 'p' of invalid type in component");
}
});
test("validate simple types, alternate form", async () => {
const Tests = [
{ type: Number, ok: 1, ko: "1" },
{ type: Boolean, ok: true, ko: "1" },
{ type: String, ok: "1", ko: 1 },
{ type: Object, ok: {}, ko: "1" },
{ type: Date, ok: new Date(), ko: "1" },
{ type: Function, ok: () => {}, ko: "1" }
];
for (let test of Tests) {
let TestWidget = class extends Widget {
static props = { p: { type: test.type } };
};
expect(() => {
new TestWidget(env);
}).toThrow("Missing props 'p'");
expect(() => {
new TestWidget(env, { p: test.ok });
}).not.toThrow();
expect(() => {
new TestWidget(env, { p: test.ko });
}).toThrow("Props 'p' of invalid type in component");
}
});
test("can validate a prop with multiple types", async () => {
let TestWidget = class extends Widget {
static props = { p: [String, Boolean] };
};
expect(() => {
new TestWidget(env, { p: "string" });
new TestWidget(env, { p: true });
}).not.toThrow();
expect(() => {
new TestWidget(env, { p: 1 });
}).toThrow("Props 'p' of invalid type in component");
});
test("can validate an optional props", async () => {
let TestWidget = class extends Widget {
static props = { p: { type: String, optional: true } };
};
expect(() => {
new TestWidget(env, { p: "hey" });
new TestWidget(env, {});
}).not.toThrow();
expect(() => {
new TestWidget(env, { p: 1 });
}).toThrow();
});
test("can validate an array with given primitive type", async () => {
let TestWidget = class extends Widget {
static props = { p: { type: Array, element: String } };
};
expect(() => {
new TestWidget(env, { p: [] });
new TestWidget(env, { p: ["string"] });
}).not.toThrow();
expect(() => {
new TestWidget(env, { p: [1] });
}).toThrow();
expect(() => {
new TestWidget(env, { p: ["string", 1] });
}).toThrow();
});
test("can validate an array with multiple sub element types", async () => {
let TestWidget = class extends Widget {
static props = { p: { type: Array, element: [String, Boolean] } };
};
expect(() => {
new TestWidget(env, { p: [] });
new TestWidget(env, { p: ["string"] });
new TestWidget(env, { p: [false, true, "string"] });
}).not.toThrow();
expect(() => {
new TestWidget(env, { p: [true, 1] });
}).toThrow();
});
test("can validate an object with simple shape", async () => {
let TestWidget = class extends Widget {
static props = {
p: { type: Object, shape: { id: Number, url: String } }
};
};
expect(() => {
new TestWidget(env, { p: { id: 1, url: "url" } });
new TestWidget(env, { p: { id: 1, url: "url", extra: true } });
}).not.toThrow();
expect(() => {
new TestWidget(env, { p: { id: "1", url: "url" } });
}).toThrow();
expect(() => {
new TestWidget(env, { p: { id: 1 } });
}).toThrow();
});
test("can validate recursively complicated prop def", async () => {
let TestWidget = class extends Widget {
static props = {
p: {
type: Object,
shape: {
id: Number,
url: [Boolean, { type: Array, element: Number }]
}
}
};
};
expect(() => {
new TestWidget(env, { p: { id: 1, url: true } });
new TestWidget(env, { p: { id: 1, url: [12] } });
}).not.toThrow();
expect(() => {
new TestWidget(env, { p: { id: 1, url: [12, true] } });
}).toThrow();
});
test("props are validated in dev mode (code snapshot)", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="App">
<Child message="1"/>
</div>
<div t-name="Child"><t t-esc="props.message"/></div>
</templates>`);
class Child extends Widget {
static props = ["message"];
}
class App extends Widget {
components = { Child };
}
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div>1</div></div>");
// need to make sure there are 2 call to update props. one at component
// creation, and one at update time.
expect(env.qweb.templates.App.fn.toString()).toMatchSnapshot();
});
test("props: list of strings with optional props", async () => {
class TestWidget extends Widget {
static props = ["message", "someProp?"];
}
expect(() => {
UTILS.validateProps(TestWidget, { someProp: 1 });
}).toThrow();
expect(() => {
UTILS.validateProps(TestWidget, { message: 1 });
}).not.toThrow();
});
test("props: can be defined with a boolean", async () => {
class TestWidget extends Widget {
static props = { message: true };
}
expect(() => {
UTILS.validateProps(TestWidget, {});
}).toThrow();
});
test("props: extra props cause an error", async () => {
class TestWidget extends Widget {
static props = ["message"];
}
expect(() => {
UTILS.validateProps(TestWidget, { message: 1, flag: true });
}).toThrow();
});
test("props: extra props cause an error, part 2", async () => {
class TestWidget extends Widget {
static props = {message: true};
}
expect(() => {
UTILS.validateProps(TestWidget, { message: 1, flag: true });
}).toThrow();
});
test("props: optional prop do not cause an error", async () => {
class TestWidget extends Widget {
static props = ["message?"];
}
expect(() => {
UTILS.validateProps(TestWidget, { message: 1});
}).not.toThrow();
});
test("optional prop do not cause an error if value is undefined", async () => {
class TestWidget extends Widget {
static props = {message: {type: String, optional: true}};
}
expect(() => {
UTILS.validateProps(TestWidget, { message: undefined});
}).not.toThrow();
expect(() => {
UTILS.validateProps(TestWidget, { message: null});
}).toThrow();
});
});
describe("default props", () => {
test("can set default values", async () => {
class TestWidget extends Widget {
static defaultProps = { p: 4 };
}
const w = new TestWidget(env, {});
expect(w.props.p).toBe(4);
});
test("default values are also set whenever component is updated", async () => {
class TestWidget extends Widget {
static defaultProps = { p: 4 };
}
const w = new TestWidget(env, { p: 1 });
await w.__updateProps({});
expect(w.props.p).toBe(4);
});
});
File diff suppressed because it is too large Load Diff
+512 -16
View File
@@ -1,5 +1,6 @@
import { QWeb } from "../src/qweb_core";
import { normalize, renderToDOM, renderToString, trim } from "./helpers";
import { QWeb } from "../../src/qweb/index";
import { nextTick, normalize, renderToDOM, renderToString, trim } from "../helpers";
import { patch } from "../../src/vdom";
//------------------------------------------------------------------------------
// Setup and helpers
@@ -11,6 +12,7 @@ import { normalize, renderToDOM, renderToString, trim } from "./helpers";
let qweb: QWeb;
beforeEach(() => {
QWeb.TEMPLATES = {};
qweb = new QWeb();
});
@@ -48,6 +50,11 @@ describe("static templates", () => {
qweb.addTemplate("test", "<div><span>word</span></div>");
expect(renderToString(qweb, "test")).toBe("<div><span>word</span></div>");
});
test("properly handle comments", () => {
qweb.addTemplate("test", "<div>hello <!-- comment-->owl</div>");
expect(renderToString(qweb, "test")).toBe("<div>hello <!-- comment-->owl</div>");
});
});
describe("error handling", () => {
@@ -73,19 +80,12 @@ describe("error handling", () => {
expect(() => qweb.addTemplate("test", "<div/>")).toThrow("already defined");
});
test("loadTemplates throw if parser error", () => {
test("addTemplates throw if parser error", () => {
expect(() => {
qweb.addTemplates("<templates><abc>></templates>");
}).toThrow("Invalid XML in template");
});
test("nice error when t-on-directive is evaluated with a missing handler", () => {
qweb.addTemplate("templatename", `<div t-on-click="somemethod"></div>`);
expect(() => qweb.render("templatename", {}, { handlers: [] })).toThrow(
"Missing handler 'somemethod' when evaluating template 'templatename'"
);
});
test("nice error when t-on is evaluated with a missing event", () => {
qweb.addTemplate("templatename", `<div t-on="somemethod"></div>`);
expect(() => qweb.render("templatename", { someMethod() {} }, { handlers: [] })).toThrow(
@@ -242,6 +242,32 @@ describe("t-set", () => {
);
expect(renderToString(qweb, "test", { somevariable: 43 })).toBe("<div>45</div>");
});
test("t-set, t-if, and mix of expression/body lookup, 1", () => {
qweb.addTemplate(
"test",
`<div>
<t t-if="flag" t-set="ourvar">1</t>
<t t-else="" t-set="ourvar" t-value="0"></t>
<t t-esc="ourvar"/>
</div>`
);
expect(renderToString(qweb, "test", { flag: true })).toBe("<div>1</div>");
expect(renderToString(qweb, "test", { flag: false })).toBe("<div>0</div>");
});
test("t-set, t-if, and mix of expression/body lookup, 2", () => {
qweb.addTemplate(
"test",
`<div>
<t t-if="flag" t-set="ourvar" t-value="1"></t>
<t t-else="" t-set="ourvar">0</t>
<t t-esc="ourvar"/>
</div>`
);
expect(renderToString(qweb, "test", { flag: true })).toBe("<div>1</div>");
expect(renderToString(qweb, "test", { flag: false })).toBe("<div>0</div>");
});
});
describe("t-if", () => {
@@ -322,6 +348,61 @@ describe("t-if", () => {
};
expect(normalize(renderToString(qweb, "test", context))).toBe("<div>andormgtnlt</div>");
});
test("t-esc with t-if", () => {
qweb.addTemplate("test", `<div><t t-if="true" t-esc="'x'"/></div>`);
expect(renderToString(qweb, "test")).toBe("<div>x</div>");
});
test("t-esc with t-elif", () => {
qweb.addTemplate("test", `<div><t t-if="false">abc</t><t t-else="1" t-esc="'x'"/></div>`);
expect(renderToString(qweb, "test")).toBe("<div>x</div>");
});
test("t-set, then t-if", () => {
qweb.addTemplate(
"test",
`
<div>
<t t-set="title" t-value="'test'"/>
<t t-if="title"><t t-esc="title"/></t>
</div>`
);
const result = renderToString(qweb, "test");
const expected = `<div>test</div>`;
expect(result).toBe(expected);
});
test("t-set, then t-if, part 2", () => {
qweb.addTemplate(
"test",
`
<div>
<t t-set="y" t-value="true"/>
<t t-set="x" t-value="y"/>
<span t-if="x">COUCOU</span>
</div>`
);
const result = renderToString(qweb, "test");
const expected = `<div><span>COUCOU</span></div>`;
expect(result).toBe(expected);
});
test("t-set, then t-elif, part 3", () => {
qweb.addTemplate(
"test",
`
<div>
<t t-set="y" t-value="false"/>
<t t-set="x" t-value="y"/>
<span t-if="x">AAA</span>
<span t-elif="!x">BBB</span>
</div>`
);
const result = renderToString(qweb, "test");
const expected = `<div><span>BBB</span></div>`;
expect(result).toBe(expected);
});
});
describe("attributes", () => {
@@ -501,6 +582,13 @@ describe("t-call (template calling", () => {
expect(renderToString(qweb, "caller")).toBe(expected);
});
test("t-call with t-if", () => {
qweb.addTemplate("sub", "<span>ok</span>");
qweb.addTemplate("caller", '<div><t t-if="flag" t-call="sub"/></div>');
const expected = "<div><span>ok</span></div>";
expect(renderToString(qweb, "caller", { flag: true })).toBe(expected);
});
test("t-call not allowed on a non t node", () => {
qweb.addTemplate("_basic-callee", "<t>ok</t>");
qweb.addTemplate("caller", '<div t-call="_basic-callee"/>');
@@ -566,6 +654,137 @@ describe("t-call (template calling", () => {
const expected = "<div>ok</div>";
expect(trim(renderToString(qweb, "caller"))).toBe(expected);
});
test("call with several sub nodes on same line", () => {
qweb.addTemplates(`
<templates>
<div t-name="SubTemplate">
<t t-raw="0"/>
</div>
<div t-name="main">
<t t-call="SubTemplate">
<span>hey</span> <span>yay</span>
</t>
</div>
</templates>
`);
const expected = "<div><div><span>hey</span> <span>yay</span></div></div>";
expect(renderToString(qweb, "main")).toBe(expected);
});
test("cascading t-call t-raw='0'", () => {
qweb.addTemplates(`
<templates>
<div t-name="finalTemplate">
<span>cascade 2</span>
<t t-raw="0"/>
</div>
<div t-name="subSubTemplate">
<t t-call="finalTemplate">
<span>cascade 1</span>
<t t-raw="0"/>
</t>
</div>
<div t-name="SubTemplate">
<t t-call="subSubTemplate">
<span>cascade 0</span>
<t t-raw="0"/>
</t>
</div>
<div t-name="main">
<t t-call="SubTemplate">
<span>hey</span> <span>yay</span>
</t>
</div>
</templates>
`);
const expected =
"<div><div><div><div><span>cascade 2</span><span>cascade 1</span><span>cascade 0</span><span>hey</span> <span>yay</span></div></div></div></div>";
expect(renderToString(qweb, "main")).toBe(expected);
});
test("recursive template, part 1", () => {
qweb.addTemplates(`
<templates>
<div t-name="recursive">
<span>hey</span>
<t t-if="false">
<t t-call="recursive"/>
</t>
</div>
</templates>
`);
const expected = "<div><span>hey</span></div>";
expect(renderToString(qweb, "recursive")).toBe(expected);
const recursiveFn = Object.values(qweb.recursiveFns)[0] as any;
expect(recursiveFn.toString()).toMatchSnapshot();
});
test("recursive template, part 2", () => {
qweb.addTemplates(`
<templates>
<div t-name="Parent">
<t t-call="nodeTemplate">
<t t-set="node" t-value="root"/>
</t>
</div>
<div t-name="nodeTemplate">
<p><t t-esc="node.val"/></p>
<t t-foreach="node.children or []" t-as="subtree">
<t t-call="nodeTemplate">
<t t-set="node" t-value="subtree"/>
</t>
</t>
</div>
</templates>
`);
const root = { val: "a", children: [{ val: "b" }, { val: "c" }] };
const expected = "<div><div><p>a</p><div><p>b</p></div><div><p>c</p></div></div></div>";
expect(renderToString(qweb, "Parent", { root }, { fiber: { vars: {}, scope: {} } })).toBe(
expected
);
const recursiveFn = Object.values(qweb.recursiveFns)[0] as any;
expect(recursiveFn.toString()).toMatchSnapshot();
});
test("recursive template, part 3", () => {
qweb.addTemplates(`
<templates>
<div t-name="Parent">
<t t-call="nodeTemplate">
<t t-set="node" t-value="root"/>
</t>
</div>
<div t-name="nodeTemplate">
<p><t t-esc="node.val"/></p>
<t t-foreach="node.children or []" t-as="subtree">
<t t-call="nodeTemplate">
<t t-set="node" t-value="subtree"/>
</t>
</t>
</div>
</templates>
`);
const root = { val: "a", children: [{ val: "b", children: [{ val: "d" }] }, { val: "c" }] };
const expected =
"<div><div><p>a</p><div><p>b</p><div><p>d</p></div></div><div><p>c</p></div></div></div>";
expect(renderToString(qweb, "Parent", { root }, { fiber: {} })).toBe(expected);
const recursiveFn = Object.values(qweb.recursiveFns)[0] as any;
expect(recursiveFn.toString()).toMatchSnapshot();
});
test("t-call, global templates", () => {
QWeb.registerTemplate("abcd", '<div><t t-call="john"/></div>');
qweb.addTemplate("john", `<span>desk</span>`);
const expected = "<div><span>desk</span></div>";
expect(trim(renderToString(qweb, "abcd"))).toBe(expected);
});
});
describe("foreach", () => {
@@ -639,6 +858,23 @@ describe("foreach", () => {
expect(Object.keys(context).length).toBe(0);
});
test("t-foreach in t-forach", () => {
qweb.addTemplate(
"test",
`<div>
<t t-foreach="numbers" t-as="number">
<t t-foreach="letters" t-as="letter">
[<t t-esc="number"/><t t-esc="letter"/>]
</t>
</t>
</div>`
);
const context = { numbers: [1, 2, 3], letters: ["a", "b"] };
expect(renderToString(qweb, "test", context)).toBe(
"<div> [1a] [1b] [2a] [2b] [3a] [3b] </div>"
);
});
test("throws error if invalid loop expression", () => {
qweb.addTemplate(
"test",
@@ -662,13 +898,16 @@ describe("foreach", () => {
);
renderToString(qweb, "test");
expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledWith(
"Directive t-foreach should always be used with a t-key! (in template: 'test')"
);
console.warn = consoleWarn;
});
});
describe("misc", () => {
test("global", () => {
qweb.addTemplate("_callee-asc", `<Año t-att-falló="'agüero'" t-raw="0"/>`);
qweb.addTemplate("_callee-asc", `<año t-att-falló="'agüero'" t-raw="0"/>`);
qweb.addTemplate("_callee-uses-foo", `<span t-esc="foo">foo default</span>`);
qweb.addTemplate("_callee-asc-toto", `<div t-raw="toto">toto default</div>`);
qweb.addTemplate(
@@ -865,6 +1104,35 @@ describe("t-on", () => {
(<HTMLElement>node).click();
});
test("t-on with inline statement", () => {
qweb.addTemplate("test", `<button t-on-click="state.counter++">Click</button>`);
let owner = {
state: {
counter: 0
}
};
const node = renderToDOM(qweb, "test", owner, { handlers: [] });
expect(owner.state.counter).toBe(0);
(<HTMLElement>node).click();
expect(owner.state.counter).toBe(1);
});
test("t-on with inline statement (function call)", () => {
qweb.addTemplate("test", `<button t-on-click="state.incrementCounter(2)">Click</button>`);
let owner = {
state: {
counter: 0,
incrementCounter: inc => {
owner.state.counter += inc;
}
}
};
const node = renderToDOM(qweb, "test", owner, { handlers: [] });
expect(owner.state.counter).toBe(0);
(<HTMLElement>node).click();
expect(owner.state.counter).toBe(2);
});
test("t-on with prevent and/or stop modifiers", async () => {
expect.assertions(7);
qweb.addTemplate(
@@ -976,20 +1244,113 @@ describe("t-on", () => {
expect(steps).toEqual([true, true]);
});
test("t-on with prevent modifier in t-foreach", async () => {
expect.assertions(5);
qweb.addTemplate(
"test",
`<div>
<t t-foreach="projects" t-as="project">
<a href="#" t-key="project" t-on-click.prevent="onEdit(project.id)">
Edit <t t-esc="project.name"/>
</a>
</t>
</div>`
);
const steps: string[] = [];
const owner = {
projects: [
{ id: 1, name: "Project 1" },
{ id: 2, name: "Project 2" }
],
onEdit(projectId, ev) {
expect(ev.defaultPrevented).toBe(true);
steps.push(projectId);
}
};
const node = <HTMLElement>renderToDOM(qweb, "test", owner, { handlers: [] });
expect(node.outerHTML).toBe(
`<div><a href="#"> Edit Project 1</a><a href="#"> Edit Project 2</a></div>`
);
const links = node.querySelectorAll("a")!;
links[0].click();
links[1].click();
expect(steps).toEqual([1, 2]);
});
test("t-on with empty handler (only modifiers)", () => {
expect.assertions(2);
qweb.addTemplate(
"test",
`<div>
<button t-on-click.prevent="">Button</button>
</div>`
);
const node = renderToDOM(qweb, "test", {}, { handlers: [] });
node.addEventListener("click", e => {
expect(e.defaultPrevented).toBe(true);
});
const button = (<HTMLElement>node).getElementsByTagName("button")[0];
button.click();
});
test("t-on combined with t-esc", async () => {
expect.assertions(3);
qweb.addTemplate("test", `<div><button t-on-click="onClick" t-esc="text"/></div>`);
const steps: string[] = [];
const owner = {
text: "Click here",
onClick() {
steps.push("onClick");
}
};
const node = <HTMLElement>renderToDOM(qweb, "test", owner, { handlers: [] });
expect(node.outerHTML).toBe(`<div><button>Click here</button></div>`);
node.querySelector("button")!.click();
expect(steps).toEqual(["onClick"]);
});
test("t-on combined with t-raw", async () => {
expect.assertions(3);
qweb.addTemplate("test", `<div><button t-on-click="onClick" t-raw="html"/></div>`);
const steps: string[] = [];
const owner = {
html: "Click <b>here</b>",
onClick() {
steps.push("onClick");
}
};
const node = <HTMLElement>renderToDOM(qweb, "test", owner, { handlers: [] });
expect(node.outerHTML).toBe(`<div><button>Click <b>here</b></button></div>`);
node.querySelector("button")!.click();
expect(steps).toEqual(["onClick"]);
});
});
describe("t-ref", () => {
test("can get a ref on a node", () => {
qweb.addTemplate("test", `<div><span t-ref="myspan"/></div>`);
let refs: any = {};
renderToDOM(qweb, "test", { refs });
renderToDOM(qweb, "test", { __owl__: { refs } });
expect(refs.myspan.tagName).toBe("SPAN");
});
test("can get a dynamic ref on a node", () => {
qweb.addTemplate("test", `<div><span t-ref="myspan{{id}}"/></div>`);
let refs: any = {};
renderToDOM(qweb, "test", { refs, id: 3 });
renderToDOM(qweb, "test", { id: 3, __owl__: { refs } });
expect(refs.myspan3.tagName).toBe("SPAN");
});
@@ -1004,19 +1365,19 @@ describe("t-ref", () => {
</div>`
);
let refs: any = {};
renderToDOM(qweb, "test", { refs, items: [1, 2, 3] });
renderToDOM(qweb, "test", { items: [1, 2, 3], __owl__: { refs } });
expect(Object.keys(refs)).toEqual(["1", "2", "3"]);
});
});
describe("loading templates", () => {
test("can initialize qweb with a string", () => {
const data = `
const templates = `
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<div t-name="hey">jupiler</div>
</templates>`;
const qweb = new QWeb(data);
const qweb = new QWeb({ templates });
expect(renderToString(qweb, "hey")).toBe("<div>jupiler</div>");
});
@@ -1171,3 +1532,138 @@ describe("debugging", () => {
console.log = consoleLog;
});
});
describe("update on event bus", () => {
test("two consecutive forceUpdates only causes one listener update", async () => {
const fn = jest.fn();
qweb.on("update", null, fn);
qweb.forceUpdate();
qweb.forceUpdate();
expect(fn).toBeCalledTimes(0);
await nextTick();
expect(fn).toBeCalledTimes(1);
});
});
describe("global template registration", () => {
test("can register template globally", () => {
expect.assertions(5);
let qweb = new QWeb();
try {
qweb.render("mytemplate");
} catch (e) {
expect(e.message).toMatch("Template mytemplate does not exist");
}
expect(qweb.templates.mytemplate).toBeUndefined();
QWeb.registerTemplate("mytemplate", "<div>global</div>");
expect(qweb.templates.mytemplate).toBeDefined();
const vnode = qweb.render("mytemplate");
expect(vnode.sel).toBe("div");
expect((vnode as any).children[0].text).toBe("global");
});
});
describe("properly support svg", () => {
test("add proper namespace to svg", () => {
qweb.addTemplate(
"test",
`<svg width="100px" height="90px"><circle cx="50" cy="50" r="4" stroke="green" stroke-width="1" fill="yellow"/> </svg>`
);
expect(renderToString(qweb, "test")).toBe(
`<svg width=\"100px\" height=\"90px\"><circle cx=\"50\" cy=\"50\" r=\"4\" stroke=\"green\" stroke-width=\"1\" fill=\"yellow\"></circle> </svg>`
);
});
test("add proper namespace to g tags", () => {
// this is necessary if one wants to use components in a svg
qweb.addTemplate(
"test",
`<g><circle cx="50" cy="50" r="4" stroke="green" stroke-width="1" fill="yellow"/> </g>`
);
expect(renderToString(qweb, "test")).toBe(
`<g><circle cx=\"50\" cy=\"50\" r=\"4\" stroke=\"green\" stroke-width=\"1\" fill=\"yellow\"></circle> </g>`
);
});
});
describe("translation support", () => {
test("can translate node content", () => {
const translations = {
word: "mot"
};
const translateFn = expr => translations[expr] || expr;
const qweb = new QWeb({ translateFn });
qweb.addTemplate("test", "<div>word</div>");
expect(renderToString(qweb, "test")).toBe("<div>mot</div>");
});
test("does not translate node content if disabled", () => {
const translations = {
word: "mot"
};
const translateFn = expr => translations[expr] || expr;
const qweb = new QWeb({ translateFn });
qweb.addTemplate(
"test",
`
<div>
<span>word</span>
<span t-translation="off">word</span>
</div>`
);
expect(renderToString(qweb, "test")).toBe("<div><span>mot</span><span>word</span></div>");
});
test("some attributes are translated", () => {
const translations = {
word: "mot"
};
const translateFn = expr => translations[expr] || expr;
const qweb = new QWeb({ translateFn });
qweb.addTemplate(
"test",
`
<div>
<p label="word">word</p>
<p title="word">word</p>
<p placeholder="word">word</p>
<p alt="word">word</p>
<p something="word">word</p>
</div>`
);
expect(renderToString(qweb, "test")).toBe(
'<div><p label="mot">mot</p><p title="mot">mot</p><p placeholder="mot">mot</p><p alt="mot">mot</p><p something="word">mot</p></div>'
);
});
});
describe("t-key tests", () => {
test("t-key on t-foreach", async () => {
qweb.addTemplate(
"test",
`
<div>
<t t-foreach="things" t-as="thing" t-key="thing">
<span/>
</t>
</div>`
);
let vnode = qweb.render("test", { things: [1, 2] });
vnode = patch(document.createElement("div"), vnode);
let elm = vnode.elm as HTMLElement;
expect(elm.outerHTML).toBe("<div><span></span><span></span></div>");
const first = elm.querySelectorAll("span")[0];
const second = elm.querySelectorAll("span")[1];
patch(vnode, qweb.render("test", { things: [2, 1] }));
expect(elm.outerHTML).toBe("<div><span></span><span></span></div>");
expect(first).toBe(elm.querySelectorAll("span")[1]);
expect(second).toBe(elm.querySelectorAll("span")[0]);
});
});
@@ -1,4 +1,4 @@
import { compileExpr, tokenize } from "../src/qweb_expressions";
import { compileExpr, tokenize } from "../../src/qweb/expression_parser";
describe("tokenizer", () => {
test("simple tokens", () => {
@@ -27,7 +27,10 @@ describe("tokenizer", () => {
{ type: "VALUE", value: "2" },
{ type: "RIGHT_BRACE", value: "}" }
]);
expect(tokenize("a,")).toEqual([{ type: "SYMBOL", value: "a" }, { type: "COMMA", value: "," }]);
expect(tokenize("a,")).toEqual([
{ type: "SYMBOL", value: "a" },
{ type: "COMMA", value: "," }
]);
expect(tokenize("][")).toEqual([
{ type: "RIGHT_BRACKET", value: "]" },
{ type: "LEFT_BRACKET", value: "[" }
@@ -43,6 +46,10 @@ describe("tokenizer", () => {
{ type: "OPERATOR", value: "!==" },
{ type: "OPERATOR", value: "!=" }
]);
expect(tokenize("typeof a")).toEqual([
{ type: "OPERATOR", value: "typeof " },
{ type: "SYMBOL", value: "a" }
]);
});
test("strings", () => {
@@ -112,6 +119,7 @@ describe("expression evaluation", () => {
expect(compileExpr("!flag", {})).toBe("!context['flag']");
expect(compileExpr("-3", {})).toBe("-3");
expect(compileExpr("-a", {})).toBe("-context['a']");
expect(compileExpr("typeof a", {})).toBe("typeof context['a']");
});
test("various binary operators", () => {
@@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Link component can render simple cases 1`] = `
"function anonymous(context,extra
) {
let utils = this.constructor.utils;
let owner = context;
var h = this.h;
let _6 = utils.toObj({'router-link-active':context['isActive']});
var _7 = context['href'];
let c8 = [], p8 = {key:8,attrs:{href: _7},class:_6,on:{}};
var vn8 = h('a', p8, c8);
extra.handlers['click' + 8] = extra.handlers['click' + 8] || function (e) {const fn = context['navigate'];if (fn) { fn.call(owner, e); } else { context.navigate; }};
p8.on['click'] = extra.handlers['click' + 8];
const slot9 = this.constructor.slots[context.__owl__.slotId + '_' + 'default'];
if (slot9) {
slot9.call(this, context.__owl__.parent, Object.assign({}, extra, {parentNode: c8, vars: extra.vars, parent: extra.parent || owner}));
}
return vn8;
}"
`;
@@ -0,0 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RouteComponent can render simple cases 1`] = `
"function anonymous(context,extra
) {
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
let owner = context;
let result;
var h = this.h;
if (context['routeComponent']) {
const nodeKey6 = context['env'].router.currentRouteName;
//COMPONENT
let k9 = \`__10__\` + nodeKey6;
let w8 = k9 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k9]] : false;
let vn11 = {};
result = vn11;
let props8 = Object.assign({}, context['env'].router.currentParams);
if (w8 && w8.__owl__.currentFiber && !w8.__owl__.vnode) {
w8.destroy();
w8 = false;
}
if (w8) {
w8.__updateProps(props8, extra.fiber, undefined, undefined);
let pvnode = w8.__owl__.pvnode;
utils.defineProxy(vn11, pvnode);
} else {
let componentKey8 = \`routeComponent\`;
let W8 = context.constructor.components[componentKey8] || QWeb.components[componentKey8]|| context['routeComponent'];
if (!W8) {throw new Error('Cannot find the definition of component \\"' + componentKey8 + '\\"')}
w8 = new W8(parent, props8);
parent.__owl__.cmap[k9] = w8.__owl__.id;
let def7 = w8.__prepare(extra.fiber, undefined, undefined);
let pvnode = h('dummy', {key: k9, hook: {remove() {},destroy(vn) {w8.destroy();}}});
const fiber = w8.__owl__.currentFiber;
def7.then(function () { if (fiber.isCompleted) { return; } const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
utils.defineProxy(vn11, pvnode);
w8.__owl__.pvnode = pvnode;
}
w8.__owl__.parentLastFiberId = extra.fiber.id;
}
return result;
}"
`;
+91
View File
@@ -0,0 +1,91 @@
import { Component } from "../../src/component/component";
import { Link } from "../../src/router/link";
import { RouterEnv } from "../../src/router/router";
import { makeTestEnv, makeTestFixture, nextTick } from "../helpers";
import { TestRouter } from "./test_router";
describe("Link component", () => {
let fixture: HTMLElement;
let env: RouterEnv;
let router: TestRouter | null = null;
beforeEach(() => {
fixture = makeTestFixture();
env = <RouterEnv>makeTestEnv();
Component.env = env;
});
afterEach(() => {
fixture.remove();
if (router) {
router.destroy();
}
router = null;
});
test("can render simple cases", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="App">
<Link to="'about'">About</Link>
</div>
</templates>
`);
class App extends Component<any, any> {
static components = { Link: Link };
}
const routes = [
{ name: "about", path: "/about" },
{ name: "users", path: "/users" }
];
router = new TestRouter(env, routes, { mode: "history" });
router.navigate({ to: "users" });
const app = new App();
await app.mount(fixture);
expect(fixture.innerHTML).toBe('<div><a href="/about">About</a></div>');
expect(window.location.pathname).toBe("/users");
fixture.querySelector("a")!.click();
await nextTick();
expect(window.location.pathname).toBe("/about");
expect(fixture.innerHTML).toBe(
'<div><a href="/about" class="router-link-active">About</a></div>'
);
expect(env.qweb.templates[Link.template].fn.toString()).toMatchSnapshot();
});
test("do not redirect if right clicking", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="App">
<Link to="'about'">About</Link>
</div>
</templates>
`);
class App extends Component<any, any> {
static components = { Link: Link };
}
const routes = [
{ name: "about", path: "/about" },
{ name: "users", path: "/users" }
];
router = new TestRouter(env, routes, { mode: "history" });
router.navigate({ to: "users" });
const app = new App();
await app.mount(fixture);
expect(window.location.pathname).toBe("/users");
var evt = new MouseEvent("click", {
button: 1
});
fixture.querySelector("a")!.dispatchEvent(evt);
await nextTick();
expect(window.location.pathname).toBe("/users");
});
});
+106
View File
@@ -0,0 +1,106 @@
import { Component } from "../../src/component/component";
import { RouterEnv } from "../../src/router/router";
import { RouteComponent } from "../../src/router/route_component";
import { makeTestEnv, makeTestFixture, nextTick } from "../helpers";
import { TestRouter } from "./test_router";
describe("RouteComponent", () => {
let fixture: HTMLElement;
let env: RouterEnv;
let router: TestRouter | null = null;
beforeEach(() => {
fixture = makeTestFixture();
env = <RouterEnv>makeTestEnv();
Component.env = env;
});
afterEach(() => {
fixture.remove();
if (router) {
router.destroy();
}
router = null;
});
test("can render simple cases", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="App">
<RouteComponent />
</div>
<span t-name="About">About</span>
<span t-name="Users">Users</span>
</templates>
`);
class About extends Component<any, any> {}
class Users extends Component<any, any> {}
class App extends Component<any, any> {
static components = { RouteComponent };
}
const routes = [
{ name: "about", path: "/about", component: About },
{ name: "users", path: "/users", component: Users }
];
router = new TestRouter(env, routes, { mode: "history" });
await router.navigate({ to: "about" });
const app = new App();
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>About</span></div>");
await router.navigate({ to: "users" });
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>Users</span></div>");
expect(env.qweb.templates[RouteComponent.template].fn.toString()).toMatchSnapshot();
});
test("can render parameterized route", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="App">
<RouteComponent />
</div>
<span t-name="Book">Book <t t-esc="props.title"/></span>
</templates>
`);
class Book extends Component<any, any> {}
class App extends Component<any, any> {
static components = { RouteComponent };
}
const routes = [{ name: "book", path: "/book/{{title}}", component: Book }];
router = new TestRouter(env, routes, { mode: "history" });
await router.navigate({ to: "book", params: { title: "1984" } });
const app = new App();
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>Book 1984</span></div>");
});
test("can render parameterized route with suffixes", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="App">
<RouteComponent />
</div>
<span t-name="Book">Book <t t-esc="props.title"/>|<t t-esc="incVal"/></span>
</templates>
`);
class Book extends Component<any, any> {
get incVal() {
return this.props.val + 1;
}
}
class App extends Component<any, any> {
static components = { RouteComponent };
}
const routes = [{ name: "book", path: "/book/{{title}}/{{val.number}}", component: Book }];
router = new TestRouter(env, routes, { mode: "history" });
await router.navigate({ to: "book", params: { title: "1984", val: "123" } });
const app = new App();
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>Book 1984|124</span></div>");
});
});
+266
View File
@@ -0,0 +1,266 @@
import { Destination, RouterEnv, Route } from "../../src/router/router";
import { makeTestEnv, nextTick } from "../helpers";
import { TestRouter } from "./test_router";
let env: RouterEnv;
let router: TestRouter | null = null;
beforeEach(() => {
env = <RouterEnv>makeTestEnv();
window.history.pushState({}, "/", "/");
});
afterEach(() => {
if (router) {
router.destroy();
}
router = null;
});
describe("router miscellaneous", () => {
test("validate routes shape", () => {
expect(() => {
router = new TestRouter(env, [
{ name: "someroute", path: "/some/path", redirect: { abc: "hey" } as Destination }
]);
}).toThrow(`Invalid destination: {"abc":"hey"}`);
});
test("navigate to same route but with different params should trigger update", async () => {
router = new TestRouter(env, [{ name: "users", path: "/users/{{id}}" }]);
env.qweb.forceUpdate = jest.fn();
await router.navigate({ to: "users", params: { id: 3 } });
expect(window.location.pathname).toBe("/users/3");
expect(env.qweb.forceUpdate).toHaveBeenCalledTimes(1);
await router.navigate({ to: "users", params: { id: 5 } });
expect(window.location.pathname).toBe("/users/5");
expect(env.qweb.forceUpdate).toHaveBeenCalledTimes(2);
});
test("changing url to same route but with different params should trigger update (hash mode)", async () => {
env.qweb.forceUpdate = jest.fn();
router = new TestRouter(env, [{ name: "users", path: "/users/{{id}}" }], { mode: "hash" });
await router.start();
await router.navigate({ to: "users", params: { id: 3 } });
expect(window.location.hash).toBe("#/users/3");
expect(env.qweb.forceUpdate).toHaveBeenCalledTimes(1);
window.location.hash = "/users/5";
window.dispatchEvent(new Event("hashchange"));
await nextTick();
expect(window.location.hash).toBe("#/users/5");
expect(env.qweb.forceUpdate).toHaveBeenCalledTimes(2);
});
test("navigate in hash mode preserve location", async () => {
router = new TestRouter(env, [{ name: "users", path: "/users/{{id}}" }], { mode: "hash" });
window.history.pushState({}, "title", window.location.origin + "/test.html");
expect(window.location.href).toBe("http://localhost/test.html");
await router.navigate({ to: "users", params: { id: 3 } });
expect(window.location.href).toBe("http://localhost/test.html#/users/3");
});
});
describe("routeToPath", () => {
test("simple non parameterized path", () => {
router = new TestRouter(env, []);
expect(router["routeToPath"]({ path: "/abc" } as Route, {})).toBe("/abc");
expect(router["routeToPath"]({ path: "/abc/def" } as Route, {})).toBe("/abc/def");
expect(router["routeToPath"]({ path: "/abc" } as Route, { val: 12 })).toBe("/abc");
});
test("simple parameterized path", () => {
router = new TestRouter(env, []);
expect(router["routeToPath"]({ path: "/abc/{{def}}" } as Route, { def: 34 })).toBe("/abc/34");
});
test("simple non parameterized path, mode = hash", () => {
router = new TestRouter(env, [], { mode: "hash" });
expect(router["routeToPath"]({ path: "/abc" } as Route, {})).toBe("#/abc");
expect(router["routeToPath"]({ path: "/abc/def" } as Route, {})).toBe("#/abc/def");
expect(router["routeToPath"]({ path: "/abc" } as Route, { val: 12 })).toBe("#/abc");
});
test("simple parameterized path, mode=hash", () => {
router = new TestRouter(env, [], { mode: "hash" });
expect(router["routeToPath"]({ path: "/abc/{{def}}" } as Route, { def: 34 })).toBe("#/abc/34");
});
});
describe("destToPath", () => {
test("validate destination shape", () => {
router = new TestRouter(env, [{ name: "someroute", path: "/some/path" }]);
expect(() => {
router!.destToPath({ abc: 123 } as Destination);
}).toThrow('Invalid destination: {"abc":123}');
expect(() => {
router!.destToPath({ to: "someroute" } as Destination);
}).not.toThrow();
expect(() => {
router!.destToPath({ path: "/someroute", to: "otherroute" } as Destination);
}).toThrow();
});
});
describe("getRouteParams", () => {
test("properly match simple routes", () => {
router = new TestRouter(env, []);
// simple route
expect(router["getRouteParams"]({ path: "/home" } as Route, "/home")).toEqual({});
// no match
expect(router["getRouteParams"]({ path: "/home" } as Route, "/otherpath")).toEqual(false);
// fallback route
expect(router["getRouteParams"]({ path: "*" } as Route, "somepath")).toEqual({});
});
test("properly match simple routes, mode hash", () => {
router = new TestRouter(env, [], { mode: "hash" });
// simple route
expect(router["getRouteParams"]({ path: "/home" } as Route, "#/home")).toEqual({});
// no match
expect(router["getRouteParams"]({ path: "/home" } as Route, "#/otherpath")).toEqual(false);
// fallback route
expect(router["getRouteParams"]({ path: "*" } as Route, "#/somepath")).toEqual({});
});
test("match some parameterized routes", () => {
router = new TestRouter(env, []);
expect(router["getRouteParams"]({ path: "/invoices/{{id}}" } as Route, "/invoices/3")).toEqual({
id: "3"
});
});
test("match some parameterized routes, mode hash", () => {
router = new TestRouter(env, [], { mode: "hash" });
expect(router["getRouteParams"]({ path: "/invoices/{{id}}" } as Route, "#/invoices/3")).toEqual(
{
id: "3"
}
);
});
test("can convert to number if needed", () => {
router = new TestRouter(env, []);
expect(
router["getRouteParams"]({ path: "/invoices/{{id.number}}" } as Route, "/invoices/3")
).toEqual({
id: 3
});
});
test("can convert to number if needed, mode: hash", () => {
router = new TestRouter(env, [], { mode: "hash" });
expect(
router["getRouteParams"]({ path: "/invoices/{{id.number}}" } as Route, "#/invoices/3")
).toEqual({
id: 3
});
});
});
describe("redirect", () => {
test("can redirect to other route", async () => {
router = new TestRouter(env, [
{ name: "routea", path: "/some/path" },
{ name: "routeb", path: "/some/other/path", redirect: { to: "routea" } }
]);
await router.start();
expect(window.location.pathname).toBe("/");
await router.navigate({ to: "routeb" });
expect(window.location.pathname).toBe("/some/path");
expect(router.currentRouteName).toBe("routea");
});
test("can redirect to other path", async () => {
router = new TestRouter(env, [
{ name: "routea", path: "/some/path" },
{ name: "routeb", path: "/some/other/path", redirect: { path: "/some/path" } }
]);
router.navigate = jest.fn(router.navigate);
await router.start();
await router.navigate({ to: "routeb" });
// once because we ask for it, once because it is redirected
// expect(router.navigate).toBeCalledTimes(2);
expect(window.location.pathname).toBe("/some/path");
expect(router.currentRouteName).toBe("routea");
});
});
describe("beforeRouteEnter", () => {
test("navigation guard is called and properly handle return true", async () => {
expect(window.location.pathname).not.toBe("/some/path");
const guard = jest.fn(() => true);
router = new TestRouter(env, [{ name: "route", path: "/some/path", beforeRouteEnter: guard }]);
await router.start();
await router.navigate({ to: "route" });
expect(guard).toBeCalledTimes(1);
expect(window.location.pathname).toBe("/some/path");
expect(router.currentRouteName).toBe("route");
});
test("navigation is cancelled if guard return false", async () => {
expect(window.location.pathname).toBe("/");
const guard = jest.fn(() => false);
router = new TestRouter(env, [
{ name: "routea", path: "/some/patha" },
{ name: "routeb", path: "/some/pathb", beforeRouteEnter: guard }
]);
await router.start();
await router.navigate({ to: "routea" });
expect(window.location.pathname).toBe("/some/patha");
const result = await router.navigate({ to: "routeb" });
expect(result).toBe(false);
expect(guard).toBeCalledTimes(1);
expect(window.location.pathname).toBe("/some/patha");
expect(router.currentRouteName).toBe("routea");
});
test("navigation is redirected if guard decides so", async () => {
expect(window.location.pathname).toBe("/");
const guard = jest.fn(() => {
return { to: "routec" };
});
router = new TestRouter(env, [
{ name: "routea", path: "/some/patha" },
{ name: "routeb", path: "/some/pathb", beforeRouteEnter: guard },
{ name: "routec", path: "/some/pathc" }
]);
await router.start();
const result = await router.navigate({ to: "routea" });
expect(result).toBe(true);
expect(window.location.pathname).toBe("/some/patha");
await router.navigate({ to: "routeb" });
expect(guard).toBeCalledTimes(1);
expect(window.location.pathname).toBe("/some/pathc");
expect(router.currentRouteName).toBe("routec");
});
test("navigation is initially redirected if guard decides so", async () => {
expect(window.location.pathname).toBe("/");
const guard = jest.fn(() => {
return { to: "otherroute" };
});
router = new TestRouter(env, [
{ name: "landing", path: "/", beforeRouteEnter: guard },
{ name: "otherroute", path: "/some/other/route" }
]);
expect(window.location.pathname).toBe("/");
await router.start();
expect(window.location.pathname).toBe("/some/other/route");
});
});
+15
View File
@@ -0,0 +1,15 @@
import { Router } from "../../src/router/router";
import { QWeb } from "../../src/qweb/index";
export class TestRouter extends Router {
destroy() {
window.removeEventListener("popstate", (this as any)._listener);
// remove component defined inroutes
for (let key in QWeb.components) {
if (key.startsWith("__component__")) {
delete QWeb.components[key];
}
}
}
}
+78 -1053
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+58 -59
View File
@@ -1,4 +1,6 @@
import { h, patch, init } from "../src/vdom";
import { h, patch } from "../src/vdom";
import { htmlToVDOM } from "../src/vdom/html_to_vdom";
import { init, addNS } from "../src/vdom/vdom";
function map(list, fn) {
var ret: any[] = [];
@@ -183,20 +185,6 @@ describe("snabbdom", function() {
expect(elm.tagName).toBe("DIV");
});
test("has different tag and id", function() {
let elm: any = document.createElement("div");
vnode0.appendChild(elm);
const vnode1 = h("span#id");
elm = patch(elm, vnode1).elm;
expect(elm.tagName).toBe("SPAN");
expect(elm.id).toBe("id");
});
test("has id", function() {
elm = patch(vnode0, h("div", [h("div#unique")])).elm;
expect(elm.firstChild.id).toBe("unique");
});
test("has correct namespace", function() {
const SVGNamespace = "http://www.w3.org/2000/svg";
const XHTMLNamespace = "http://www.w3.org/1999/xhtml";
@@ -205,33 +193,15 @@ describe("snabbdom", function() {
expect(elm.firstChild.namespaceURI).toBe(SVGNamespace);
// verify that svg tag automatically gets svg namespace
elm = patch(vnode0, h("svg", [h("foreignObject", [h("div", ["I am HTML embedded in SVG"])])]))
.elm;
const vnode = h("svg", [h("foreignObject", [h("div", ["I am HTML embedded in SVG"])])]);
// need to add namespace manually. it is usually done by the template
// compiler
addNS(vnode.data, (vnode as any).children, vnode.sel);
elm = patch(vnode0, vnode).elm;
expect(elm.namespaceURI).toBe(SVGNamespace);
expect(elm.firstChild.namespaceURI).toBe(SVGNamespace);
expect(elm.firstChild.firstChild.namespaceURI).toBe(XHTMLNamespace);
// verify that svg tag with extra selectors gets svg namespace
elm = patch(vnode0, h("svg#some-id")).elm;
expect(elm.namespaceURI).toBe(SVGNamespace);
// verify that non-svg tag beginning with 'svg' does NOT get namespace
elm = patch(vnode0, h("svg-custom-el")).elm;
expect(elm.namespaceURI).not.toBe(SVGNamespace);
});
test("receives classes in selector", function() {
elm = patch(vnode0, h("div", [h("i.am.a.class")])).elm;
expect(elm.firstChild.classList.contains("am")).toBeTruthy();
expect(elm.firstChild.classList.contains("a")).toBeTruthy();
expect(elm.firstChild.classList.contains("class")).toBeTruthy();
});
test("receives classes in selector when namespaced", function() {
elm = patch(vnode0, h("svg", [h("g.am.a.class.too")])).elm;
expect(elm.firstChild.classList.contains("am")).toBeTruthy();
expect(elm.firstChild.classList.contains("a")).toBeTruthy();
expect(elm.firstChild.classList.contains("class")).toBeTruthy();
});
test("can create elements with text content", function() {
@@ -270,26 +240,6 @@ describe("snabbdom", function() {
});
describe("patching an element", function() {
test("changes the elements classes", function() {
const vnode1 = h("i.i.am.horse");
const vnode2 = h("i.i.am");
patch(vnode0, vnode1);
elm = patch(vnode1, vnode2).elm;
expect(elm.classList.contains("i")).toBeTruthy();
expect(elm.classList.contains("am")).toBeTruthy();
expect(!elm.classList.contains("horse")).toBeTruthy();
});
test("removes missing classes", function() {
const vnode1 = h("i.i.am.horse");
const vnode2 = h("i.i.am");
patch(vnode0, vnode1);
elm = patch(vnode1, vnode2).elm;
expect(elm.classList.contains("i")).toBeTruthy();
expect(elm.classList.contains("am")).toBeTruthy();
expect(!elm.classList.contains("horse")).toBeTruthy();
});
test("changes an elements props", function() {
const vnode1 = h("a", { props: { src: "http://other/" } });
const vnode2 = h("a", { props: { src: "http://localhost/" } });
@@ -1107,3 +1057,52 @@ describe("snabbdom", function() {
});
});
});
//------------------------------------------------------------------------------
// Html to vdom
//------------------------------------------------------------------------------
describe("html to vdom", function() {
let elm, vnode0;
beforeEach(function() {
elm = document.createElement("div");
vnode0 = elm;
});
test("empty strings return empty list", function() {
expect(htmlToVDOM("")).toEqual([]);
});
test("just text", function() {
const nodeList = htmlToVDOM("simple text");
expect(nodeList).toHaveLength(1);
expect(nodeList[0]).toEqual({ text: "simple text" });
});
test("empty tag", function() {
const nodeList = htmlToVDOM("<span></span>");
expect(nodeList).toHaveLength(1);
elm = patch(vnode0, nodeList[0]).elm;
expect(elm.outerHTML).toEqual("<span></span>");
});
test("tag with text", function() {
const nodeList = htmlToVDOM("<span>abc</span>");
expect(nodeList).toHaveLength(1);
elm = patch(vnode0, nodeList[0]).elm;
expect(elm.outerHTML).toEqual("<span>abc</span>");
});
test("tag with attribute", function() {
const nodeList = htmlToVDOM(`<span a="1" b="2">abc</span>`);
expect(nodeList).toHaveLength(1);
elm = patch(vnode0, nodeList[0]).elm;
expect(elm.outerHTML).toEqual(`<span a="1" b="2">abc</span>`);
});
test("misc", function() {
const nodeList = htmlToVDOM(`<span a="1" b="2">abc<div>1</div></span>`);
expect(nodeList).toHaveLength(1);
elm = patch(vnode0, nodeList[0]).elm;
expect(elm.outerHTML).toEqual(`<span a="1" b="2">abc<div>1</div></span>`);
});
});
+161
View File
@@ -0,0 +1,161 @@
import { buildData, startMeasure, stopMeasure, formatNumber } from "../shared/utils.js";
//------------------------------------------------------------------------------
// Likes Counter Widget
//------------------------------------------------------------------------------
class Counter extends owl.Component {
state = { counter: 0 };
increment() {
this.state.counter++;
}
}
//------------------------------------------------------------------------------
// Message Widget
//------------------------------------------------------------------------------
class Message extends owl.Component {
components = { Counter };
shouldUpdate(nextProps) {
return nextProps.message !== this.props.message;
}
removeMessage() {
this.trigger("remove-message", {
id: this.props.message.id
});
}
}
//------------------------------------------------------------------------------
// Root Widget
//------------------------------------------------------------------------------
class App extends owl.Component {
components = { Message };
state = { messages: [], multipleFlag: false, clearAfterFlag: false };
mounted() {
this.log(`Benchmarking Owl v${owl.__info__.version} (build date: ${owl.__info__.date})`);
}
benchmark(message, fn, callback) {
if (this.state.multipleFlag) {
const N = 20;
let n = N;
let total = 0;
let cb = info => {
let finalize = () => {
n--;
total += info.delta;
if (n === 0) {
const avg = total / N;
this.log(`Average: ${formatNumber(avg)}ms`, true);
if (callback) {
callback();
}
} else {
this._benchmark(message, fn, cb);
}
};
if (this.state.clearAfterFlag) {
this._benchmark(
"clear",
() => {
this.state.messages = [];
},
finalize,
false
);
} else {
finalize();
}
};
this._benchmark(message, fn, cb);
} else {
this._benchmark(message, fn, callback);
}
}
_benchmark(message, fn, cb, log = true) {
setTimeout(() => {
startMeasure(message);
fn();
stopMeasure(info => {
if (log) {
this.log(info.msg);
}
if (cb) {
cb(info);
}
});
}, 10);
}
addMessages(n) {
this.benchmark("add " + n, () => {
const newMessages = buildData(n);
this.state.messages.push.apply(this.state.messages, newMessages);
});
}
clear() {
this._benchmark("clear", () => {
this.state.messages = [];
});
}
updateSomeMessages() {
this.benchmark("update every 10th", () => {
const messages = this.state.messages;
for (let i = 0; i < this.state.messages.length; i += 10) {
const msg = Object.assign({}, messages[i]);
msg.author += "!!!";
owl.Observer.set(messages, i, msg);
}
});
}
removeMessage(event) {
this.benchmark("remove message", () => {
const index = this.state.messages.findIndex(m => m.id === event.detail.id);
this.state.messages.splice(index, 1);
});
}
log(str, isBold) {
const div = document.createElement("div");
if (isBold) {
div.classList.add("bold");
}
div.textContent = `> ${str}`;
this.refs.log.appendChild(div);
this.refs.log.scrollTop = this.refs.log.scrollHeight;
}
clearLog() {
this.refs.log.innerHTML = "";
}
toggleMultiple() {
this.state.multipleFlag = !this.state.multipleFlag;
}
toggleClear() {
this.state.clearAfterFlag = !this.state.clearAfterFlag;
}
}
//------------------------------------------------------------------------------
// Application initialization
//------------------------------------------------------------------------------
async function start() {
const templates = await owl.utils.loadTemplates("templates.xml");
const env = {
qweb: new owl.QWeb(templates)
};
const app = new App(env);
app.mount(document.body);
}
start();
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>OWL 0.17.0 Benchmark</title>
<link href="../shared/main.css" rel="stylesheet"/>
<script src='owl.js'></script>
</head>
<body>
<div id='main'></div>
<script src='app.js' type="module"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
<templates>
<div t-name="App" class="main">
<div class="left-thing">
<div class="title">Actions</div>
<div class="panel">
<button t-on-click="addMessages(100)">Add 100 messages</button>
<button t-on-click="addMessages(1000)">Add 1k messages</button>
<button t-on-click="addMessages(10000)">Add 10k messages</button>
<button t-on-click="addMessages(30000)">Add 30k messages</button>
<button t-on-click="updateSomeMessages">Update every 10th messages</button>
<button t-on-click="clear">Clear</button>
</div>
<div class="flags">
<div>
<input type="checkbox" id="multipleflag" t-on-change="toggleMultiple" t-att-checked="state.multipleFlag"/>
<label for="multipleflag">Do it 20x</label>
</div>
<div>
<input type="checkbox" id="clearFlag" t-on-change="toggleClear" t-att-checked="state.clearAfterFlag"/>
<label for="clearFlag">Clear after</label>
</div>
</div>
<div class="info">Number of messages: <t t-esc="state.messages.length"/></div>
<hr/>
<div class="title">Log <span class="clear-log" t-on-click="clearLog">(clear)</span></div>
<div class="log">
<div class="log-content" t-ref="log"/>
</div>
</div>
<div class="right-thing">
<div class="content" t-on-remove-message="removeMessage">
<t t-foreach="state.messages" t-as="message">
<Message t-key="message.id" message="message"/>
</t>
</div>
</div>
</div>
<div t-name="Message" class="message">
<span class="author"><t t-esc="props.message.author"/></span>
<span class="msg"><t t-esc="props.message.msg"/></span>
<button class="remove" t-on-click="removeMessage">Remove</button>
<Counter/>
</div>
<div t-name="Counter">
<button t-on-click="increment">Value: <t t-esc="state.counter"/></button>
</div>
</templates>
+161
View File
@@ -0,0 +1,161 @@
import { buildData, startMeasure, stopMeasure, formatNumber } from "../shared/utils.js";
//------------------------------------------------------------------------------
// Likes Counter Widget
//------------------------------------------------------------------------------
class Counter extends owl.Component {
state = { counter: 0 };
increment() {
this.state.counter++;
}
}
//------------------------------------------------------------------------------
// Message Widget
//------------------------------------------------------------------------------
class Message extends owl.Component {
components = { Counter };
shouldUpdate(nextProps) {
return nextProps.message !== this.props.message;
}
removeMessage() {
this.trigger("remove-message", {
id: this.props.message.id
});
}
}
//------------------------------------------------------------------------------
// Root Widget
//------------------------------------------------------------------------------
class App extends owl.Component {
components = { Message };
state = { messages: [], multipleFlag: false, clearAfterFlag: false };
mounted() {
this.log(`Benchmarking Owl v${owl.__info__.version} (build date: ${owl.__info__.date})`);
}
benchmark(message, fn, callback) {
if (this.state.multipleFlag) {
const N = 20;
let n = N;
let total = 0;
let cb = info => {
let finalize = () => {
n--;
total += info.delta;
if (n === 0) {
const avg = total / N;
this.log(`Average: ${formatNumber(avg)}ms`, true);
if (callback) {
callback();
}
} else {
this._benchmark(message, fn, cb);
}
};
if (this.state.clearAfterFlag) {
this._benchmark(
"clear",
() => {
this.state.messages = [];
},
finalize,
false
);
} else {
finalize();
}
};
this._benchmark(message, fn, cb);
} else {
this._benchmark(message, fn, callback);
}
}
_benchmark(message, fn, cb, log = true) {
setTimeout(() => {
startMeasure(message);
fn();
stopMeasure(info => {
if (log) {
this.log(info.msg);
}
if (cb) {
cb(info);
}
});
}, 10);
}
addMessages(n) {
this.benchmark("add " + n, () => {
const newMessages = buildData(n);
this.state.messages.push.apply(this.state.messages, newMessages);
});
}
clear() {
this._benchmark("clear", () => {
this.state.messages = [];
});
}
updateSomeMessages() {
this.benchmark("update every 10th", () => {
const messages = this.state.messages;
for (let i = 0; i < this.state.messages.length; i += 10) {
const msg = Object.assign({}, messages[i]);
msg.author += "!!!";
messages[i] = msg;
}
});
}
removeMessage(event) {
this.benchmark("remove message", () => {
const index = this.state.messages.findIndex(m => m.id === event.detail.id);
this.state.messages.splice(index, 1);
});
}
log(str, isBold) {
const div = document.createElement("div");
if (isBold) {
div.classList.add("bold");
}
div.textContent = `> ${str}`;
this.refs.log.appendChild(div);
this.refs.log.scrollTop = this.refs.log.scrollHeight;
}
clearLog() {
this.refs.log.innerHTML = "";
}
toggleMultiple() {
this.state.multipleFlag = !this.state.multipleFlag;
}
toggleClear() {
this.state.clearAfterFlag = !this.state.clearAfterFlag;
}
}
//------------------------------------------------------------------------------
// Application initialization
//------------------------------------------------------------------------------
async function start() {
const templates = await owl.utils.loadTemplates("templates.xml");
const env = {
qweb: new owl.QWeb(templates)
};
const app = new App(env);
app.mount(document.body);
}
start();

Some files were not shown because too many files have changed in this diff Show More