Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c085eef441 | |||
| b4f84513a6 | |||
| b8d09e523d | |||
| 04c2808701 | |||
| 15c2604df1 | |||
| 3e11fe6b12 | |||
| 20c6cacb4e | |||
| eb2b32ab60 | |||
| 2a223288d4 | |||
| 1272278225 | |||
| f502dd732e | |||
| 9c2d957525 | |||
| f8bb86820e | |||
| 0cde4b8737 | |||
| 66a801393f | |||
| e7f405cc97 | |||
| 55c48b2b12 | |||
| 2eb151c92d | |||
| 7952f31e63 | |||
| 11e4e67599 | |||
| 7b7a6de373 | |||
| b63d1e28b2 | |||
| c0667a11c6 | |||
| fddb1ec924 | |||
| e6c3b62ef0 | |||
| 97b69f164f | |||
| 33dfeb1b41 | |||
| dd292472b9 | |||
| 9b18b57fdf | |||
| 68f491cd32 | |||
| 7b3e39ba27 | |||
| 61fc3f4fdc | |||
| 7b454dae66 | |||
| 70101e4c66 | |||
| 5ef405293a | |||
| 9dcbbe54eb | |||
| e94428a186 | |||
| 941190dfa8 | |||
| a53e42518f | |||
| b7c37ca69a |
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x, 14.x, 16.x]
|
||||
node-version: [20.x, 22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
@@ -47,3 +47,4 @@ Utility/helpers:
|
||||
- [`status`](reference/component.md#status-helper): utility function to get the status of a component (new, mounted or destroyed)
|
||||
- [`validate`](reference/utils.md#validate): validates if an object satisfies a specified schema
|
||||
- [`whenReady`](reference/utils.md#whenready): utility function to execute code when DOM is ready
|
||||
- [`batched`](reference/utils.md#batched): utility function to batch function calls
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
- [API](#api)
|
||||
- [Configuration](#configuration)
|
||||
- [`mount` helper](#mount-helper)
|
||||
- [Roots](#roots)
|
||||
- [Loading templates](#loading-templates)
|
||||
|
||||
## Overview
|
||||
@@ -61,6 +62,8 @@ The `config` object is an object with some of the following keys:
|
||||
templates (see [translations](translations.md))
|
||||
- **`templates (string | xml document)`**: all the templates that will be used by
|
||||
the components created by the application.
|
||||
- **`getTemplate ((s: string) => Element | Function | string | void)`**: a function that will be called by owl when it
|
||||
needs a template. If undefined is returned, owl looks into the app templates.
|
||||
- **`warnIfNoStaticProps (boolean, default=false)`**: if true, Owl will log a warning
|
||||
whenever it encounters a component that does not provide a [static props description](props.md#props-validation).
|
||||
|
||||
@@ -90,6 +93,33 @@ Most of the time, the `mount` helper is more convenient, but whenever one needs
|
||||
a reference to the actual Owl App, then using the `App` class directly is
|
||||
possible.
|
||||
|
||||
## Roots
|
||||
|
||||
An application can have multiple roots. It is sometimes useful to instantiate
|
||||
sub components in places that are not managed by Owl, such as an html editor
|
||||
with dynamic content (the Knowledge application in Odoo).
|
||||
|
||||
To create a root, one can use the `createRoot` method, which takes two arguments:
|
||||
|
||||
- **`Component`**: a component class (Root component of the app)
|
||||
- **`config (optional)`**: a config object that may contain a `props` object or a
|
||||
`env` object.
|
||||
|
||||
The `createRoot` method returns an object with a `mount` method (same API as
|
||||
the `App.mount` method), and a `destroy` method.
|
||||
|
||||
```js
|
||||
const root = app.createRoot(MyComponent, { props: { someProps: true } });
|
||||
await root.mount(targetElement);
|
||||
|
||||
// later
|
||||
root.destroy();
|
||||
```
|
||||
|
||||
Note that, like with owl `App`, it is the responsibility of the code that created
|
||||
the root to properly destroy it (before it has been removed from the DOM!). Owl
|
||||
has no way of doing it itself.
|
||||
|
||||
## Loading templates
|
||||
|
||||
Most applications will need to load templates whenever they start. Here is
|
||||
|
||||
@@ -140,6 +140,28 @@ class SomeComponent extends Component {
|
||||
The `.bind` suffix also implies `.alike`, so these props will not cause additional
|
||||
renderings.
|
||||
|
||||
## Translatable props
|
||||
|
||||
When you need to pass a user-facing string to a subcomponent, you likely want it
|
||||
to be translated. Unfortunately, because props are arbitrary expressions, it wouldn't
|
||||
be practical for Owl to find out which parts of the expression are strings and translate
|
||||
them, and it also makes it difficult for tooling to extract these strings to generate
|
||||
terms to translate. While you can work around this issue by doing the translation in
|
||||
JavaScript, or by using `t-set` with a body (the body of `t-set` is translated),
|
||||
and passing the variable as a prop, this is a sufficiently common use case that Owl
|
||||
provides a suffix for this purpose: `.translate`.
|
||||
|
||||
```xml
|
||||
<t t-name="ParentComponent">
|
||||
<Child someProp.translate="some message"/>
|
||||
</t>
|
||||
```
|
||||
|
||||
Note that the content of this attribute is _NOT_ treated as a JavaScript expression:
|
||||
it is treated as a string, as if it was an attribute on an HTML element, and translated
|
||||
before being passed to the component. If you need to interpolate some data into the
|
||||
string, you will still have to do this in JavaScript.
|
||||
|
||||
## Dynamic Props
|
||||
|
||||
The `t-props` directive can be used to specify totally dynamic props:
|
||||
@@ -238,7 +260,7 @@ class ComponentB extends owl.Component {
|
||||
count: {type: Number},
|
||||
messages: {
|
||||
type: Array,
|
||||
element: {type: Object, shape: {id: Boolean, text: String }
|
||||
element: {type: Object, shape: {id: Boolean, text: String }}
|
||||
},
|
||||
date: Date,
|
||||
combinedVal: [Number, Boolean],
|
||||
@@ -276,7 +298,8 @@ class ComponentB extends owl.Component {
|
||||
id: Number,
|
||||
name: {type: String, optional: true},
|
||||
url: String
|
||||
]}, // object, with keys id (number), name (string, optional) and url (string)
|
||||
}
|
||||
}, // object, with keys id (number), name (string, optional) and url (string)
|
||||
someObj3: {
|
||||
type: Object,
|
||||
values: { type: Array, element: String },
|
||||
|
||||
@@ -152,7 +152,7 @@ This may seem counter-intuitive, but it makes perfect sense in the context of co
|
||||
```js
|
||||
class DoubleCounter extends Component {
|
||||
static template = xml`
|
||||
<t t-esc="state.selected + ': ' + state[state.selected].value"/>
|
||||
<t t-esc="'selected: ' + state.selected + ', value: ' + state[state.selected]"/>
|
||||
<button t-on-click="() => this.state.count1++">increment count 1</button>
|
||||
<button t-on-click="() => this.state.count2++">increment count 2</button>
|
||||
<button t-on-click="changeCounter">Switch counter</button>
|
||||
@@ -193,7 +193,7 @@ to be able to opt out of creating them in the first place. This is the purpose o
|
||||
### `markRaw`
|
||||
|
||||
Marks an object so that it is ignored by the reactivity system, meaning that if this object is ever
|
||||
part of a of a reactive object, it will be returned as is, and no keys in that object will be
|
||||
part of a reactive object, it will be returned as is, and no keys in that object will be
|
||||
observed.
|
||||
|
||||
```js
|
||||
|
||||
@@ -133,7 +133,7 @@ Slots can define a default content, in case the parent did not define them:
|
||||
## Dynamic Slots
|
||||
|
||||
The `t-slot` directive is actually able to use any expressions, using string
|
||||
interplolation:
|
||||
interpolation:
|
||||
|
||||
```xml
|
||||
<t t-slot="{{current}}" />
|
||||
@@ -201,16 +201,17 @@ use this `Notebook` component:
|
||||
|
||||
```xml
|
||||
<Notebook>
|
||||
<t t-set-slot="page1" title="'Page 1'">
|
||||
<t t-set-slot="page1" title.translate="Page 1">
|
||||
<div>this is in the page 1</div>
|
||||
</t>
|
||||
<t t-set-slot="page2" title="'Page 2'" hidden="somevalue">
|
||||
<t t-set-slot="page2" title.translate="Page 2" hidden="somevalue">
|
||||
<div>this is in the page 2</div>
|
||||
</t>
|
||||
</Notebook>
|
||||
```
|
||||
|
||||
Slot params works like normal props, so one can use the `.bind` suffix to
|
||||
Slot params works like normal props, so one can use suffixes like `.translate`
|
||||
when a prop is a user facing string and should be translated, or `.bind` to
|
||||
bind a function if needed.
|
||||
|
||||
## Slot scopes
|
||||
|
||||
@@ -9,6 +9,7 @@ functions are all available in the `owl.utils` namespace.
|
||||
- [`loadFile`](#loadfile): loading a file (useful for templates)
|
||||
- [`EventBus`](#eventbus): a simple EventBus
|
||||
- [`validate`](#validate): a validation function
|
||||
- [`batched`](#batched): batch function calls
|
||||
|
||||
## `whenReady`
|
||||
|
||||
@@ -78,3 +79,22 @@ validate(
|
||||
// - 'id' is missing (should be a number),
|
||||
// - 'url' is missing (should be a boolean or list of numbers),
|
||||
```
|
||||
|
||||
## `batched`
|
||||
|
||||
The `batched` function creates a batched version of a callback so that multiple calls to it within the same microtick will only result in a single invocation of the original callback.
|
||||
|
||||
```js
|
||||
function hello() {
|
||||
console.log("hello");
|
||||
}
|
||||
|
||||
const batchedHello = batched(hello);
|
||||
batchedHello();
|
||||
// Nothing is logged
|
||||
batchedHello();
|
||||
// Still not logged
|
||||
|
||||
await Promise.resolve(); // Await the next microtick
|
||||
// "hello" is logged only once
|
||||
```
|
||||
|
||||
@@ -35,13 +35,13 @@ The components tab is separated into two sub windows: the components tree in the
|
||||
the component details in the right. The components tree will display all the different
|
||||
components that are present in the tab in the form of a tree. The root of this tree is
|
||||
actually the app which is not a component but can still be inspected by the devtools like
|
||||
one. There can also be multiple apps loaded in the page like in the following:
|
||||
one. There can also be multiple apps loaded in the page like in website:
|
||||
|
||||
<img src="screenshots/multi_apps.png"/>
|
||||
|
||||
There is a convenient search bar at the top of the components tree which will help finding
|
||||
the components tou want in the tree and also, an element picker can be used to directly select
|
||||
the component you want to focus on in the page which is especially useful when trying to find
|
||||
the component you want to focus on in the page which is especially useful when 1trying to find
|
||||
what you want. Just click on the elements picker icon and click on the element you want to focus
|
||||
on in the page and it will be selected in the devtools accordingly. Hovering any element in the
|
||||
page in this mode will highlight it and the same happens anytime in the components tree.
|
||||
@@ -64,25 +64,18 @@ as its env, props, observed states and all the other variables that are present
|
||||
While the props and the env are already present on the actual instance of the component and are
|
||||
pretty explicit by themselves, the observed state value is a bit more complicated to grasp.
|
||||
|
||||
The observed state is actually information about which variables are observed by the component
|
||||
which will trigger a rerender of the component when it is modified. The keys represent which part of the
|
||||
variable is actually observed and the target is the actual variable. For simplicity, the properties
|
||||
that are not observed by the component are greyed out while the others are in bold. This means that
|
||||
editing bold ones will trigger a rerender while the greyed out ones will not.
|
||||
The observed state is actually information about which variables are being observed by the component:
|
||||
when any property of a reactive object is being read by the component, the component will subscribe
|
||||
to this property which means it will listen to any change that can occur on the property and render
|
||||
when such a change occurs. This can be visualized easily within the devtools inside of the observed
|
||||
state section: observed properties of the reactive object(s) are displayed in bold while the others
|
||||
are greyed out. Do keep in mind that a greyed out property in the observed state of one component
|
||||
may be observed by another and the other way around is also possible. Here is an example for some
|
||||
user Field component:
|
||||
|
||||
<img src="screenshots/states.png"/>
|
||||
|
||||
In the given example, we have two keys/target pairs for two different variables. The first one indicates
|
||||
that adding or removing an element to the array will trigger a rerender since the length will have changed.
|
||||
Replacing the element at index 0, 1 or 2 will also have the same effect as implied by the keys. It doesn't
|
||||
mean that editing the properties of element at index 0, 1 or 2 will rerender the component though. It may
|
||||
be the case for some but this will be described in another keys/target pair. The second keys/target pair
|
||||
is actually the element at index 0 of the first pair. It only has id in the keys meaning that only the
|
||||
id property will actually trigger a rerender the component when modified. Be aware however that the other
|
||||
properties may be in the observed state of another component like a child one in this case. A greyed out
|
||||
property only implies it is not reactive for the selected component and not for the others.
|
||||
|
||||
The navigation inside the properties is also similar to the one in console variables: properties have
|
||||
Navigation inside the properties is also similar to the one in console variables: properties have
|
||||
their prototype displayed and getters will get their value when clicked on (...). It is also possible to
|
||||
send any property to the console using the right-click context menu on it and functions can be inspected
|
||||
in the sources tab as well.
|
||||
@@ -96,8 +89,10 @@ component's name. Using the left click on the component's name will focus it in
|
||||
It is also possible to edit any of the leaf node properties. To do so, you must double click on the
|
||||
property's value and modify it using the freshly created input then press enter to apply the changes.
|
||||
Do note that the modified values should be written in JSON format in order to be valid (examples:
|
||||
89, "yes", undefined, null, \["hello", 15\], {"a": 1}, true, ...). Whether it has an impact on the
|
||||
component or not and whether it produces an error is the responsability of the user.
|
||||
89, "yes", undefined, null, \["hello", 15\], {"a": 1}, true, ...). Editing any value will produce a
|
||||
manual render of the component (or the root component of the application in the case of env values).
|
||||
Whether the edition has an impact on the component or not and whether it produces an error is the
|
||||
responsability of the user.
|
||||
|
||||
<img src="screenshots/edit.png"/>
|
||||
|
||||
@@ -117,7 +112,8 @@ are intercepted by the devtools using the record button.
|
||||
The second button is used to clear all the events that have been recorded. The select can be used to
|
||||
switch between the tree view (which shows the causality between renders) and the events log view which
|
||||
simply displays the events in the exact order they were triggered. In this view, you can expand the create,
|
||||
update and destroy events which reveals the component that initiated the event.
|
||||
update and destroy events which reveals the component that initiated the event. Also, a transition line will
|
||||
appear each time a new animation frame has been loaded between events.
|
||||
|
||||
<img src="screenshots/events_log.png"/>
|
||||
|
||||
@@ -131,20 +127,29 @@ There is also the Trace Renderings and Trace Subscriptions features. These featu
|
||||
recording of events and have no effect on the profiler tab. The Trace Renderings option is used to log in
|
||||
the console all the render events and allows to show their traceback information. Similarly, the Trace
|
||||
Subscriptions option logs all the properties that caused a render event and also allows to see the traceback
|
||||
of the modification
|
||||
of the modification.
|
||||
|
||||
<img src="screenshots/trace_rendering.png"/>
|
||||
<img src="screenshots/trace_subscriptions.png"/>
|
||||
|
||||
The Owl Devtools also allow to inspect iframes coded in Owl: when an Owl iframe is detected in the page,
|
||||
the iframe selector will appear next to the tabs. This allows to switch from an iframe to another easily.
|
||||
Be aware that switching iframes will clear all record events from the profiler tab. Iframes detection is
|
||||
currently not working in the firefox version, we are aware of this issue and will try to address it in the
|
||||
future.
|
||||
|
||||
<img src="screenshots/iframes.png"/>
|
||||
|
||||
## Options
|
||||
|
||||
The owl devtools extension has a dark mode feature which defaults to your general devtools settings and can
|
||||
be toggled using the sun/moon icon at the top-right corner of the tab. All the examples above were created
|
||||
with the dark mode enabled. There is also a refresh button to completely reset the owl devtools.
|
||||
be toggled using the sun/moon icon at the top-right corner of the tab. There is also a refresh button to
|
||||
completely reset the owl devtools.
|
||||
|
||||
<img src="screenshots/darkmode.png"/>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the feedback from the page to the devtools seems to be cut, just close the devtools and refresh the page.
|
||||
If the feedback from the page to the devtools seems to be cut, you can first try to use the refresh
|
||||
button mentioned above but if it still doesn't seem to work, just close the devtools and refresh the page.
|
||||
This will eventually happen any time a tab stays opened for too long without being refreshed.
|
||||
|
||||
|
Before Width: | Height: | Size: 333 KiB After Width: | Height: | Size: 545 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 311 KiB After Width: | Height: | Size: 536 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 488 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 182 KiB |
@@ -1850,8 +1850,9 @@ const NO_CALLBACK = () => {
|
||||
};
|
||||
const objectToString = Object.prototype.toString;
|
||||
const objectHasOwnProperty = Object.prototype.hasOwnProperty;
|
||||
const SUPPORTED_RAW_TYPES = new Set(["Object", "Array", "Set", "Map", "WeakMap"]);
|
||||
const COLLECTION_RAWTYPES = new Set(["Set", "Map", "WeakMap"]);
|
||||
// Use arrays because Array.includes is faster than Set.has for small arrays
|
||||
const SUPPORTED_RAW_TYPES = ["Object", "Array", "Set", "Map", "WeakMap"];
|
||||
const COLLECTION_RAW_TYPES = ["Set", "Map", "WeakMap"];
|
||||
/**
|
||||
* extract "RawType" from strings like "[object RawType]" => this lets us ignore
|
||||
* many native objects such as Promise (whose toString is [object Promise])
|
||||
@@ -1874,7 +1875,7 @@ function canBeMadeReactive(value) {
|
||||
if (typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
return SUPPORTED_RAW_TYPES.has(rawType(value));
|
||||
return SUPPORTED_RAW_TYPES.includes(rawType(value));
|
||||
}
|
||||
/**
|
||||
* Creates a reactive from the given object/callback if possible and returns it,
|
||||
@@ -2044,7 +2045,7 @@ function reactive(target, callback = NO_CALLBACK) {
|
||||
const reactivesForTarget = reactiveCache.get(target);
|
||||
if (!reactivesForTarget.has(callback)) {
|
||||
const targetRawType = rawType(target);
|
||||
const handler = COLLECTION_RAWTYPES.has(targetRawType)
|
||||
const handler = COLLECTION_RAW_TYPES.includes(targetRawType)
|
||||
? collectionsProxyHandler(target, callback, targetRawType)
|
||||
: basicProxyHandler(callback);
|
||||
const proxy = new Proxy(target, handler);
|
||||
@@ -2073,7 +2074,7 @@ function basicProxyHandler(callback) {
|
||||
set(target, key, value, receiver) {
|
||||
const hadKey = objectHasOwnProperty.call(target, key);
|
||||
const originalValue = Reflect.get(target, key, receiver);
|
||||
const ret = Reflect.set(target, key, value, receiver);
|
||||
const ret = Reflect.set(target, key, toRaw(value), receiver);
|
||||
if (!hadKey && objectHasOwnProperty.call(target, key)) {
|
||||
notifyReactives(target, KEYCHANGES);
|
||||
}
|
||||
@@ -2597,42 +2598,47 @@ class ComponentNode {
|
||||
}
|
||||
|
||||
const TIMEOUT = Symbol("timeout");
|
||||
const HOOK_TIMEOUT = {
|
||||
onWillStart: 3000,
|
||||
onWillUpdateProps: 3000,
|
||||
};
|
||||
function wrapError(fn, hookName) {
|
||||
const error = new OwlError(`The following error occurred in ${hookName}: `);
|
||||
const timeoutError = new OwlError(`${hookName}'s promise hasn't resolved after 3 seconds`);
|
||||
const error = new OwlError();
|
||||
const timeoutError = new OwlError();
|
||||
const node = getCurrent();
|
||||
return (...args) => {
|
||||
const onError = (cause) => {
|
||||
error.cause = cause;
|
||||
if (cause instanceof Error) {
|
||||
error.message += `"${cause.message}"`;
|
||||
}
|
||||
else {
|
||||
error.message = `Something that is not an Error was thrown in ${hookName} (see this Error's "cause" property)`;
|
||||
}
|
||||
error.message =
|
||||
cause instanceof Error
|
||||
? `The following error occurred in ${hookName}: "${cause.message}"`
|
||||
: `Something that is not an Error was thrown in ${hookName} (see this Error's "cause" property)`;
|
||||
throw error;
|
||||
};
|
||||
let result;
|
||||
try {
|
||||
const result = fn(...args);
|
||||
if (result instanceof Promise) {
|
||||
if (hookName === "onWillStart" || hookName === "onWillUpdateProps") {
|
||||
const fiber = node.fiber;
|
||||
Promise.race([
|
||||
result.catch(() => { }),
|
||||
new Promise((resolve) => setTimeout(() => resolve(TIMEOUT), 3000)),
|
||||
]).then((res) => {
|
||||
if (res === TIMEOUT && node.fiber === fiber) {
|
||||
console.warn(timeoutError);
|
||||
}
|
||||
});
|
||||
}
|
||||
return result.catch(onError);
|
||||
}
|
||||
return result;
|
||||
result = fn(...args);
|
||||
}
|
||||
catch (cause) {
|
||||
onError(cause);
|
||||
}
|
||||
if (!(result instanceof Promise)) {
|
||||
return result;
|
||||
}
|
||||
const timeout = HOOK_TIMEOUT[hookName];
|
||||
if (timeout) {
|
||||
const fiber = node.fiber;
|
||||
Promise.race([
|
||||
result.catch(() => { }),
|
||||
new Promise((resolve) => setTimeout(() => resolve(TIMEOUT), timeout)),
|
||||
]).then((res) => {
|
||||
if (res === TIMEOUT && node.fiber === fiber && node.status <= 2) {
|
||||
timeoutError.message = `${hookName}'s promise hasn't resolved after ${timeout / 1000} seconds`;
|
||||
console.log(timeoutError);
|
||||
}
|
||||
});
|
||||
}
|
||||
return result.catch(onError);
|
||||
};
|
||||
}
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -3159,8 +3165,14 @@ const helpers = {
|
||||
makeRefWrapper,
|
||||
};
|
||||
|
||||
const bdom = { text, createBlock, list, multi, html, toggler, comment };
|
||||
function parseXML$1(xml) {
|
||||
/**
|
||||
* Parses an XML string into an XML document, throwing errors on parser errors
|
||||
* instead of returning an XML document containing the parseerror.
|
||||
*
|
||||
* @param xml the string to parse
|
||||
* @returns an XML document corresponding to the content of the string
|
||||
*/
|
||||
function parseXML(xml) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xml, "text/xml");
|
||||
if (doc.getElementsByTagName("parsererror").length) {
|
||||
@@ -3187,7 +3199,9 @@ function parseXML$1(xml) {
|
||||
throw new OwlError(msg);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
|
||||
const bdom = { text, createBlock, list, multi, html, toggler, comment };
|
||||
class TemplateSet {
|
||||
constructor(config = {}) {
|
||||
this.rawTemplates = Object.create(globalTemplates);
|
||||
@@ -3197,8 +3211,16 @@ class TemplateSet {
|
||||
this.translateFn = config.translateFn;
|
||||
this.translatableAttributes = config.translatableAttributes;
|
||||
if (config.templates) {
|
||||
this.addTemplates(config.templates);
|
||||
if (config.templates instanceof Document || typeof config.templates === "string") {
|
||||
this.addTemplates(config.templates);
|
||||
}
|
||||
else {
|
||||
for (const name in config.templates) {
|
||||
this.addTemplate(name, config.templates[name]);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.getRawTemplate = config.getTemplate;
|
||||
}
|
||||
static registerTemplate(name, fn) {
|
||||
globalTemplates[name] = fn;
|
||||
@@ -3228,15 +3250,16 @@ class TemplateSet {
|
||||
// empty string
|
||||
return;
|
||||
}
|
||||
xml = xml instanceof Document ? xml : parseXML$1(xml);
|
||||
xml = xml instanceof Document ? xml : parseXML(xml);
|
||||
for (const template of xml.querySelectorAll("[t-name]")) {
|
||||
const name = template.getAttribute("t-name");
|
||||
this.addTemplate(name, template);
|
||||
}
|
||||
}
|
||||
getTemplate(name) {
|
||||
var _a;
|
||||
if (!(name in this.templates)) {
|
||||
const rawTemplate = this.rawTemplates[name];
|
||||
const rawTemplate = ((_a = this.getRawTemplate) === null || _a === void 0 ? void 0 : _a.call(this, name)) || this.rawTemplates[name];
|
||||
if (rawTemplate === undefined) {
|
||||
let extraInfo = "";
|
||||
try {
|
||||
@@ -3494,7 +3517,7 @@ function compileExprToArray(expr) {
|
||||
const localVars = new Set();
|
||||
const tokens = tokenize(expr);
|
||||
let i = 0;
|
||||
let stack = []; // to track last opening [ or {
|
||||
let stack = []; // to track last opening (, [ or {
|
||||
while (i < tokens.length) {
|
||||
let token = tokens[i];
|
||||
let prevToken = tokens[i - 1];
|
||||
@@ -3503,10 +3526,12 @@ function compileExprToArray(expr) {
|
||||
switch (token.type) {
|
||||
case "LEFT_BRACE":
|
||||
case "LEFT_BRACKET":
|
||||
case "LEFT_PAREN":
|
||||
stack.push(token.type);
|
||||
break;
|
||||
case "RIGHT_BRACE":
|
||||
case "RIGHT_BRACKET":
|
||||
case "RIGHT_PAREN":
|
||||
stack.pop();
|
||||
}
|
||||
let isVar = token.type === "SYMBOL" && !RESERVED_WORDS.includes(token.value);
|
||||
@@ -3618,6 +3643,13 @@ function isProp(tag, key) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Returns a template literal that evaluates to str. You can add interpolation
|
||||
* sigils into the string if required
|
||||
*/
|
||||
function toStringExpression(str) {
|
||||
return `\`${str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/, "\\${")}\``;
|
||||
}
|
||||
// -----------------------------------------------------------------------------
|
||||
// BlockDescription
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -3798,15 +3830,14 @@ class CodeGenerator {
|
||||
mainCode.push(``);
|
||||
for (let block of this.blocks) {
|
||||
if (block.dom) {
|
||||
let xmlString = block.asXmlString();
|
||||
xmlString = xmlString.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
|
||||
let xmlString = toStringExpression(block.asXmlString());
|
||||
if (block.dynamicTagName) {
|
||||
xmlString = xmlString.replace(/^<\w+/, `<\${tag || '${block.dom.nodeName}'}`);
|
||||
xmlString = xmlString.replace(/\w+>$/, `\${tag || '${block.dom.nodeName}'}>`);
|
||||
mainCode.push(`let ${block.blockName} = tag => createBlock(\`${xmlString}\`);`);
|
||||
xmlString = xmlString.replace(/^`<\w+/, `\`<\${tag || '${block.dom.nodeName}'}`);
|
||||
xmlString = xmlString.replace(/\w+>`$/, `\${tag || '${block.dom.nodeName}'}>\``);
|
||||
mainCode.push(`let ${block.blockName} = tag => createBlock(${xmlString});`);
|
||||
}
|
||||
else {
|
||||
mainCode.push(`let ${block.blockName} = createBlock(\`${xmlString}\`);`);
|
||||
mainCode.push(`let ${block.blockName} = createBlock(${xmlString});`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3984,7 +4015,7 @@ class CodeGenerator {
|
||||
const isNewBlock = !block || forceNewBlock;
|
||||
if (isNewBlock) {
|
||||
block = this.createBlock(block, "comment", ctx);
|
||||
this.insertBlock(`comment(\`${ast.value}\`)`, block, {
|
||||
this.insertBlock(`comment(${toStringExpression(ast.value)})`, block, {
|
||||
...ctx,
|
||||
forceNewBlock: forceNewBlock && !block,
|
||||
});
|
||||
@@ -4006,7 +4037,7 @@ class CodeGenerator {
|
||||
}
|
||||
if (!block || forceNewBlock) {
|
||||
block = this.createBlock(block, "text", ctx);
|
||||
this.insertBlock(`text(\`${value}\`)`, block, {
|
||||
this.insertBlock(`text(${toStringExpression(value)})`, block, {
|
||||
...ctx,
|
||||
forceNewBlock: forceNewBlock && !block,
|
||||
});
|
||||
@@ -4227,7 +4258,8 @@ class CodeGenerator {
|
||||
expr = compileExpr(ast.expr);
|
||||
if (ast.defaultValue) {
|
||||
this.helpers.add("withDefault");
|
||||
expr = `withDefault(${expr}, \`${ast.defaultValue}\`)`;
|
||||
// FIXME: defaultValue is not translated
|
||||
expr = `withDefault(${expr}, ${toStringExpression(ast.defaultValue)})`;
|
||||
}
|
||||
}
|
||||
if (!block || forceNewBlock) {
|
||||
@@ -4480,7 +4512,7 @@ class CodeGenerator {
|
||||
this.addLine(`${ctxVar}[zero] = ${bl};`);
|
||||
}
|
||||
}
|
||||
const key = `key + \`${this.generateComponentKey()}\``;
|
||||
const key = this.generateComponentKey();
|
||||
if (isDynamic) {
|
||||
const templateVar = generateId("template");
|
||||
if (!this.staticDefs.find((d) => d.id === "call")) {
|
||||
@@ -4532,12 +4564,12 @@ class CodeGenerator {
|
||||
else {
|
||||
let value;
|
||||
if (ast.defaultValue) {
|
||||
const defaultValue = ctx.translate ? this.translate(ast.defaultValue) : ast.defaultValue;
|
||||
const defaultValue = toStringExpression(ctx.translate ? this.translate(ast.defaultValue) : ast.defaultValue);
|
||||
if (ast.value) {
|
||||
value = `withDefault(${expr}, \`${defaultValue}\`)`;
|
||||
value = `withDefault(${expr}, ${defaultValue})`;
|
||||
}
|
||||
else {
|
||||
value = `\`${defaultValue}\``;
|
||||
value = defaultValue;
|
||||
}
|
||||
}
|
||||
else {
|
||||
@@ -4548,12 +4580,12 @@ class CodeGenerator {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
generateComponentKey() {
|
||||
generateComponentKey(currentKey = "key") {
|
||||
const parts = [generateId("__")];
|
||||
for (let i = 0; i < this.target.loopLevel; i++) {
|
||||
parts.push(`\${key${i + 1}}`);
|
||||
}
|
||||
return parts.join("__");
|
||||
return `${currentKey} + \`${parts.join("__")}\``;
|
||||
}
|
||||
/**
|
||||
* Formats a prop name and value into a string suitable to be inserted in the
|
||||
@@ -4567,7 +4599,12 @@ class CodeGenerator {
|
||||
* "onClick.bind" "onClick" "onClick: bind(ctx, ctx['onClick'])"
|
||||
*/
|
||||
formatProp(name, value) {
|
||||
value = this.captureExpression(value);
|
||||
if (name.endsWith(".translate")) {
|
||||
value = toStringExpression(this.translateFn(value));
|
||||
}
|
||||
else {
|
||||
value = this.captureExpression(value);
|
||||
}
|
||||
if (name.includes(".")) {
|
||||
let [_name, suffix] = name.split(".");
|
||||
name = _name;
|
||||
@@ -4576,6 +4613,7 @@ class CodeGenerator {
|
||||
value = `(${value}).bind(this)`;
|
||||
break;
|
||||
case "alike":
|
||||
case "translate":
|
||||
break;
|
||||
default:
|
||||
throw new OwlError("Invalid prop suffix");
|
||||
@@ -4644,7 +4682,6 @@ class CodeGenerator {
|
||||
this.addLine(`${propVar}.slots = markRaw(Object.assign(${slotDef}, ${propVar}.slots))`);
|
||||
}
|
||||
// cmap key
|
||||
const key = this.generateComponentKey();
|
||||
let expr;
|
||||
if (ast.isDynamic) {
|
||||
expr = generateId("Comp");
|
||||
@@ -4660,7 +4697,7 @@ class CodeGenerator {
|
||||
// todo: check the forcenewblock condition
|
||||
this.insertAnchor(block);
|
||||
}
|
||||
let keyArg = `key + \`${key}\``;
|
||||
let keyArg = this.generateComponentKey();
|
||||
if (ctx.tKeyExpr) {
|
||||
keyArg = `${ctx.tKeyExpr} + ${keyArg}`;
|
||||
}
|
||||
@@ -4733,7 +4770,7 @@ class CodeGenerator {
|
||||
}
|
||||
let key = this.target.loopLevel ? `key${this.target.loopLevel}` : "key";
|
||||
if (isMultiple) {
|
||||
key = `${key} + \`${this.generateComponentKey()}\``;
|
||||
key = this.generateComponentKey(key);
|
||||
}
|
||||
const props = ast.attrs ? this.formatPropObject(ast.attrs) : [];
|
||||
const scope = this.getPropString(props, dynProps);
|
||||
@@ -4774,7 +4811,6 @@ class CodeGenerator {
|
||||
}
|
||||
let { block } = ctx;
|
||||
const name = this.compileInNewTarget("slot", ast.content, ctx);
|
||||
const key = this.generateComponentKey();
|
||||
let ctxStr = "ctx";
|
||||
if (this.target.loopLevel || !this.hasSafeContext) {
|
||||
ctxStr = generateId("ctx");
|
||||
@@ -4787,7 +4823,8 @@ class CodeGenerator {
|
||||
expr: `app.createComponent(null, false, true, false, false)`,
|
||||
});
|
||||
const target = compileExpr(ast.target);
|
||||
const blockString = `${id}({target: ${target},slots: {'default': {__render: ${name}.bind(this), __ctx: ${ctxStr}}}}, key + \`${key}\`, node, ctx, Portal)`;
|
||||
const key = this.generateComponentKey();
|
||||
const blockString = `${id}({target: ${target},slots: {'default': {__render: ${name}.bind(this), __ctx: ${ctxStr}}}}, ${key}, node, ctx, Portal)`;
|
||||
if (block) {
|
||||
this.insertAnchor(block);
|
||||
}
|
||||
@@ -4946,9 +4983,9 @@ function parseDOMNode(node, ctx) {
|
||||
const isSelect = tagName === "select";
|
||||
const isCheckboxInput = isInput && typeAttr === "checkbox";
|
||||
const isRadioInput = isInput && typeAttr === "radio";
|
||||
const hasLazyMod = attr.includes(".lazy");
|
||||
const hasNumberMod = attr.includes(".number");
|
||||
const hasTrimMod = attr.includes(".trim");
|
||||
const hasLazyMod = hasTrimMod || attr.includes(".lazy");
|
||||
const hasNumberMod = attr.includes(".number");
|
||||
const eventType = isRadioInput ? "click" : isSelect || hasLazyMod ? "change" : "input";
|
||||
model = {
|
||||
baseExpr,
|
||||
@@ -5279,14 +5316,14 @@ function parseComponent(node, ctx) {
|
||||
// be ignored)
|
||||
let el = slotNode.parentElement;
|
||||
let isInSubComponent = false;
|
||||
while (el !== clone) {
|
||||
while (el && el !== clone) {
|
||||
if (el.hasAttribute("t-component") || el.tagName[0] === el.tagName[0].toUpperCase()) {
|
||||
isInSubComponent = true;
|
||||
break;
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
if (isInSubComponent) {
|
||||
if (isInSubComponent || !el) {
|
||||
continue;
|
||||
}
|
||||
slotNode.removeAttribute("t-set-slot");
|
||||
@@ -5494,41 +5531,6 @@ function normalizeTEscTOut(el) {
|
||||
function normalizeXML(el) {
|
||||
normalizeTIf(el);
|
||||
normalizeTEscTOut(el);
|
||||
}
|
||||
/**
|
||||
* Parses an XML string into an XML document, throwing errors on parser errors
|
||||
* instead of returning an XML document containing the parseerror.
|
||||
*
|
||||
* @param xml the string to parse
|
||||
* @returns an XML document corresponding to the content of the string
|
||||
*/
|
||||
function parseXML(xml) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xml, "text/xml");
|
||||
if (doc.getElementsByTagName("parsererror").length) {
|
||||
let msg = "Invalid XML in template.";
|
||||
const parsererrorText = doc.getElementsByTagName("parsererror")[0].textContent;
|
||||
if (parsererrorText) {
|
||||
msg += "\nThe parser has produced the following error message:\n" + parsererrorText;
|
||||
const re = /\d+/g;
|
||||
const firstMatch = re.exec(parsererrorText);
|
||||
if (firstMatch) {
|
||||
const lineNumber = Number(firstMatch[0]);
|
||||
const line = xml.split("\n")[lineNumber - 1];
|
||||
const secondMatch = re.exec(parsererrorText);
|
||||
if (line && secondMatch) {
|
||||
const columnIndex = Number(secondMatch[0]) - 1;
|
||||
if (line[columnIndex]) {
|
||||
msg +=
|
||||
`\nThe error might be located at xml line ${lineNumber} column ${columnIndex}\n` +
|
||||
`${line}\n${"-".repeat(columnIndex - 1)}^`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new OwlError(msg);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
function compile(template, options = {}) {
|
||||
@@ -5555,7 +5557,7 @@ function compile(template, options = {}) {
|
||||
}
|
||||
|
||||
// do not modify manually. This file is generated by the release script.
|
||||
const version = "2.2.6";
|
||||
const version = "2.4.0";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Scheduler
|
||||
@@ -5650,6 +5652,7 @@ class App extends TemplateSet {
|
||||
constructor(Root, config = {}) {
|
||||
super(config);
|
||||
this.scheduler = new Scheduler();
|
||||
this.subRoots = new Set();
|
||||
this.root = null;
|
||||
this.name = config.name || "";
|
||||
this.Root = Root;
|
||||
@@ -5668,14 +5671,42 @@ class App extends TemplateSet {
|
||||
this.props = config.props || {};
|
||||
}
|
||||
mount(target, options) {
|
||||
App.validateTarget(target);
|
||||
if (this.dev) {
|
||||
validateProps(this.Root, this.props, { __owl__: { app: this } });
|
||||
const root = this.createRoot(this.Root, { props: this.props });
|
||||
this.root = root.node;
|
||||
this.subRoots.delete(root.node);
|
||||
return root.mount(target, options);
|
||||
}
|
||||
createRoot(Root, config = {}) {
|
||||
const props = config.props || {};
|
||||
// hack to make sure the sub root get the sub env if necessary. for owl 3,
|
||||
// would be nice to rethink the initialization process to make sure that
|
||||
// we can create a ComponentNode and give it explicitely the env, instead
|
||||
// of looking it up in the app
|
||||
const env = this.env;
|
||||
if (config.env) {
|
||||
this.env = config.env;
|
||||
}
|
||||
const node = this.makeNode(this.Root, this.props);
|
||||
const prom = this.mountNode(node, target, options);
|
||||
this.root = node;
|
||||
return prom;
|
||||
const node = this.makeNode(Root, props);
|
||||
if (config.env) {
|
||||
this.env = env;
|
||||
}
|
||||
this.subRoots.add(node);
|
||||
return {
|
||||
node,
|
||||
mount: (target, options) => {
|
||||
App.validateTarget(target);
|
||||
if (this.dev) {
|
||||
validateProps(Root, props, { __owl__: { app: this } });
|
||||
}
|
||||
const prom = this.mountNode(node, target, options);
|
||||
return prom;
|
||||
},
|
||||
destroy: () => {
|
||||
this.subRoots.delete(node);
|
||||
node.destroy();
|
||||
this.scheduler.processTasks();
|
||||
},
|
||||
};
|
||||
}
|
||||
makeNode(Component, props) {
|
||||
return new ComponentNode(Component, props, this, null, null);
|
||||
@@ -5707,6 +5738,9 @@ class App extends TemplateSet {
|
||||
}
|
||||
destroy() {
|
||||
if (this.root) {
|
||||
for (let subroot of this.subRoots) {
|
||||
subroot.destroy();
|
||||
}
|
||||
this.root.destroy();
|
||||
this.scheduler.processTasks();
|
||||
}
|
||||
@@ -5902,7 +5936,7 @@ function useChildSubEnv(envExtension) {
|
||||
*
|
||||
* @template T
|
||||
* @param {Effect<T>} effect the effect to run on component mount and/or patch
|
||||
* @param {()=>T} [computeDependencies=()=>[NaN]] a callback to compute
|
||||
* @param {()=>[...T]} [computeDependencies=()=>[NaN]] a callback to compute
|
||||
* dependencies that will decide if the effect needs to be cleaned up and
|
||||
* run again. If the dependencies did not change, the effect will not run
|
||||
* again. The default value returns an array containing only NaN because
|
||||
@@ -5981,9 +6015,9 @@ TemplateSet.prototype._compileTemplate = function _compileTemplate(name, templat
|
||||
});
|
||||
};
|
||||
|
||||
export { App, Component, EventBus, OwlError, __info__, blockDom, loadFile, markRaw, markup, mount, onError, onMounted, onPatched, onRendered, onWillDestroy, onWillPatch, onWillRender, onWillStart, onWillUnmount, onWillUpdateProps, reactive, status, toRaw, useChildSubEnv, useComponent, useEffect, useEnv, useExternalListener, useRef, useState, useSubEnv, validate, validateType, whenReady, xml };
|
||||
export { App, Component, EventBus, OwlError, __info__, batched, blockDom, loadFile, markRaw, markup, mount, onError, onMounted, onPatched, onRendered, onWillDestroy, onWillPatch, onWillRender, onWillStart, onWillUnmount, onWillUpdateProps, reactive, status, toRaw, useChildSubEnv, useComponent, useEffect, useEnv, useExternalListener, useRef, useState, useSubEnv, validate, validateType, whenReady, xml };
|
||||
|
||||
|
||||
__info__.date = '2023-09-25T11:48:01.531Z';
|
||||
__info__.hash = '752160f';
|
||||
__info__.date = '2024-09-30T08:49:29.420Z';
|
||||
__info__.hash = 'eb2b32a';
|
||||
__info__.url = 'https://github.com/odoo/owl';
|
||||
|
||||
@@ -41,9 +41,6 @@ const loadFile = (path) => {
|
||||
* Make an iframe, with all the js, css and xml properly injected.
|
||||
*/
|
||||
function makeCodeIframe(js, css, xml) {
|
||||
// escape backticks in the xml so they don't close the template string
|
||||
const escapedXml = xml.replace(/`/g, '\\\`');
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.onload = () => {
|
||||
const doc = iframe.contentDocument;
|
||||
@@ -55,6 +52,8 @@ function makeCodeIframe(js, css, xml) {
|
||||
|
||||
const script = doc.createElement("script");
|
||||
script.type = "module";
|
||||
// escape characters with special meaning in template literals
|
||||
const escapedXml = xml.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/, "\\${");
|
||||
script.textContent = `const TEMPLATES = \`${escapedXml}\`\n${js}`;
|
||||
doc.body.appendChild(script);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@odoo/owl",
|
||||
"version": "2.2.6",
|
||||
"version": "2.4.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@odoo/owl",
|
||||
"version": "2.2.6",
|
||||
"version": "2.4.0",
|
||||
"description": "Odoo Web Library (OWL)",
|
||||
"main": "dist/owl.cjs.js",
|
||||
"module": "dist/owl.es.js",
|
||||
@@ -9,7 +9,7 @@
|
||||
"dist"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12.18.3"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build:bundle": "rollup -c --failAfterWarnings",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OwlError } from "../common/owl_error";
|
||||
import { OwlError } from "./owl_error";
|
||||
|
||||
/**
|
||||
* Owl QWeb Expression Parser
|
||||
@@ -268,7 +268,7 @@ export function compileExprToArray(expr: string): Token[] {
|
||||
const localVars = new Set<string>();
|
||||
const tokens = tokenize(expr);
|
||||
let i = 0;
|
||||
let stack = []; // to track last opening [ or {
|
||||
let stack = []; // to track last opening (, [ or {
|
||||
|
||||
while (i < tokens.length) {
|
||||
let token = tokens[i];
|
||||
@@ -279,10 +279,12 @@ export function compileExprToArray(expr: string): Token[] {
|
||||
switch (token.type) {
|
||||
case "LEFT_BRACE":
|
||||
case "LEFT_BRACKET":
|
||||
case "LEFT_PAREN":
|
||||
stack.push(token.type);
|
||||
break;
|
||||
case "RIGHT_BRACE":
|
||||
case "RIGHT_BRACKET":
|
||||
case "RIGHT_PAREN":
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { OwlError } from "./owl_error";
|
||||
|
||||
/**
|
||||
* Parses an XML string into an XML document, throwing errors on parser errors
|
||||
* instead of returning an XML document containing the parseerror.
|
||||
*
|
||||
* @param xml the string to parse
|
||||
* @returns an XML document corresponding to the content of the string
|
||||
*/
|
||||
export function parseXML(xml: string): XMLDocument {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xml, "text/xml");
|
||||
if (doc.getElementsByTagName("parsererror").length) {
|
||||
let msg = "Invalid XML in template.";
|
||||
const parsererrorText = doc.getElementsByTagName("parsererror")[0].textContent;
|
||||
if (parsererrorText) {
|
||||
msg += "\nThe parser has produced the following error message:\n" + parsererrorText;
|
||||
const re = /\d+/g;
|
||||
const firstMatch = re.exec(parsererrorText);
|
||||
if (firstMatch) {
|
||||
const lineNumber = Number(firstMatch[0]);
|
||||
const line = xml.split("\n")[lineNumber - 1];
|
||||
const secondMatch = re.exec(parsererrorText);
|
||||
if (line && secondMatch) {
|
||||
const columnIndex = Number(secondMatch[0]) - 1;
|
||||
if (line[columnIndex]) {
|
||||
msg +=
|
||||
`\nThe error might be located at xml line ${lineNumber} column ${columnIndex}\n` +
|
||||
`${line}\n${"-".repeat(columnIndex - 1)}^`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new OwlError(msg);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
interpolate,
|
||||
INTERP_REGEXP,
|
||||
replaceDynamicParts,
|
||||
} from "./inline_expressions";
|
||||
} from "../common/inline_expressions";
|
||||
import {
|
||||
AST,
|
||||
ASTComment,
|
||||
@@ -82,6 +82,14 @@ function isProp(tag: string, key: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a template literal that evaluates to str. You can add interpolation
|
||||
* sigils into the string if required
|
||||
*/
|
||||
function toStringExpression(str: string) {
|
||||
return `\`${str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/, "\\${")}\``;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// BlockDescription
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -311,14 +319,13 @@ export class CodeGenerator {
|
||||
mainCode.push(``);
|
||||
for (let block of this.blocks) {
|
||||
if (block.dom) {
|
||||
let xmlString = block.asXmlString();
|
||||
xmlString = xmlString.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
|
||||
let xmlString = toStringExpression(block.asXmlString());
|
||||
if (block.dynamicTagName) {
|
||||
xmlString = xmlString.replace(/^<\w+/, `<\${tag || '${block.dom.nodeName}'}`);
|
||||
xmlString = xmlString.replace(/\w+>$/, `\${tag || '${block.dom.nodeName}'}>`);
|
||||
mainCode.push(`let ${block.blockName} = tag => createBlock(\`${xmlString}\`);`);
|
||||
xmlString = xmlString.replace(/^`<\w+/, `\`<\${tag || '${block.dom.nodeName}'}`);
|
||||
xmlString = xmlString.replace(/\w+>`$/, `\${tag || '${block.dom.nodeName}'}>\``);
|
||||
mainCode.push(`let ${block.blockName} = tag => createBlock(${xmlString});`);
|
||||
} else {
|
||||
mainCode.push(`let ${block.blockName} = createBlock(\`${xmlString}\`);`);
|
||||
mainCode.push(`let ${block.blockName} = createBlock(${xmlString});`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,7 +522,7 @@ export class CodeGenerator {
|
||||
const isNewBlock = !block || forceNewBlock;
|
||||
if (isNewBlock) {
|
||||
block = this.createBlock(block, "comment", ctx);
|
||||
this.insertBlock(`comment(\`${ast.value}\`)`, block, {
|
||||
this.insertBlock(`comment(${toStringExpression(ast.value)})`, block, {
|
||||
...ctx,
|
||||
forceNewBlock: forceNewBlock && !block,
|
||||
});
|
||||
@@ -539,7 +546,7 @@ export class CodeGenerator {
|
||||
|
||||
if (!block || forceNewBlock) {
|
||||
block = this.createBlock(block, "text", ctx);
|
||||
this.insertBlock(`text(\`${value}\`)`, block, {
|
||||
this.insertBlock(`text(${toStringExpression(value)})`, block, {
|
||||
...ctx,
|
||||
forceNewBlock: forceNewBlock && !block,
|
||||
});
|
||||
@@ -774,7 +781,8 @@ export class CodeGenerator {
|
||||
expr = compileExpr(ast.expr);
|
||||
if (ast.defaultValue) {
|
||||
this.helpers.add("withDefault");
|
||||
expr = `withDefault(${expr}, \`${ast.defaultValue}\`)`;
|
||||
// FIXME: defaultValue is not translated
|
||||
expr = `withDefault(${expr}, ${toStringExpression(ast.defaultValue)})`;
|
||||
}
|
||||
}
|
||||
if (!block || forceNewBlock) {
|
||||
@@ -1039,7 +1047,7 @@ export class CodeGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
const key = `key + \`${this.generateComponentKey()}\``;
|
||||
const key = this.generateComponentKey();
|
||||
if (isDynamic) {
|
||||
const templateVar = generateId("template");
|
||||
if (!this.staticDefs.find((d) => d.id === "call")) {
|
||||
@@ -1091,11 +1099,13 @@ export class CodeGenerator {
|
||||
} else {
|
||||
let value: string;
|
||||
if (ast.defaultValue) {
|
||||
const defaultValue = ctx.translate ? this.translate(ast.defaultValue) : ast.defaultValue;
|
||||
const defaultValue = toStringExpression(
|
||||
ctx.translate ? this.translate(ast.defaultValue) : ast.defaultValue
|
||||
);
|
||||
if (ast.value) {
|
||||
value = `withDefault(${expr}, \`${defaultValue}\`)`;
|
||||
value = `withDefault(${expr}, ${defaultValue})`;
|
||||
} else {
|
||||
value = `\`${defaultValue}\``;
|
||||
value = defaultValue;
|
||||
}
|
||||
} else {
|
||||
value = expr;
|
||||
@@ -1106,12 +1116,12 @@ export class CodeGenerator {
|
||||
return null;
|
||||
}
|
||||
|
||||
generateComponentKey() {
|
||||
generateComponentKey(currentKey: string = "key") {
|
||||
const parts = [generateId("__")];
|
||||
for (let i = 0; i < this.target.loopLevel; i++) {
|
||||
parts.push(`\${key${i + 1}}`);
|
||||
}
|
||||
return parts.join("__");
|
||||
return `${currentKey} + \`${parts.join("__")}\``;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1126,7 +1136,11 @@ export class CodeGenerator {
|
||||
* "onClick.bind" "onClick" "onClick: bind(ctx, ctx['onClick'])"
|
||||
*/
|
||||
formatProp(name: string, value: string): string {
|
||||
value = this.captureExpression(value);
|
||||
if (name.endsWith(".translate")) {
|
||||
value = toStringExpression(this.translateFn(value));
|
||||
} else {
|
||||
value = this.captureExpression(value);
|
||||
}
|
||||
if (name.includes(".")) {
|
||||
let [_name, suffix] = name.split(".");
|
||||
name = _name;
|
||||
@@ -1135,6 +1149,7 @@ export class CodeGenerator {
|
||||
value = `(${value}).bind(this)`;
|
||||
break;
|
||||
case "alike":
|
||||
case "translate":
|
||||
break;
|
||||
default:
|
||||
throw new OwlError("Invalid prop suffix");
|
||||
@@ -1214,7 +1229,6 @@ export class CodeGenerator {
|
||||
}
|
||||
|
||||
// cmap key
|
||||
const key = this.generateComponentKey();
|
||||
let expr: string;
|
||||
if (ast.isDynamic) {
|
||||
expr = generateId("Comp");
|
||||
@@ -1232,7 +1246,7 @@ export class CodeGenerator {
|
||||
this.insertAnchor(block);
|
||||
}
|
||||
|
||||
let keyArg = `key + \`${key}\``;
|
||||
let keyArg = this.generateComponentKey();
|
||||
if (ctx.tKeyExpr) {
|
||||
keyArg = `${ctx.tKeyExpr} + ${keyArg}`;
|
||||
}
|
||||
@@ -1311,7 +1325,7 @@ export class CodeGenerator {
|
||||
}
|
||||
let key = this.target.loopLevel ? `key${this.target.loopLevel}` : "key";
|
||||
if (isMultiple) {
|
||||
key = `${key} + \`${this.generateComponentKey()}\``;
|
||||
key = this.generateComponentKey(key);
|
||||
}
|
||||
|
||||
const props = ast.attrs ? this.formatPropObject(ast.attrs) : [];
|
||||
@@ -1354,7 +1368,6 @@ export class CodeGenerator {
|
||||
|
||||
let { block } = ctx;
|
||||
const name = this.compileInNewTarget("slot", ast.content, ctx);
|
||||
const key = this.generateComponentKey();
|
||||
let ctxStr = "ctx";
|
||||
if (this.target.loopLevel || !this.hasSafeContext) {
|
||||
ctxStr = generateId("ctx");
|
||||
@@ -1368,7 +1381,8 @@ export class CodeGenerator {
|
||||
});
|
||||
|
||||
const target = compileExpr(ast.target);
|
||||
const blockString = `${id}({target: ${target},slots: {'default': {__render: ${name}.bind(this), __ctx: ${ctxStr}}}}, key + \`${key}\`, node, ctx, Portal)`;
|
||||
const key = this.generateComponentKey();
|
||||
const blockString = `${id}({target: ${target},slots: {'default': {__render: ${name}.bind(this), __ctx: ${ctxStr}}}}, ${key}, node, ctx, Portal)`;
|
||||
if (block) {
|
||||
this.insertAnchor(block);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { OwlError } from "../common/owl_error";
|
||||
import { parseXML } from "../common/utils";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// AST Type definition
|
||||
@@ -366,9 +367,9 @@ function parseDOMNode(node: Element, ctx: ParsingContext): AST | null {
|
||||
const isSelect = tagName === "select";
|
||||
const isCheckboxInput = isInput && typeAttr === "checkbox";
|
||||
const isRadioInput = isInput && typeAttr === "radio";
|
||||
const hasLazyMod = attr.includes(".lazy");
|
||||
const hasNumberMod = attr.includes(".number");
|
||||
const hasTrimMod = attr.includes(".trim");
|
||||
const hasLazyMod = hasTrimMod || attr.includes(".lazy");
|
||||
const hasNumberMod = attr.includes(".number");
|
||||
const eventType = isRadioInput ? "click" : isSelect || hasLazyMod ? "change" : "input";
|
||||
|
||||
model = {
|
||||
@@ -740,14 +741,14 @@ function parseComponent(node: Element, ctx: ParsingContext): AST | null {
|
||||
// be ignored)
|
||||
let el = slotNode.parentElement!;
|
||||
let isInSubComponent = false;
|
||||
while (el !== clone) {
|
||||
while (el && el !== clone) {
|
||||
if (el!.hasAttribute("t-component") || el!.tagName[0] === el!.tagName[0].toUpperCase()) {
|
||||
isInSubComponent = true;
|
||||
break;
|
||||
}
|
||||
el = el.parentElement!;
|
||||
}
|
||||
if (isInSubComponent) {
|
||||
if (isInSubComponent || !el) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -972,40 +973,3 @@ function normalizeXML(el: Element) {
|
||||
normalizeTIf(el);
|
||||
normalizeTEscTOut(el);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an XML string into an XML document, throwing errors on parser errors
|
||||
* instead of returning an XML document containing the parseerror.
|
||||
*
|
||||
* @param xml the string to parse
|
||||
* @returns an XML document corresponding to the content of the string
|
||||
*/
|
||||
function parseXML(xml: string): XMLDocument {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xml, "text/xml");
|
||||
if (doc.getElementsByTagName("parsererror").length) {
|
||||
let msg = "Invalid XML in template.";
|
||||
const parsererrorText = doc.getElementsByTagName("parsererror")[0].textContent;
|
||||
if (parsererrorText) {
|
||||
msg += "\nThe parser has produced the following error message:\n" + parsererrorText;
|
||||
const re = /\d+/g;
|
||||
const firstMatch = re.exec(parsererrorText);
|
||||
if (firstMatch) {
|
||||
const lineNumber = Number(firstMatch[0]);
|
||||
const line = xml.split("\n")[lineNumber - 1];
|
||||
const secondMatch = re.exec(parsererrorText);
|
||||
if (line && secondMatch) {
|
||||
const columnIndex = Number(secondMatch[0]) - 1;
|
||||
if (line[columnIndex]) {
|
||||
msg +=
|
||||
`\nThe error might be located at xml line ${lineNumber} column ${columnIndex}\n` +
|
||||
`${line}\n${"-".repeat(columnIndex - 1)}^`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new OwlError(msg);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { OwlError } from "../common/owl_error";
|
||||
import { version } from "../version";
|
||||
import { Component, ComponentConstructor, Props } from "./component";
|
||||
import { ComponentNode } from "./component_node";
|
||||
import { nodeErrorHandlers, handleError } from "./error_handling";
|
||||
import { OwlError } from "../common/owl_error";
|
||||
import { Fiber, RootFiber, MountOptions } from "./fibers";
|
||||
import { ComponentNode, saveCurrent } from "./component_node";
|
||||
import { handleError, nodeErrorHandlers } from "./error_handling";
|
||||
import { Fiber, MountOptions, RootFiber } from "./fibers";
|
||||
import { reactive, toRaw } from "./reactivity";
|
||||
import { Scheduler } from "./scheduler";
|
||||
import { validateProps } from "./template_helpers";
|
||||
import { TemplateSet, TemplateSetConfig } from "./template_set";
|
||||
import { validateTarget } from "./utils";
|
||||
import { toRaw, reactive } from "./reactivity";
|
||||
|
||||
// reimplement dev mode stuff see last change in 0f7a8289a6fb8387c3c1af41c6664b2a8448758f
|
||||
|
||||
@@ -16,10 +16,13 @@ export interface Env {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface AppConfig<P, E> extends TemplateSetConfig {
|
||||
name?: string;
|
||||
export interface RootConfig<P, E> {
|
||||
props?: P;
|
||||
env?: E;
|
||||
}
|
||||
|
||||
export interface AppConfig<P, E> extends TemplateSetConfig, RootConfig<P, E> {
|
||||
name?: string;
|
||||
test?: boolean;
|
||||
warnIfNoStaticProps?: boolean;
|
||||
}
|
||||
@@ -49,6 +52,12 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
interface Root<P extends Props, E> {
|
||||
node: ComponentNode<P, E> | null;
|
||||
mount(target: HTMLElement | ShadowRoot, options?: MountOptions): Promise<Component<P, E>>;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
window.__OWL_DEVTOOLS__ ||= { apps, Fiber, RootFiber, toRaw, reactive };
|
||||
|
||||
export class App<
|
||||
@@ -65,8 +74,10 @@ export class App<
|
||||
props: P;
|
||||
env: E;
|
||||
scheduler = new Scheduler();
|
||||
root: ComponentNode<P, E> | null = null;
|
||||
subRoots: Set<Root<any, any>> = new Set();
|
||||
root: Root<P, E> | null = null;
|
||||
warnIfNoStaticProps: boolean;
|
||||
_lastRootEl: HTMLElement | ShadowRoot | null = null; // temporary ref to propagate to roots
|
||||
|
||||
constructor(Root: ComponentConstructor<P, E>, config: AppConfig<P, E> = {}) {
|
||||
super(config);
|
||||
@@ -91,14 +102,64 @@ export class App<
|
||||
target: HTMLElement | ShadowRoot,
|
||||
options?: MountOptions
|
||||
): Promise<Component<P, E> & InstanceType<T>> {
|
||||
App.validateTarget(target);
|
||||
if (this.dev) {
|
||||
validateProps(this.Root, this.props, { __owl__: { app: this } });
|
||||
}
|
||||
const node = this.makeNode(this.Root, this.props);
|
||||
const prom = this.mountNode(node, target, options);
|
||||
this.root = node;
|
||||
return prom;
|
||||
this.root = this.createRoot(this.Root, { props: this.props });
|
||||
return this.root.mount(target, options) as any;
|
||||
}
|
||||
|
||||
createRoot<Props extends object, SubEnv = any>(
|
||||
Root: ComponentConstructor<Props, E>,
|
||||
config: RootConfig<Props, SubEnv> = {}
|
||||
): Root<Props, SubEnv> {
|
||||
const props = config.props || ({} as Props);
|
||||
const env = this.env;
|
||||
const root: Root<Props, SubEnv> = {
|
||||
node: null,
|
||||
mount: (target: HTMLElement | ShadowRoot, options?: MountOptions) => {
|
||||
App.validateTarget(target);
|
||||
|
||||
// hack to make sure the sub root get the sub env if necessary. for owl 3,
|
||||
// would be nice to rethink the initialization process to make sure that
|
||||
// we can create a ComponentNode and give it explicitely the env, instead
|
||||
// of looking it up in the app
|
||||
if (config.env) {
|
||||
this.env = config.env as any;
|
||||
}
|
||||
const restore = saveCurrent();
|
||||
if (options?.position === "attach") {
|
||||
if (Root.template) {
|
||||
throw new Error("Cannot attach a component with a template");
|
||||
}
|
||||
this._lastRootEl = target;
|
||||
} else {
|
||||
if (!Root.template) {
|
||||
// no template => trigger an error
|
||||
this.getTemplate("");
|
||||
}
|
||||
}
|
||||
const node = this.makeNode(Root, props);
|
||||
root.node = node;
|
||||
this._lastRootEl = null;
|
||||
restore();
|
||||
if (config.env) {
|
||||
this.env = env;
|
||||
}
|
||||
if (this.dev) {
|
||||
validateProps(Root, props, { __owl__: { app: this } });
|
||||
}
|
||||
const prom = this.mountNode(node, target, options);
|
||||
return prom;
|
||||
},
|
||||
destroy: () => {
|
||||
this.subRoots.delete(root);
|
||||
if (root.node) {
|
||||
root.node?.destroy();
|
||||
this.scheduler.processTasks();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
this.subRoots.add(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
makeNode(Component: ComponentConstructor, props: any): ComponentNode {
|
||||
@@ -133,10 +194,17 @@ export class App<
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.root) {
|
||||
this.root.destroy();
|
||||
this.scheduler.processTasks();
|
||||
const roots = [...this.subRoots].reverse();
|
||||
for (let root of roots) {
|
||||
root.destroy();
|
||||
}
|
||||
// if (this.root) {
|
||||
// for (let subroot of this.subRoots) {
|
||||
// subroot.destroy();
|
||||
// }
|
||||
// this.root.destroy();
|
||||
this.scheduler.processTasks();
|
||||
// }
|
||||
apps.delete(this);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export type Props = { [key: string]: any };
|
||||
|
||||
interface StaticComponentProperties {
|
||||
template: string;
|
||||
dynamicContent?: { [spec: string]: string };
|
||||
defaultProps?: any;
|
||||
props?: Schema;
|
||||
components?: { [componentName: string]: ComponentConstructor };
|
||||
@@ -23,7 +24,7 @@ export type ComponentConstructor<P extends Props = any, E = any> = (new (
|
||||
|
||||
export class Component<Props = any, Env = any> {
|
||||
static template: string = "";
|
||||
static props?: any;
|
||||
static props?: Schema;
|
||||
static defaultProps?: any;
|
||||
|
||||
props: Props;
|
||||
|
||||
@@ -6,10 +6,19 @@ import { OwlError } from "../common/owl_error";
|
||||
import { Fiber, makeChildFiber, makeRootFiber, MountFiber, MountOptions } from "./fibers";
|
||||
import { clearReactivesForCallback, getSubscriptions, reactive, targets } from "./reactivity";
|
||||
import { STATUS } from "./status";
|
||||
import { batched, Callback } from "./utils";
|
||||
import { batched, Callback, Markup } from "./utils";
|
||||
import { xml } from "./template_set";
|
||||
import { compileExpr } from "../common/inline_expressions";
|
||||
|
||||
let currentNode: ComponentNode | null = null;
|
||||
|
||||
export function saveCurrent() {
|
||||
let n = currentNode;
|
||||
return () => {
|
||||
currentNode = n;
|
||||
};
|
||||
}
|
||||
|
||||
export function getCurrent(): ComponentNode {
|
||||
if (!currentNode) {
|
||||
throw new OwlError("No active component (a hook function should only be called in 'setup')");
|
||||
@@ -117,11 +126,102 @@ export class ComponentNode<P extends Props = any, E = any> implements VNode<Comp
|
||||
}
|
||||
this.component = new C(props, env, this);
|
||||
const ctx = Object.assign(Object.create(this.component), { this: this.component });
|
||||
this.renderFn = app.getTemplate(C.template).bind(this.component, ctx, this);
|
||||
if (C.template) {
|
||||
this.renderFn = app.getTemplate(C.template).bind(this.component, ctx, this);
|
||||
} else {
|
||||
// component will be attached
|
||||
this.renderFn = app.getTemplate(xml``).bind(this.component, ctx, this);
|
||||
if (C.dynamicContent) {
|
||||
this.prepareAttach(app._lastRootEl!, C.dynamicContent, ctx);
|
||||
}
|
||||
}
|
||||
this.component.setup();
|
||||
currentNode = null;
|
||||
}
|
||||
|
||||
prepareAttach(
|
||||
el: HTMLElement | ShadowRoot,
|
||||
dynamicContent: { [spec: string]: string },
|
||||
ctx: any
|
||||
) {
|
||||
const attrs: { selector: string; attr: string; fn: Function }[] = [];
|
||||
const handlers: { selector: string; event: string; fn: any }[] = [];
|
||||
const tOuts: { selector: string; fn: Function }[] = [];
|
||||
for (let key in dynamicContent) {
|
||||
const value = dynamicContent[key];
|
||||
const parts = key.split(":");
|
||||
if (parts[1].startsWith("t-att-")) {
|
||||
const attr = parts[1].slice(6);
|
||||
const fn = new Function("ctx", `return ${compileExpr(value)};`);
|
||||
attrs.push({
|
||||
selector: parts[0],
|
||||
attr,
|
||||
fn,
|
||||
});
|
||||
}
|
||||
if (parts[1].startsWith("t-on-")) {
|
||||
const event = parts[1].slice(5);
|
||||
// const fn = new Function("ctx", "ev", `${compileExpr(value)}(ev);`);
|
||||
const fn = (ev: any) => (this as any).component[value](ev);
|
||||
handlers.push({
|
||||
selector: parts[0],
|
||||
event,
|
||||
fn,
|
||||
});
|
||||
}
|
||||
if (parts[1] === "t-out") {
|
||||
const fn = new Function("ctx", `return ${compileExpr(value)};`);
|
||||
tOuts.push({ selector: parts[0], fn });
|
||||
}
|
||||
}
|
||||
const handleAttrs = () => {
|
||||
for (let attr of attrs) {
|
||||
const val = attr.fn.call(this.component, ctx);
|
||||
// todo: cache the queryselector result?
|
||||
const target = attr.selector === "root" ? el : (el.querySelector(attr.selector) as any);
|
||||
if (target) {
|
||||
target.setAttribute(attr.attr, val);
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleEvents = () => {
|
||||
for (let handler of handlers) {
|
||||
// const val = attr.fn.call(this.component, ctx);
|
||||
// todo: cache the queryselector result?
|
||||
const target =
|
||||
handler.selector === "root" ? el : (el.querySelector(handler.selector) as any);
|
||||
if (target) {
|
||||
target.addEventListener(handler.event, handler.fn);
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleTOuts = () => {
|
||||
for (let tOut of tOuts) {
|
||||
const val = tOut.fn.call(this.component, ctx);
|
||||
// todo: cache the queryselector result?
|
||||
const target = tOut.selector === "root" ? el : (el.querySelector(tOut.selector) as any);
|
||||
if (target) {
|
||||
if (val instanceof Markup) {
|
||||
target.innerHTML = val as any;
|
||||
} else {
|
||||
target.textContent = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if (attrs.length) {
|
||||
this.mounted.push(handleAttrs);
|
||||
this.patched.push(handleAttrs);
|
||||
}
|
||||
if (handlers.length) {
|
||||
this.mounted.push(handleEvents);
|
||||
}
|
||||
if (tOuts.length) {
|
||||
this.mounted.push(handleTOuts);
|
||||
this.patched.push(handleTOuts);
|
||||
}
|
||||
}
|
||||
|
||||
mountComponent(target: any, options?: MountOptions) {
|
||||
const fiber = new MountFiber(this, target, options);
|
||||
this.app.scheduler.addFiber(fiber);
|
||||
|
||||
@@ -207,7 +207,7 @@ export class RootFiber extends Fiber {
|
||||
}
|
||||
}
|
||||
|
||||
type Position = "first-child" | "last-child";
|
||||
type Position = "first-child" | "last-child" | "attach";
|
||||
|
||||
export interface MountOptions {
|
||||
position?: Position;
|
||||
|
||||
@@ -136,3 +136,20 @@ export function useExternalListener(
|
||||
onMounted(() => target.addEventListener(eventName, boundHandler, eventParams));
|
||||
onWillUnmount(() => target.removeEventListener(eventName, boundHandler, eventParams));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// useAttachedEl
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The purpose of this hook is to allow attached components to get a reference to
|
||||
* the element they have been attached on.
|
||||
*/
|
||||
export function useAttachedEl(): HTMLElement {
|
||||
const node = getCurrent();
|
||||
const el = node.app._lastRootEl as HTMLElement;
|
||||
if (!el) {
|
||||
throw new Error("useAttachedEl can only be called with component that are attached");
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export { useComponent, useState } from "./component_node";
|
||||
export { status } from "./status";
|
||||
export { reactive, markRaw, toRaw } from "./reactivity";
|
||||
export { useEffect, useEnv, useExternalListener, useRef, useChildSubEnv, useSubEnv } from "./hooks";
|
||||
export { EventBus, whenReady, loadFile, markup } from "./utils";
|
||||
export { batched, EventBus, whenReady, loadFile, markup } from "./utils";
|
||||
export {
|
||||
onWillStart,
|
||||
onMounted,
|
||||
|
||||
@@ -3,42 +3,50 @@ import { nodeErrorHandlers } from "./error_handling";
|
||||
import { OwlError } from "../common/owl_error";
|
||||
|
||||
const TIMEOUT = Symbol("timeout");
|
||||
const HOOK_TIMEOUT: { [key: string]: number } = {
|
||||
onWillStart: 3000,
|
||||
onWillUpdateProps: 3000,
|
||||
};
|
||||
function wrapError(fn: (...args: any[]) => any, hookName: string) {
|
||||
const error = new OwlError(`The following error occurred in ${hookName}: `) as Error & {
|
||||
const error = new OwlError() as Error & {
|
||||
cause: any;
|
||||
};
|
||||
const timeoutError = new OwlError(`${hookName}'s promise hasn't resolved after 3 seconds`);
|
||||
const timeoutError = new OwlError();
|
||||
const node = getCurrent();
|
||||
return (...args: any[]) => {
|
||||
const onError = (cause: any) => {
|
||||
error.cause = cause;
|
||||
if (cause instanceof Error) {
|
||||
error.message += `"${cause.message}"`;
|
||||
} else {
|
||||
error.message = `Something that is not an Error was thrown in ${hookName} (see this Error's "cause" property)`;
|
||||
}
|
||||
error.message =
|
||||
cause instanceof Error
|
||||
? `The following error occurred in ${hookName}: "${cause.message}"`
|
||||
: `Something that is not an Error was thrown in ${hookName} (see this Error's "cause" property)`;
|
||||
throw error;
|
||||
};
|
||||
let result;
|
||||
try {
|
||||
const result = fn(...args);
|
||||
if (result instanceof Promise) {
|
||||
if (hookName === "onWillStart" || hookName === "onWillUpdateProps") {
|
||||
const fiber = node.fiber;
|
||||
Promise.race([
|
||||
result.catch(() => {}),
|
||||
new Promise((resolve) => setTimeout(() => resolve(TIMEOUT), 3000)),
|
||||
]).then((res) => {
|
||||
if (res === TIMEOUT && node.fiber === fiber) {
|
||||
console.warn(timeoutError);
|
||||
}
|
||||
});
|
||||
}
|
||||
return result.catch(onError);
|
||||
}
|
||||
return result;
|
||||
result = fn(...args);
|
||||
} catch (cause) {
|
||||
onError(cause);
|
||||
}
|
||||
if (!(result instanceof Promise)) {
|
||||
return result;
|
||||
}
|
||||
const timeout = HOOK_TIMEOUT[hookName];
|
||||
if (timeout) {
|
||||
const fiber = node.fiber;
|
||||
Promise.race([
|
||||
result.catch(() => {}),
|
||||
new Promise((resolve) => setTimeout(() => resolve(TIMEOUT), timeout)),
|
||||
]).then((res) => {
|
||||
if (res === TIMEOUT && node.fiber === fiber && node.status <= 2) {
|
||||
timeoutError.message = `${hookName}'s promise hasn't resolved after ${
|
||||
timeout / 1000
|
||||
} seconds`;
|
||||
console.log(timeoutError);
|
||||
}
|
||||
});
|
||||
}
|
||||
return result.catch(onError);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export class Portal extends Component {
|
||||
type: String,
|
||||
},
|
||||
slots: true,
|
||||
};
|
||||
} as const;
|
||||
|
||||
setup() {
|
||||
const node: any = this.__owl__;
|
||||
|
||||
@@ -20,8 +20,9 @@ type CollectionRawType = "Set" | "Map" | "WeakMap";
|
||||
const objectToString = Object.prototype.toString;
|
||||
const objectHasOwnProperty = Object.prototype.hasOwnProperty;
|
||||
|
||||
const SUPPORTED_RAW_TYPES = new Set(["Object", "Array", "Set", "Map", "WeakMap"]);
|
||||
const COLLECTION_RAWTYPES = new Set(["Set", "Map", "WeakMap"]);
|
||||
// Use arrays because Array.includes is faster than Set.has for small arrays
|
||||
const SUPPORTED_RAW_TYPES = ["Object", "Array", "Set", "Map", "WeakMap"];
|
||||
const COLLECTION_RAW_TYPES = ["Set", "Map", "WeakMap"];
|
||||
|
||||
/**
|
||||
* extract "RawType" from strings like "[object RawType]" => this lets us ignore
|
||||
@@ -45,7 +46,7 @@ function canBeMadeReactive(value: any): boolean {
|
||||
if (typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
return SUPPORTED_RAW_TYPES.has(rawType(value));
|
||||
return SUPPORTED_RAW_TYPES.includes(rawType(value));
|
||||
}
|
||||
/**
|
||||
* Creates a reactive from the given object/callback if possible and returns it,
|
||||
@@ -220,7 +221,7 @@ export function reactive<T extends Target>(target: T, callback: Callback = NO_CA
|
||||
const reactivesForTarget = reactiveCache.get(target)!;
|
||||
if (!reactivesForTarget.has(callback)) {
|
||||
const targetRawType = rawType(target);
|
||||
const handler = COLLECTION_RAWTYPES.has(targetRawType)
|
||||
const handler = COLLECTION_RAW_TYPES.includes(targetRawType)
|
||||
? collectionsProxyHandler(target as Collection, callback, targetRawType as CollectionRawType)
|
||||
: basicProxyHandler<T>(callback);
|
||||
const proxy = new Proxy(target, handler as ProxyHandler<T>) as Reactive<T>;
|
||||
|
||||
@@ -16,6 +16,7 @@ export class Scheduler {
|
||||
frame: number = 0;
|
||||
delayedRenders: Fiber[] = [];
|
||||
cancelledNodes: Set<ComponentNode> = new Set();
|
||||
processing = false;
|
||||
|
||||
constructor() {
|
||||
this.requestAnimationFrame = Scheduler.requestAnimationFrame;
|
||||
@@ -53,6 +54,10 @@ export class Scheduler {
|
||||
}
|
||||
|
||||
processTasks() {
|
||||
if (this.processing) {
|
||||
return;
|
||||
}
|
||||
this.processing = true;
|
||||
this.frame = 0;
|
||||
for (let node of this.cancelledNodes) {
|
||||
node._destroy();
|
||||
@@ -66,6 +71,7 @@ export class Scheduler {
|
||||
this.tasks.delete(task);
|
||||
}
|
||||
}
|
||||
this.processing = false;
|
||||
}
|
||||
|
||||
processFiber(fiber: RootFiber) {
|
||||
|
||||
@@ -4,44 +4,16 @@ import { getCurrent } from "./component_node";
|
||||
import { Portal, portalTemplate } from "./portal";
|
||||
import { helpers } from "./template_helpers";
|
||||
import { OwlError } from "../common/owl_error";
|
||||
import { parseXML } from "../common/utils";
|
||||
|
||||
const bdom = { text, createBlock, list, multi, html, toggler, comment };
|
||||
|
||||
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.";
|
||||
const parsererrorText = doc.getElementsByTagName("parsererror")[0].textContent;
|
||||
if (parsererrorText) {
|
||||
msg += "\nThe parser has produced the following error message:\n" + parsererrorText;
|
||||
const re = /\d+/g;
|
||||
const firstMatch = re.exec(parsererrorText);
|
||||
if (firstMatch) {
|
||||
const lineNumber = Number(firstMatch[0]);
|
||||
const line = xml.split("\n")[lineNumber - 1];
|
||||
const secondMatch = re.exec(parsererrorText);
|
||||
if (line && secondMatch) {
|
||||
const columnIndex = Number(secondMatch[0]) - 1;
|
||||
if (line[columnIndex]) {
|
||||
msg +=
|
||||
`\nThe error might be located at xml line ${lineNumber} column ${columnIndex}\n` +
|
||||
`${line}\n${"-".repeat(columnIndex - 1)}^`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new OwlError(msg);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
export interface TemplateSetConfig {
|
||||
dev?: boolean;
|
||||
translatableAttributes?: string[];
|
||||
translateFn?: (s: string) => string;
|
||||
templates?: string | Document;
|
||||
templates?: string | Document | Record<string, string>;
|
||||
getTemplate?: (s: string) => Element | Function | string | void;
|
||||
}
|
||||
|
||||
export class TemplateSet {
|
||||
@@ -51,6 +23,7 @@ export class TemplateSet {
|
||||
dev: boolean;
|
||||
rawTemplates: typeof globalTemplates = Object.create(globalTemplates);
|
||||
templates: { [name: string]: Template } = {};
|
||||
getRawTemplate?: (s: string) => Element | Function | string | void;
|
||||
translateFn?: (s: string) => string;
|
||||
translatableAttributes?: string[];
|
||||
Portal = Portal;
|
||||
@@ -60,8 +33,15 @@ export class TemplateSet {
|
||||
this.translateFn = config.translateFn;
|
||||
this.translatableAttributes = config.translatableAttributes;
|
||||
if (config.templates) {
|
||||
this.addTemplates(config.templates);
|
||||
if (config.templates instanceof Document || typeof config.templates === "string") {
|
||||
this.addTemplates(config.templates);
|
||||
} else {
|
||||
for (const name in config.templates) {
|
||||
this.addTemplate(name, config.templates[name]);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.getRawTemplate = config.getTemplate;
|
||||
}
|
||||
|
||||
addTemplate(name: string, template: string | Element) {
|
||||
@@ -100,7 +80,7 @@ export class TemplateSet {
|
||||
|
||||
getTemplate(name: string): Template {
|
||||
if (!(name in this.templates)) {
|
||||
const rawTemplate = this.rawTemplates[name];
|
||||
const rawTemplate = this.getRawTemplate?.(name) || this.rawTemplates[name];
|
||||
if (rawTemplate === undefined) {
|
||||
let extraInfo = "";
|
||||
try {
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { OwlError } from "../common/owl_error";
|
||||
import { toRaw } from "./reactivity";
|
||||
|
||||
type BaseType =
|
||||
| typeof String
|
||||
| typeof Boolean
|
||||
| typeof Number
|
||||
| typeof Date
|
||||
| typeof Object
|
||||
| typeof Array
|
||||
| true
|
||||
| "*";
|
||||
type BaseType = { new (...args: any[]): any } | true | "*";
|
||||
|
||||
interface TypeInfo {
|
||||
type?: TypeDescription;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// do not modify manually. This file is generated by the release script.
|
||||
export const version = "2.2.6";
|
||||
export const version = "2.4.0";
|
||||
|
||||
@@ -43,6 +43,33 @@ exports[`app app: clear scheduler tasks and destroy cancelled nodes immediately
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`app can call processTask twice in a row without crashing 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
const comp1 = app.createComponent(\`Child\`, true, false, false, []);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
const b2 = text(\`parent\`);
|
||||
const b3 = comp1({}, key + \`__1\`, node, this, null);
|
||||
return multi([b2, b3]);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`app can call processTask twice in a row without crashing 2`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div/>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`app can configure an app with props 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
@@ -57,6 +84,19 @@ exports[`app can configure an app with props 1`] = `
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`app can load templates from an object name-string 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div class=\\"hello\\">hello</div>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`app can mount app in an iframe 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`destroy a subroot while another component is mounted in main app 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
const comp1 = app.createComponent(\`ChildB\`, true, false, false, []);
|
||||
const comp2 = app.createComponent(\`ChildA\`, true, false, false, []);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
let b2, b3;
|
||||
if (ctx['state'].flag) {
|
||||
b2 = comp1({}, key + \`__1\`, node, this, null);
|
||||
} else {
|
||||
b3 = comp2({}, key + \`__2\`, node, this, null);
|
||||
}
|
||||
return multi([b2, b3]);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`destroy a subroot while another component is mounted in main app 2`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block3 = createBlock(\`<div block-ref=\\"0\\"/>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
const b2 = text(\`a\`);
|
||||
let ref1 = (el) => this.__owl__.setRef((\`elem\`), el);
|
||||
const b3 = block3([ref1]);
|
||||
return multi([b2, b3]);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`destroy a subroot while another component is mounted in main app 3`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return text(\`c\`);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`destroy a subroot while another component is mounted in main app 4`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return text(\`b\`);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`subroot by default, env is the same in sub root 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div>main app</div>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`subroot by default, env is the same in sub root 2`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div>sub root</div>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`subroot can create a root in a setup function, then use a hook 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return text(\`a\`);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`subroot can mount subroot 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div>main app</div>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`subroot can mount subroot 2`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div>sub root</div>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`subroot can mount subroot inside own dom 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div>main app</div>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`subroot can mount subroot inside own dom 2`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div>sub root</div>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`subroot env can be specified for sub roots 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div>main app</div>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`subroot env can be specified for sub roots 2`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div>sub root</div>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`subroot subcomponents can be destroyed, and it properly cleanup the subroots 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div>main app</div>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`subroot subcomponents can be destroyed, and it properly cleanup the subroots 2`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div>sub root</div>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { App, Component, mount, onWillStart, useState, xml } from "../../src";
|
||||
import { App, Component, mount, onWillPatch, onWillStart, useState, xml } from "../../src";
|
||||
import { status } from "../../src/runtime/status";
|
||||
import {
|
||||
makeTestFixture,
|
||||
@@ -167,4 +167,38 @@ describe("app", () => {
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test("can load templates from an object name-string", async () => {
|
||||
const templates = {
|
||||
hello: `<div class="hello">hello</div>`,
|
||||
world: `<div>world</div>`,
|
||||
};
|
||||
class SomeComponent extends Component {
|
||||
static template = "hello";
|
||||
}
|
||||
|
||||
const app = new App(SomeComponent, { templates });
|
||||
await app.mount(fixture);
|
||||
expect(fixture.querySelector(".hello")).toBeDefined();
|
||||
// Only the "hello" template is used, so the "world" template is not yet loaded
|
||||
expect(Object.keys(app.templates)).toEqual(["hello"]);
|
||||
expect(Object.keys(app.rawTemplates)).toEqual(["hello", "world"]);
|
||||
});
|
||||
|
||||
test("can call processTask twice in a row without crashing", async () => {
|
||||
class Child extends Component {
|
||||
static template = xml`<div/>`;
|
||||
setup() {
|
||||
onWillPatch(() => app.scheduler.processTasks());
|
||||
}
|
||||
}
|
||||
class SomeComponent extends Component {
|
||||
static template = xml`parent<Child/>`;
|
||||
static components = { Child };
|
||||
}
|
||||
|
||||
const app = new App(SomeComponent);
|
||||
await app.mount(fixture);
|
||||
expect(fixture.innerHTML).toBe("parent<div></div>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { App, Component, onMounted, onWillDestroy, useRef, useState, xml } from "../../src";
|
||||
import { status } from "../../src/runtime/status";
|
||||
import { makeTestFixture, nextTick, snapshotEverything } from "../helpers";
|
||||
|
||||
let fixture: HTMLElement;
|
||||
|
||||
snapshotEverything();
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = makeTestFixture();
|
||||
});
|
||||
|
||||
class SomeComponent extends Component {
|
||||
static template = xml`<div>main app</div>`;
|
||||
}
|
||||
|
||||
class SubComponent extends Component {
|
||||
static template = xml`<div>sub root</div>`;
|
||||
}
|
||||
|
||||
describe("subroot", () => {
|
||||
test("can mount subroot", async () => {
|
||||
const app = new App(SomeComponent);
|
||||
const comp = await app.mount(fixture);
|
||||
expect(fixture.innerHTML).toBe("<div>main app</div>");
|
||||
const subRoot = app.createRoot(SubComponent);
|
||||
const subcomp = await subRoot.mount(fixture);
|
||||
expect(fixture.innerHTML).toBe("<div>main app</div><div>sub root</div>");
|
||||
|
||||
app.destroy();
|
||||
expect(fixture.innerHTML).toBe("");
|
||||
expect(status(comp)).toBe("destroyed");
|
||||
expect(status(subcomp)).toBe("destroyed");
|
||||
});
|
||||
|
||||
test("can mount subroot inside own dom", async () => {
|
||||
const app = new App(SomeComponent);
|
||||
const comp = await app.mount(fixture);
|
||||
expect(fixture.innerHTML).toBe("<div>main app</div>");
|
||||
const subRoot = app.createRoot(SubComponent);
|
||||
const subcomp = await subRoot.mount(fixture.querySelector("div")!);
|
||||
expect(fixture.innerHTML).toBe("<div>main app<div>sub root</div></div>");
|
||||
|
||||
app.destroy();
|
||||
expect(fixture.innerHTML).toBe("");
|
||||
expect(status(comp)).toBe("destroyed");
|
||||
expect(status(subcomp)).toBe("destroyed");
|
||||
});
|
||||
|
||||
test("by default, env is the same in sub root", async () => {
|
||||
let env, subenv;
|
||||
class SC extends SomeComponent {
|
||||
setup() {
|
||||
env = this.env;
|
||||
}
|
||||
}
|
||||
class Sub extends SubComponent {
|
||||
setup() {
|
||||
subenv = this.env;
|
||||
}
|
||||
}
|
||||
|
||||
const app = new App(SC);
|
||||
await app.mount(fixture);
|
||||
const subRoot = app.createRoot(Sub);
|
||||
await subRoot.mount(fixture);
|
||||
|
||||
expect(env).toBeDefined();
|
||||
expect(subenv).toBeDefined();
|
||||
expect(env).toBe(subenv);
|
||||
});
|
||||
|
||||
test("env can be specified for sub roots", async () => {
|
||||
const env1 = { env1: true };
|
||||
const env2 = {};
|
||||
let someComponentEnv: any, subComponentEnv: any;
|
||||
class SC extends SomeComponent {
|
||||
setup() {
|
||||
someComponentEnv = this.env;
|
||||
}
|
||||
}
|
||||
class Sub extends SubComponent {
|
||||
setup() {
|
||||
subComponentEnv = this.env;
|
||||
}
|
||||
}
|
||||
|
||||
const app = new App(SC, { env: env1 });
|
||||
await app.mount(fixture);
|
||||
const subRoot = app.createRoot(Sub, { env: env2 });
|
||||
await subRoot.mount(fixture);
|
||||
|
||||
// because env is different in app => it is given a sub object, frozen and all
|
||||
// not sure it is a good idea, but it's the way owl 2 works. maybe we should
|
||||
// avoid doing anything with the main env and let user code do it if they
|
||||
// want. in that case, we can change the test here to assert that they are equal
|
||||
expect(someComponentEnv).not.toBe(env1);
|
||||
expect(someComponentEnv!.env1).toBe(true);
|
||||
expect(subComponentEnv).toBe(env2);
|
||||
});
|
||||
|
||||
test("subcomponents can be destroyed, and it properly cleanup the subroots", async () => {
|
||||
const app = new App(SomeComponent);
|
||||
const comp = await app.mount(fixture);
|
||||
expect(fixture.innerHTML).toBe("<div>main app</div>");
|
||||
const root = app.createRoot(SubComponent);
|
||||
const subcomp = await root.mount(fixture.querySelector("div")!);
|
||||
expect(fixture.innerHTML).toBe("<div>main app<div>sub root</div></div>");
|
||||
|
||||
root.destroy();
|
||||
expect(fixture.innerHTML).toBe("<div>main app</div>");
|
||||
expect(status(comp)).not.toBe("destroyed");
|
||||
expect(status(subcomp)).toBe("destroyed");
|
||||
});
|
||||
|
||||
test("can create a root in a setup function, then use a hook", async () => {
|
||||
class C extends Component {
|
||||
static template = xml`c`;
|
||||
}
|
||||
|
||||
class A extends Component {
|
||||
static template = xml`a`;
|
||||
state: any;
|
||||
setup() {
|
||||
app.createRoot(C);
|
||||
this.state = useState({ value: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const app = new App(A);
|
||||
await app.mount(fixture);
|
||||
expect(fixture.innerHTML).toBe("a");
|
||||
});
|
||||
});
|
||||
|
||||
test("destroy a subroot while another component is mounted in main app", async () => {
|
||||
class C extends Component {
|
||||
static template = xml`c`;
|
||||
}
|
||||
|
||||
class ChildA extends Component {
|
||||
static template = xml`a<div t-ref="elem"></div>`;
|
||||
ref: any;
|
||||
setup() {
|
||||
this.ref = useRef("elem");
|
||||
let root = app.createRoot(C);
|
||||
onMounted(() => {
|
||||
root.mount(this.ref.el);
|
||||
});
|
||||
onWillDestroy(() => {
|
||||
root.destroy();
|
||||
});
|
||||
}
|
||||
}
|
||||
class ChildB extends Component {
|
||||
static template = xml`b`;
|
||||
}
|
||||
|
||||
class SomeComponent extends Component {
|
||||
static template = xml`
|
||||
<t t-if="state.flag"><ChildB/></t>
|
||||
<t t-else=""><ChildA/></t>
|
||||
`;
|
||||
static components = { ChildA, ChildB };
|
||||
state = useState({ flag: false });
|
||||
}
|
||||
|
||||
const app = new App(SomeComponent);
|
||||
const comp = await app.mount(fixture);
|
||||
expect(fixture.innerHTML).toBe("a<div></div>");
|
||||
await nextTick();
|
||||
expect(fixture.innerHTML).toBe("a<div>c</div>");
|
||||
comp.state.flag = true;
|
||||
await nextTick();
|
||||
expect(fixture.innerHTML).toBe("b");
|
||||
});
|
||||
@@ -1,5 +1,38 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`comments comment node with backslash at top level 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return comment(\` \\\\\\\\ \`);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`comments comment node with backtick at top-level 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return comment(\` \\\\\` \`);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`comments comment node with interpolation sigil at top level 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return comment(\` \\\\\${very cool} \`);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`comments only a comment 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
|
||||
@@ -341,6 +341,39 @@ exports[`simple templates, mostly static template with t tag with multiple conte
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`simple templates, mostly static text node with backslash at top level 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return text(\`\\\\\\\\\`);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`simple templates, mostly static text node with backtick at top-level 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return text(\`\\\\\`\`);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`simple templates, mostly static text node with interpolation sigil at top level 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return text(\`\\\\\${very cool}\`);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`simple templates, mostly static two t-escs next to each other 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`t-esc default with backslash at top level 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
let { withDefault } = helpers;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return text(withDefault(undefined, \`\\\\\\\\\`));
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`t-esc default with backtick at top-level 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
let { withDefault } = helpers;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return text(withDefault(undefined, \`\\\\\`\`));
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`t-esc default with interpolation sigil at top level 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
let { withDefault } = helpers;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return text(withDefault(undefined, \`\\\\\${very cool}\`));
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`t-esc div with falsy values 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`t-set body with backslash at top level 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
let { isBoundary, withDefault, setContextValue } = helpers;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
ctx = Object.create(ctx);
|
||||
ctx[isBoundary] = 1
|
||||
setContextValue(ctx, \\"value\\", \`\\\\\\\\\`);
|
||||
return text(ctx['value']);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`t-set body with backtick at top-level 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
let { isBoundary, withDefault, setContextValue } = helpers;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
ctx = Object.create(ctx);
|
||||
ctx[isBoundary] = 1
|
||||
setContextValue(ctx, \\"value\\", \`\\\\\`\`);
|
||||
return text(ctx['value']);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`t-set body with interpolation sigil at top level 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
let { isBoundary, withDefault, setContextValue } = helpers;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
ctx = Object.create(ctx);
|
||||
ctx[isBoundary] = 1
|
||||
setContextValue(ctx, \\"value\\", \`\\\\\${very cool}\`);
|
||||
return text(ctx['value']);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`t-set evaluate value expression 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
|
||||
@@ -101,3 +101,55 @@ exports[`loading templates can load a few templates from an XMLDocument 2`] = `
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`loading templates getTemplate: element returned (2) 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div>Hello World!</div>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`loading templates getTemplate: element returned 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div>Hello World!</div>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`loading templates getTemplate: template string returned 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div>Hello World!</div>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`loading templates getTemplate: undefined returned 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div>Hello World!</div>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
@@ -26,4 +26,19 @@ describe("comments", () => {
|
||||
</div>`;
|
||||
expect(renderToString(template)).toBe("<div><span>true</span></div>");
|
||||
});
|
||||
|
||||
test("comment node with backslash at top level", () => {
|
||||
const template = "<!-- \\ -->";
|
||||
expect(renderToString(template)).toBe("<!-- \\ -->");
|
||||
});
|
||||
|
||||
test("comment node with backtick at top-level", () => {
|
||||
const template = "<!-- ` -->";
|
||||
expect(renderToString(template)).toBe("<!-- ` -->");
|
||||
});
|
||||
|
||||
test("comment node with interpolation sigil at top level", () => {
|
||||
const template = "<!-- ${very cool} -->";
|
||||
expect(renderToString(template)).toBe("<!-- ${very cool} -->");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { compileExpr, tokenize } from "../../src/compiler/inline_expressions";
|
||||
import { compileExpr, tokenize } from "../../src/common/inline_expressions";
|
||||
|
||||
describe("tokenizer", () => {
|
||||
test("simple tokens", () => {
|
||||
@@ -174,6 +174,9 @@ describe("expression evaluation", () => {
|
||||
expect(compileExpr("list.data.map((data) => data)")).toBe(
|
||||
"ctx['list'].data.map((_data)=>_data)"
|
||||
);
|
||||
expect(compileExpr("(ev) => { myFunc(v1, v2, ev.target.value); }")).toBe(
|
||||
"(_ev)=>{ctx['myFunc'](ctx['v1'],ctx['v2'],_ev.target.value);}"
|
||||
);
|
||||
});
|
||||
test.skip("arrow functions: not yet supported", () => {
|
||||
// e is added to localvars in inline_expression but not removed after the arrow func body
|
||||
|
||||
@@ -154,4 +154,19 @@ describe("simple templates, mostly static", () => {
|
||||
</div>`;
|
||||
expect(renderToString(template, { a: "a", b: "b", c: "c" })).toBe("<div>abLoadingc</div>");
|
||||
});
|
||||
|
||||
test("text node with backslash at top level", () => {
|
||||
const template = "\\";
|
||||
expect(renderToString(template)).toBe("\\");
|
||||
});
|
||||
|
||||
test("text node with backtick at top-level", () => {
|
||||
const template = "`";
|
||||
expect(renderToString(template)).toBe("`");
|
||||
});
|
||||
|
||||
test("text node with interpolation sigil at top level", () => {
|
||||
const template = "${very cool}";
|
||||
expect(renderToString(template)).toBe("${very cool}");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,4 +121,19 @@ describe("t-esc", () => {
|
||||
mount(bdom, fixture);
|
||||
expect(fixture.querySelector("span")!.textContent).toBe("<p>escaped</p>");
|
||||
});
|
||||
|
||||
test("default with backslash at top level", () => {
|
||||
const template = '<t t-esc="undefined">\\</t>';
|
||||
expect(renderToString(template)).toBe("\\");
|
||||
});
|
||||
|
||||
test("default with backtick at top-level", () => {
|
||||
const template = '<t t-esc="undefined">`</t>';
|
||||
expect(renderToString(template)).toBe("`");
|
||||
});
|
||||
|
||||
test("default with interpolation sigil at top level", () => {
|
||||
const template = '<t t-esc="undefined">${very cool}</t>';
|
||||
expect(renderToString(template)).toBe("${very cool}");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +54,21 @@ describe("t-set", () => {
|
||||
expect(renderToString(template)).toBe("ok");
|
||||
});
|
||||
|
||||
test("body with backslash at top level", () => {
|
||||
const template = '<t t-set="value">\\</t><t t-esc="value"/>';
|
||||
expect(renderToString(template)).toBe("\\");
|
||||
});
|
||||
|
||||
test("body with backtick at top-level", () => {
|
||||
const template = '<t t-set="value">`</t><t t-esc="value"/>';
|
||||
expect(renderToString(template)).toBe("`");
|
||||
});
|
||||
|
||||
test("body with interpolation sigil at top level", () => {
|
||||
const template = '<t t-set="value">${very cool}</t><t t-esc="value"/>';
|
||||
expect(renderToString(template)).toBe("${very cool}");
|
||||
});
|
||||
|
||||
test("set from body literal (with t-if/t-else", () => {
|
||||
const template = `
|
||||
<t>
|
||||
|
||||
@@ -78,4 +78,62 @@ describe("loading templates", () => {
|
||||
context.addTemplates(xml);
|
||||
expect(Object.keys(context.rawTemplates)).toEqual([]);
|
||||
});
|
||||
|
||||
test("getTemplate: element returned", () => {
|
||||
const context = new TestContext({
|
||||
getTemplate: (name) => {
|
||||
if (name === "main") {
|
||||
const data = `<div>Hello World!</div>`;
|
||||
const xml = new DOMParser().parseFromString(data, "text/xml");
|
||||
return xml.firstChild as Element;
|
||||
}
|
||||
return;
|
||||
},
|
||||
});
|
||||
const result = context.renderToString("main");
|
||||
expect(result).toBe("<div>Hello World!</div>");
|
||||
});
|
||||
|
||||
test("getTemplate: element returned (2)", () => {
|
||||
const context = new TestContext({
|
||||
getTemplate: (name) => {
|
||||
if (name === "main") {
|
||||
const doc = new Document();
|
||||
const div = doc.createElement("div");
|
||||
div.append(doc.createTextNode("Hello World!"));
|
||||
return div;
|
||||
}
|
||||
return;
|
||||
},
|
||||
});
|
||||
const result = context.renderToString("main");
|
||||
expect(result).toBe("<div>Hello World!</div>");
|
||||
});
|
||||
|
||||
test("getTemplate: template string returned", () => {
|
||||
const context = new TestContext({
|
||||
getTemplate: (name) => {
|
||||
if (name === "main") {
|
||||
return `<div>Hello World!</div>`;
|
||||
}
|
||||
return;
|
||||
},
|
||||
});
|
||||
const result = context.renderToString("main");
|
||||
expect(result).toBe("<div>Hello World!</div>");
|
||||
});
|
||||
|
||||
test("getTemplate: undefined returned", () => {
|
||||
const context = new TestContext({
|
||||
getTemplate: () => {},
|
||||
});
|
||||
const data = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<div t-name="main">Hello World!</div>
|
||||
</templates>`;
|
||||
const xml = new DOMParser().parseFromString(data, "text/xml");
|
||||
context.addTemplates(xml);
|
||||
const result = context.renderToString("main");
|
||||
expect(result).toBe("<div>Hello World!</div>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("basic validation", () => {
|
||||
test("compilation error", () => {
|
||||
const template = `<div t-att-class="a b">test</div>`;
|
||||
expect(() => renderToString(template))
|
||||
.toThrow(`Failed to compile anonymous template: Unexpected identifier
|
||||
.toThrow(`Failed to compile anonymous template: Unexpected identifier 'ctx'
|
||||
|
||||
generated code:
|
||||
function(app, bdom, helpers) {
|
||||
|
||||
@@ -97,6 +97,19 @@ exports[`basics a component cannot be mounted in a detached node (even if node i
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`basics a component cannot be mounted in a detached node 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div/>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`basics a component inside a component 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
@@ -261,6 +274,19 @@ exports[`basics can mount a simple component with props 1`] = `
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`basics cannot mount on a documentFragment 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div>content</div>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`basics child can be updated 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
@@ -1002,6 +1028,19 @@ exports[`basics three level of components with collapsing root nodes 3`] = `
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`basics throws if mounting on target=null 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<span>simple vnode</span>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`basics two child components 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
|
||||
@@ -332,6 +332,31 @@ exports[`lifecycle hooks lifecycle semantics, part 3 1`] = `
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`lifecycle hooks lifecycle semantics, part 3 3`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
const comp1 = app.createComponent(\`GrandChild\`, true, false, false, []);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return comp1({}, key + \`__1\`, node, this, null);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`lifecycle hooks lifecycle semantics, part 3 4`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div/>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`lifecycle hooks lifecycle semantics, part 4 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
@@ -683,7 +708,7 @@ exports[`lifecycle hooks sub widget (inside sub node): hooks are correctly calle
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`lifecycle hooks timeout in onWillStart emits a warning 1`] = `
|
||||
exports[`lifecycle hooks timeout in onWillStart doesn't emit a console log if app is destroyed 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
@@ -696,7 +721,20 @@ exports[`lifecycle hooks timeout in onWillStart emits a warning 1`] = `
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`lifecycle hooks timeout in onWillUpdateProps emits a warning 1`] = `
|
||||
exports[`lifecycle hooks timeout in onWillStart emits a console log 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<span/>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return block1();
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`lifecycle hooks timeout in onWillUpdateProps emits a console log 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
@@ -710,7 +748,7 @@ exports[`lifecycle hooks timeout in onWillUpdateProps emits a warning 1`] = `
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`lifecycle hooks timeout in onWillUpdateProps emits a warning 2`] = `
|
||||
exports[`lifecycle hooks timeout in onWillUpdateProps emits a console log 2`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
@@ -66,6 +66,29 @@ exports[`.alike suffix in a simple case 2`] = `
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`.translate props are translated 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
const comp1 = app.createComponent(\`Child\`, true, false, false, []);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return comp1({message: \`translated message\`}, key + \`__1\`, node, this, null);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`.translate props are translated 2`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return text(ctx['props'].message);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`basics accept ES6-like syntax for props (with getters) 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
@@ -412,6 +435,29 @@ exports[`can bind function prop with bind suffix 2`] = `
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`can use .translate suffix 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
const comp1 = app.createComponent(\`Child\`, true, false, false, []);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return comp1({message: \`some message\`}, key + \`__1\`, node, this, null);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`can use .translate suffix 2`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return text(ctx['props'].message);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`do not crash when binding anonymous function prop with bind suffix 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
|
||||
@@ -167,6 +167,45 @@ exports[`props validation can specify that additional props are allowed (object)
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`props validation can use custom class as type 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
const comp1 = app.createComponent(\`Child\`, true, false, false, [\\"customObj\\"]);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
const props1 = {customObj: ctx['customObj']};
|
||||
helpers.validateProps(\`Child\`, props1, this);
|
||||
return comp1(props1, key + \`__1\`, node, this, null);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`props validation can use custom class as type 2`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return text(ctx['props'].customObj.val);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`props validation can use custom class as type: validation failure 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
const comp1 = app.createComponent(\`Child\`, true, false, false, [\\"customObj\\"]);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
const props1 = {customObj: ctx['customObj']};
|
||||
helpers.validateProps(\`Child\`, props1, this);
|
||||
return comp1(props1, key + \`__1\`, node, this, null);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`props validation can validate a prop with multiple types 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
@@ -885,6 +924,20 @@ exports[`props validation props: list of strings 1`] = `
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`props validation validate props for root component 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
let block1 = createBlock(\`<div><block-text-0/></div>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
let txt1 = ctx['message'];
|
||||
return block1([txt1]);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`props validation validate simple types 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`slots .translate slot props are translated 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
let { capture, markRaw } = helpers;
|
||||
const comp1 = app.createComponent(\`Child\`, true, true, false, []);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
const ctx1 = capture(ctx);
|
||||
return comp1({slots: markRaw({'default': {message: \`translated message\`}})}, key + \`__1\`, node, this, null);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`slots .translate slot props are translated 2`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return text(ctx['props'].slots.default.message);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`slots can define a default content 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
@@ -201,6 +226,31 @@ exports[`slots can render only empty slot 1`] = `
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`slots can use .translate suffix on slot props 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
let { capture, markRaw } = helpers;
|
||||
const comp1 = app.createComponent(\`Child\`, true, true, false, []);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
const ctx1 = capture(ctx);
|
||||
return comp1({slots: markRaw({'default': {message: \`some message\`}})}, key + \`__1\`, node, this, null);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`slots can use .translate suffix on slot props 2`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return text(ctx['props'].slots.default.message);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`slots can use component in default-content of t-slot 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
@@ -1066,6 +1116,45 @@ exports[`slots multiple slots containing components 3`] = `
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`slots named slot inside named slot in t-component 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
let { capture, markRaw } = helpers;
|
||||
const comp1 = app.createComponent(null, false, true, false, []);
|
||||
const comp2 = app.createComponent(\`Child\`, true, true, false, []);
|
||||
|
||||
function slot1(ctx, node, key = \\"\\") {
|
||||
const b2 = text(\` outer \`);
|
||||
const ctx2 = capture(ctx);
|
||||
const Comp1 = ctx['Child'];
|
||||
const b4 = toggler(Comp1, comp1({slots: markRaw({'brol': {__render: slot2.bind(this), __ctx: ctx2}})}, (Comp1).name + key + \`__1\`, node, this, Comp1));
|
||||
return multi([b2, b4]);
|
||||
}
|
||||
|
||||
function slot2(ctx, node, key = \\"\\") {
|
||||
return text(ctx['value']);
|
||||
}
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
const ctx1 = capture(ctx);
|
||||
return comp2({slots: markRaw({'brol': {__render: slot1.bind(this), __ctx: ctx1}})}, key + \`__2\`, node, this, null);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`slots named slot inside named slot in t-component 2`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
let { callSlot } = helpers;
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
return callSlot(ctx, node, key, 'brol', false, {});
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`slots named slot inside slot 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
|
||||
@@ -44,7 +44,26 @@ exports[`t-model directive .trim modifier 1`] = `
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
let { toNumber } = helpers;
|
||||
|
||||
let block1 = createBlock(\`<div><input block-property-0=\\"value\\" block-handler-1=\\"input\\"/><span><block-text-2/></span></div>\`);
|
||||
let block1 = createBlock(\`<div><input block-property-0=\\"value\\" block-handler-1=\\"change\\"/><span><block-text-2/></span></div>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
const bExpr1 = ctx['state'];
|
||||
const expr1 = 'text';
|
||||
let prop1 = bExpr1[expr1];
|
||||
let hdlr1 = [(ev) => { bExpr1[expr1] = ev.target.value.trim(); }];
|
||||
let txt1 = ctx['state'].text;
|
||||
return block1([prop1, hdlr1, txt1]);
|
||||
}
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`t-model directive .trim modifier implies .lazy modifier 1`] = `
|
||||
"function anonymous(app, bdom, helpers
|
||||
) {
|
||||
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
|
||||
let { toNumber } = helpers;
|
||||
|
||||
let block1 = createBlock(\`<div><input block-property-0=\\"value\\" block-handler-1=\\"change\\"/><span><block-text-2/></span></div>\`);
|
||||
|
||||
return function template(ctx, node, key = \\"\\") {
|
||||
const bExpr1 = ctx['state'];
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { Component, markup, mount, useState, xml } from "../../src";
|
||||
import { useAttachedEl } from "../../src/runtime/hooks";
|
||||
import { makeTestFixture, nextTick, steps, useLogLifecycle } from "../helpers";
|
||||
|
||||
let fixture: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = makeTestFixture();
|
||||
});
|
||||
|
||||
describe("basics", () => {
|
||||
test("can attach an empty component", async () => {
|
||||
fixture.innerHTML = "<div>hello</div>";
|
||||
|
||||
class Test extends Component {
|
||||
setup() {
|
||||
useLogLifecycle();
|
||||
}
|
||||
}
|
||||
|
||||
await mount(Test, fixture, { position: "attach" });
|
||||
expect(steps.splice(0)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"Test:setup",
|
||||
"Test:willStart",
|
||||
"Test:willRender",
|
||||
"Test:rendered",
|
||||
"Test:mounted",
|
||||
]
|
||||
`);
|
||||
expect(fixture.innerHTML).toBe("<div>hello</div>");
|
||||
});
|
||||
|
||||
test("attaching a component with a template throws", async () => {
|
||||
fixture.innerHTML = "<div>hello</div>";
|
||||
|
||||
class Test extends Component {
|
||||
static template = xml`hello`;
|
||||
}
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await mount(Test, fixture, { position: "attach" });
|
||||
} catch (e: any) {
|
||||
error = e;
|
||||
}
|
||||
expect(error!.message).toBe("Cannot attach a component with a template");
|
||||
|
||||
expect(fixture.innerHTML).toBe("<div>hello</div>");
|
||||
});
|
||||
|
||||
test("can attach a component with simple dynamic content", async () => {
|
||||
fixture.innerHTML = "<div><p>hello</p></div>";
|
||||
|
||||
class Test extends Component {
|
||||
static dynamicContent = {
|
||||
"p:t-att-a": "value",
|
||||
};
|
||||
value: string = "";
|
||||
setup() {
|
||||
this.value = "b";
|
||||
}
|
||||
}
|
||||
|
||||
await mount(Test, fixture, { position: "attach" });
|
||||
expect(fixture.innerHTML).toBe('<div><p a="b">hello</p></div>');
|
||||
});
|
||||
|
||||
test("useAttachedEl returns the attached element", async () => {
|
||||
fixture.innerHTML = "<div><p>hello</p></div>";
|
||||
let el: any = null;
|
||||
|
||||
class Test extends Component {
|
||||
setup() {
|
||||
el = useAttachedEl();
|
||||
}
|
||||
}
|
||||
|
||||
await mount(Test, fixture, { position: "attach" });
|
||||
expect(el).toBe(fixture);
|
||||
});
|
||||
|
||||
test("useAttachedEl throws if component is not attached", async () => {
|
||||
class Test extends Component {
|
||||
static template = xml`hello`;
|
||||
setup() {
|
||||
useAttachedEl();
|
||||
}
|
||||
}
|
||||
|
||||
let error: any = null;
|
||||
try {
|
||||
await mount(Test, fixture);
|
||||
} catch (_e: any) {
|
||||
error = _e;
|
||||
}
|
||||
|
||||
expect(error.message).toBe("useAttachedEl can only be called with component that are attached");
|
||||
});
|
||||
|
||||
test("multiple dynamic attribute", async () => {
|
||||
fixture.innerHTML = "<div><p>hello</p></div>";
|
||||
|
||||
class Test extends Component {
|
||||
static dynamicContent = {
|
||||
"p:t-att-a": "value",
|
||||
"p:t-att-b": "value + 'coucou'",
|
||||
};
|
||||
value: string = "";
|
||||
setup() {
|
||||
this.value = "b";
|
||||
}
|
||||
}
|
||||
|
||||
await mount(Test, fixture, { position: "attach" });
|
||||
expect(fixture.innerHTML).toBe('<div><p a="b" b="bcoucou">hello</p></div>');
|
||||
});
|
||||
|
||||
test("attrs can target root", async () => {
|
||||
fixture.innerHTML = "<p>hello</p>";
|
||||
|
||||
class Test extends Component {
|
||||
static dynamicContent = {
|
||||
"root:t-att-a": "value",
|
||||
};
|
||||
value: string = "";
|
||||
setup() {
|
||||
this.value = "b";
|
||||
}
|
||||
}
|
||||
|
||||
await mount(Test, fixture, { position: "attach" });
|
||||
expect(fixture.outerHTML).toBe('<div a="b"><p>hello</p></div>');
|
||||
});
|
||||
|
||||
test("dynamic attribute is updated on rerender", async () => {
|
||||
fixture.innerHTML = "<div><p>hello</p></div>";
|
||||
|
||||
class Test extends Component {
|
||||
static dynamicContent = {
|
||||
"p:t-att-a": "state.value",
|
||||
};
|
||||
state: any;
|
||||
setup() {
|
||||
this.state = useState({ value: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const test = await mount(Test, fixture, { position: "attach" });
|
||||
expect(fixture.innerHTML).toBe('<div><p a="1">hello</p></div>');
|
||||
test.state.value = 2;
|
||||
await nextTick();
|
||||
expect(fixture.innerHTML).toBe('<div><p a="2">hello</p></div>');
|
||||
});
|
||||
|
||||
test("t-on-click, basic", async () => {
|
||||
fixture.innerHTML = "<div><p>hello</p></div>";
|
||||
|
||||
let ev: Event | null = null;
|
||||
|
||||
class Test extends Component {
|
||||
static dynamicContent = {
|
||||
"p:t-att-a": "state.value",
|
||||
"p:t-on-click": "onClick",
|
||||
};
|
||||
state: any;
|
||||
setup() {
|
||||
this.state = useState({ value: 1 });
|
||||
}
|
||||
onClick(_ev: any) {
|
||||
ev = _ev;
|
||||
this.state.value = 2;
|
||||
}
|
||||
}
|
||||
|
||||
await mount(Test, fixture, { position: "attach" });
|
||||
expect(fixture.innerHTML).toBe('<div><p a="1">hello</p></div>');
|
||||
fixture.querySelector("p")!.click();
|
||||
expect(ev).toBeInstanceOf(Event);
|
||||
await nextTick();
|
||||
expect(fixture.innerHTML).toBe('<div><p a="2">hello</p></div>');
|
||||
});
|
||||
|
||||
test("t-on-click, target root element", async () => {
|
||||
fixture.innerHTML = "hello";
|
||||
let click = false;
|
||||
class Test extends Component {
|
||||
static dynamicContent = {
|
||||
"root:t-on-click": "onClick",
|
||||
};
|
||||
onClick() {
|
||||
click = true;
|
||||
}
|
||||
}
|
||||
|
||||
await mount(Test, fixture, { position: "attach" });
|
||||
fixture.click();
|
||||
expect(click).toBe(true);
|
||||
});
|
||||
|
||||
test("t-out, basic", async () => {
|
||||
fixture.innerHTML = "<div><p>hello</p></div>";
|
||||
|
||||
class Test extends Component {
|
||||
static dynamicContent = {
|
||||
"p:t-out": "state.value",
|
||||
};
|
||||
state: any;
|
||||
setup() {
|
||||
this.state = useState({ value: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
await mount(Test, fixture, { position: "attach" });
|
||||
expect(fixture.innerHTML).toBe("<div><p>1</p></div>");
|
||||
});
|
||||
|
||||
test("t-out, on root", async () => {
|
||||
fixture.innerHTML = "hello";
|
||||
|
||||
class Test extends Component {
|
||||
static dynamicContent = {
|
||||
"root:t-out": "state.value",
|
||||
};
|
||||
state: any;
|
||||
setup() {
|
||||
this.state = useState({ value: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
expect(fixture.outerHTML).toBe("<div>hello</div>");
|
||||
await mount(Test, fixture, { position: "attach" });
|
||||
expect(fixture.outerHTML).toBe("<div>1</div>");
|
||||
});
|
||||
|
||||
test("t-out, with markup", async () => {
|
||||
fixture.innerHTML = `<p class="p1">hello</p><p class="p2">hello</p>`;
|
||||
|
||||
class Test extends Component {
|
||||
static dynamicContent = {
|
||||
"p.p1:t-out": "value1",
|
||||
"p.p2:t-out": "value2",
|
||||
};
|
||||
|
||||
value1: any;
|
||||
value2: any;
|
||||
setup() {
|
||||
this.value1 = "<div>value1</div>";
|
||||
this.value2 = markup("<div>value2</div>");
|
||||
}
|
||||
}
|
||||
|
||||
await mount(Test, fixture, { position: "attach" });
|
||||
expect(fixture.innerHTML).toBe(
|
||||
`<p class="p1"><div>value1</div></p><p class="p2"><div>value2</div></p>`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -157,7 +157,7 @@ describe("basics", () => {
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
const expectedErrorMessage = `Failed to compile anonymous template: Unexpected identifier
|
||||
const expectedErrorMessage = `Failed to compile anonymous template: Unexpected identifier 'ctx'
|
||||
|
||||
generated code:
|
||||
function(app, bdom, helpers) {
|
||||
@@ -182,7 +182,7 @@ function(app, bdom, helpers) {
|
||||
static components = { Child };
|
||||
static template = xml`<Child/>`;
|
||||
}
|
||||
const expectedErrorMessage = `Failed to compile anonymous template: Unexpected identifier
|
||||
const expectedErrorMessage = `Failed to compile anonymous template: Unexpected identifier 'ctx'
|
||||
|
||||
generated code:
|
||||
function(app, bdom, helpers) {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { App, Component, mount, onMounted, onWillStart, useState, xml } from "../../src";
|
||||
import {
|
||||
App,
|
||||
Component,
|
||||
mount,
|
||||
useState,
|
||||
xml,
|
||||
onWillPatch,
|
||||
onWillUnmount,
|
||||
onPatched,
|
||||
@@ -7,7 +11,9 @@ import {
|
||||
onWillRender,
|
||||
onWillDestroy,
|
||||
onRendered,
|
||||
} from "../../src/runtime/lifecycle_hooks";
|
||||
onMounted,
|
||||
onWillStart,
|
||||
} from "../../src";
|
||||
import { status } from "../../src/runtime/status";
|
||||
import {
|
||||
elem,
|
||||
@@ -106,10 +112,10 @@ describe("lifecycle hooks", () => {
|
||||
await mount(Test, fixture);
|
||||
});
|
||||
|
||||
test("timeout in onWillStart emits a warning", async () => {
|
||||
const { warn } = console;
|
||||
let warnArgs: any[];
|
||||
console.warn = jest.fn((...args) => (warnArgs = args));
|
||||
test("timeout in onWillStart emits a console log", async () => {
|
||||
const { log } = console;
|
||||
let logArgs: any[];
|
||||
console.log = jest.fn((...args) => (logArgs = args));
|
||||
const { setTimeout } = window;
|
||||
let timeoutCbs: any = {};
|
||||
let timeoutId = 0;
|
||||
@@ -117,27 +123,63 @@ describe("lifecycle hooks", () => {
|
||||
timeoutCbs[++timeoutId] = cb;
|
||||
return timeoutId;
|
||||
}) as any;
|
||||
class Test extends Component {
|
||||
static template = xml`<span/>`;
|
||||
setup() {
|
||||
onWillStart(() => new Promise(() => {}));
|
||||
try {
|
||||
class Test extends Component {
|
||||
static template = xml`<span/>`;
|
||||
setup() {
|
||||
onWillStart(() => new Promise(() => {}));
|
||||
}
|
||||
}
|
||||
mount(Test, fixture, { test: true });
|
||||
nextTick();
|
||||
for (const id in timeoutCbs) {
|
||||
timeoutCbs[id]();
|
||||
delete timeoutCbs[id];
|
||||
}
|
||||
await nextMicroTick();
|
||||
await nextMicroTick();
|
||||
expect(console.log).toHaveBeenCalledTimes(1);
|
||||
expect(logArgs![0]!.message).toBe("onWillStart's promise hasn't resolved after 3 seconds");
|
||||
} finally {
|
||||
console.log = log;
|
||||
window.setTimeout = setTimeout;
|
||||
}
|
||||
mount(Test, fixture, { test: true });
|
||||
nextTick();
|
||||
for (const id in timeoutCbs) {
|
||||
timeoutCbs[id]();
|
||||
delete timeoutCbs[id];
|
||||
}
|
||||
await nextMicroTick();
|
||||
await nextMicroTick();
|
||||
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||
expect(warnArgs![0]!.message).toBe("onWillStart's promise hasn't resolved after 3 seconds");
|
||||
console.warn = warn;
|
||||
window.setTimeout = setTimeout;
|
||||
});
|
||||
|
||||
test("timeout in onWillUpdateProps emits a warning", async () => {
|
||||
test("timeout in onWillStart doesn't emit a console log if app is destroyed", async () => {
|
||||
const { log } = console;
|
||||
console.log = jest.fn();
|
||||
const { setTimeout } = window;
|
||||
let timeoutCbs: any = {};
|
||||
let timeoutId = 0;
|
||||
window.setTimeout = ((cb: any) => {
|
||||
timeoutCbs[++timeoutId] = cb;
|
||||
return timeoutId;
|
||||
}) as any;
|
||||
try {
|
||||
class Test extends Component {
|
||||
static template = xml`<span/>`;
|
||||
setup() {
|
||||
onWillStart(() => new Promise(() => {}));
|
||||
}
|
||||
}
|
||||
const app = new App(Test, { test: true });
|
||||
app.mount(fixture);
|
||||
app.destroy();
|
||||
for (const id in timeoutCbs) {
|
||||
timeoutCbs[id]();
|
||||
delete timeoutCbs[id];
|
||||
}
|
||||
await nextMicroTick();
|
||||
await nextMicroTick();
|
||||
expect(console.log).toHaveBeenCalledTimes(0);
|
||||
} finally {
|
||||
console.log = log;
|
||||
window.setTimeout = setTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
test("timeout in onWillUpdateProps emits a console log", async () => {
|
||||
class Child extends Component {
|
||||
static template = xml``;
|
||||
setup() {
|
||||
@@ -151,9 +193,9 @@ describe("lifecycle hooks", () => {
|
||||
}
|
||||
const parent = await mount(Parent, fixture, { test: true });
|
||||
|
||||
const { warn } = console;
|
||||
let warnArgs: any[];
|
||||
console.warn = jest.fn((...args) => (warnArgs = args));
|
||||
const { log } = console;
|
||||
let logArgs: any[];
|
||||
console.log = jest.fn((...args) => (logArgs = args));
|
||||
const { setTimeout } = window;
|
||||
let timeoutCbs: any = {};
|
||||
let timeoutId = 0;
|
||||
@@ -162,25 +204,28 @@ describe("lifecycle hooks", () => {
|
||||
return timeoutId;
|
||||
}) as any;
|
||||
|
||||
parent.state.prop = 2;
|
||||
let tick = nextTick();
|
||||
for (const id in timeoutCbs) {
|
||||
timeoutCbs[id]();
|
||||
delete timeoutCbs[id];
|
||||
try {
|
||||
parent.state.prop = 2;
|
||||
let tick = nextTick();
|
||||
for (const id in timeoutCbs) {
|
||||
timeoutCbs[id]();
|
||||
delete timeoutCbs[id];
|
||||
}
|
||||
await tick;
|
||||
tick = nextTick();
|
||||
for (const id in timeoutCbs) {
|
||||
timeoutCbs[id]();
|
||||
delete timeoutCbs[id];
|
||||
}
|
||||
await tick;
|
||||
expect(console.log).toHaveBeenCalledTimes(1);
|
||||
expect(logArgs![0]!.message).toBe(
|
||||
"onWillUpdateProps's promise hasn't resolved after 3 seconds"
|
||||
);
|
||||
} finally {
|
||||
console.log = log;
|
||||
window.setTimeout = setTimeout;
|
||||
}
|
||||
await tick;
|
||||
tick = nextTick();
|
||||
for (const id in timeoutCbs) {
|
||||
timeoutCbs[id]();
|
||||
delete timeoutCbs[id];
|
||||
}
|
||||
await tick;
|
||||
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||
expect(warnArgs![0]!.message).toBe(
|
||||
"onWillUpdateProps's promise hasn't resolved after 3 seconds"
|
||||
);
|
||||
console.warn = warn;
|
||||
window.setTimeout = setTimeout;
|
||||
});
|
||||
|
||||
test("mounted hook is called if mounted in DOM", async () => {
|
||||
|
||||
@@ -299,6 +299,34 @@ test("bound functions are considered 'alike'", async () => {
|
||||
expect(fixture.innerHTML).toBe("3child");
|
||||
});
|
||||
|
||||
test("can use .translate suffix", async () => {
|
||||
class Child extends Component {
|
||||
static template = xml`<t t-esc="props.message"/>`;
|
||||
}
|
||||
|
||||
class Parent extends Component {
|
||||
static template = xml`<Child message.translate="some message"/>`;
|
||||
static components = { Child };
|
||||
}
|
||||
|
||||
await mount(Parent, fixture);
|
||||
expect(fixture.innerHTML).toBe("some message");
|
||||
});
|
||||
|
||||
test(".translate props are translated", async () => {
|
||||
class Child extends Component {
|
||||
static template = xml`<t t-esc="props.message"/>`;
|
||||
}
|
||||
|
||||
class Parent extends Component {
|
||||
static template = xml`<Child message.translate="some message"/>`;
|
||||
static components = { Child };
|
||||
}
|
||||
|
||||
await mount(Parent, fixture, { translateFn: () => "translated message" });
|
||||
expect(fixture.innerHTML).toBe("translated message");
|
||||
});
|
||||
|
||||
test("throw if prop uses an unknown suffix", async () => {
|
||||
class Child extends Component {
|
||||
static template = xml`<t t-esc="props.val"/>`;
|
||||
|
||||
@@ -594,7 +594,7 @@ describe("props validation", () => {
|
||||
|
||||
test("props: can be defined with a boolean", async () => {
|
||||
class SubComp extends Component {
|
||||
static props = { message: true };
|
||||
static props = { message: true } as const;
|
||||
}
|
||||
expect(() => {
|
||||
validateProps(SubComp as any, {});
|
||||
@@ -636,7 +636,7 @@ describe("props validation", () => {
|
||||
|
||||
test("props: extra props cause an error, part 2", async () => {
|
||||
class SubComp extends Component {
|
||||
static props = { message: true };
|
||||
static props = { message: true } as const;
|
||||
}
|
||||
expect(() => {
|
||||
validateProps(SubComp as any, { message: 1, flag: true });
|
||||
@@ -702,7 +702,7 @@ describe("props validation", () => {
|
||||
const app = new App(Parent, { test: true });
|
||||
await app.mount(fixture);
|
||||
expect(fixture.innerHTML).toBe("12");
|
||||
expect(app.root!.subscriptions).toEqual([{ keys: ["otherValue"], target: obj }]);
|
||||
expect(app.root!.node!.subscriptions).toEqual([{ keys: ["otherValue"], target: obj }]);
|
||||
});
|
||||
|
||||
test("props are validated whenever component is updated", async () => {
|
||||
@@ -829,6 +829,52 @@ describe("props validation", () => {
|
||||
expect(error!).toBeDefined();
|
||||
expect(error!.message).toBe("Invalid props for component 'Child': 'message' is missing");
|
||||
});
|
||||
|
||||
test("can use custom class as type", async () => {
|
||||
class CustomClass {
|
||||
val = "hey";
|
||||
}
|
||||
class Child extends Component {
|
||||
static props = { customObj: CustomClass };
|
||||
static template = xml`<t t-esc="props.customObj.val"/>`;
|
||||
}
|
||||
|
||||
class Parent extends Component {
|
||||
static components = { Child };
|
||||
static template = xml`<Child customObj="customObj" />`;
|
||||
customObj = new CustomClass();
|
||||
}
|
||||
|
||||
const app = new App(Parent, { test: true });
|
||||
await app.mount(fixture);
|
||||
expect(fixture.innerHTML).toBe("hey");
|
||||
});
|
||||
|
||||
test("can use custom class as type: validation failure", async () => {
|
||||
class CustomClass {}
|
||||
class Child extends Component {
|
||||
static props = { customObj: CustomClass };
|
||||
static template = xml`<div>hey</div>`;
|
||||
}
|
||||
|
||||
class Parent extends Component {
|
||||
static components = { Child };
|
||||
static template = xml`<Child customObj="customObj" />`;
|
||||
customObj = {};
|
||||
}
|
||||
|
||||
const app = new App(Parent, { test: true });
|
||||
let error: OwlError | undefined;
|
||||
const mountProm = app.mount(fixture).catch((e: Error) => (error = e));
|
||||
await expect(nextAppError(app)).resolves.toThrow(
|
||||
"Invalid props for component 'Child': 'customObj' is not a customclass"
|
||||
);
|
||||
await mountProm;
|
||||
expect(error!).toBeDefined();
|
||||
expect(error!.message).toBe(
|
||||
"Invalid props for component 'Child': 'customObj' is not a customclass"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
@@ -179,6 +179,34 @@ describe("slots", () => {
|
||||
expect(fixture.innerHTML).toBe("<span>default empty</span>");
|
||||
});
|
||||
|
||||
test("can use .translate suffix on slot props", async () => {
|
||||
class Child extends Component {
|
||||
static template = xml`<t t-esc="props.slots.default.message"/>`;
|
||||
}
|
||||
|
||||
class Parent extends Component {
|
||||
static template = xml`<Child><t t-set-slot="default" message.translate="some message"/></Child>`;
|
||||
static components = { Child };
|
||||
}
|
||||
|
||||
await mount(Parent, fixture);
|
||||
expect(fixture.innerHTML).toBe("some message");
|
||||
});
|
||||
|
||||
test(".translate slot props are translated", async () => {
|
||||
class Child extends Component {
|
||||
static template = xml`<t t-esc="props.slots.default.message"/>`;
|
||||
}
|
||||
|
||||
class Parent extends Component {
|
||||
static template = xml`<Child><t t-set-slot="default" message.translate="some message"/></Child>`;
|
||||
static components = { Child };
|
||||
}
|
||||
|
||||
await mount(Parent, fixture, { translateFn: () => "translated message" });
|
||||
expect(fixture.innerHTML).toBe("translated message");
|
||||
});
|
||||
|
||||
test("default slot with slot scope: shorthand syntax", async () => {
|
||||
let child: any;
|
||||
class Child extends Component {
|
||||
@@ -1673,6 +1701,31 @@ describe("slots", () => {
|
||||
expect(fixture.innerHTML).toBe("<div><div><p>Ablip</p><div><p>Bblip</p></div></div></div>");
|
||||
});
|
||||
|
||||
test("named slot inside named slot in t-component", async () => {
|
||||
class Child extends Component {
|
||||
static template = xml`<t t-slot="brol"/>`;
|
||||
}
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<Child>
|
||||
<t t-set-slot="brol">
|
||||
outer
|
||||
<t t-component="Child">
|
||||
<t t-set-slot="brol">
|
||||
<t t-esc="value"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</Child>`;
|
||||
static components = { Child };
|
||||
Child = Child;
|
||||
value = "inner";
|
||||
}
|
||||
await mount(Parent, fixture);
|
||||
|
||||
expect(fixture.innerHTML).toBe(" outer inner");
|
||||
});
|
||||
|
||||
test("can render only empty slot", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`<t t-slot="default"/>`;
|
||||
|
||||
@@ -331,6 +331,32 @@ describe("t-model directive", () => {
|
||||
expect(fixture.innerHTML).toBe("<div><input><span>test</span></div>");
|
||||
});
|
||||
|
||||
test(".trim modifier implies .lazy modifier", async () => {
|
||||
class SomeComponent extends Component {
|
||||
static template = xml`
|
||||
<div>
|
||||
<input t-model.trim="state.text"/>
|
||||
<span><t t-esc="state.text"/></span>
|
||||
</div>
|
||||
`;
|
||||
state = useState({ text: "" });
|
||||
}
|
||||
const comp = await mount(SomeComponent, fixture);
|
||||
|
||||
expect(fixture.innerHTML).toBe("<div><input><span></span></div>");
|
||||
|
||||
const input = fixture.querySelector("input")!;
|
||||
input.value = "test ";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
await nextTick();
|
||||
expect(comp.state.text).toBe("");
|
||||
expect(fixture.innerHTML).toBe("<div><input><span></span></div>");
|
||||
input.dispatchEvent(new Event("change"));
|
||||
await nextTick();
|
||||
expect(comp.state.text).toBe("test");
|
||||
expect(fixture.innerHTML).toBe("<div><input><span>test</span></div>");
|
||||
});
|
||||
|
||||
test(".number modifier", async () => {
|
||||
class SomeComponent extends Component {
|
||||
static template = xml`
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"default_popup": "popup_app/popup.html"
|
||||
},
|
||||
"permissions": ["scripting", "storage"],
|
||||
"host_permissions": ["http://*/*", "https://*/*"],
|
||||
"host_permissions": ["http://*/*", "https://*/*", "file://*"],
|
||||
"content_security_policy": {
|
||||
"script-src": "self",
|
||||
"object-src": "self"
|
||||
|
||||
@@ -109,23 +109,34 @@
|
||||
object(obj) {
|
||||
const result = [];
|
||||
let length = 0;
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (length > 25) {
|
||||
result.push("...");
|
||||
break;
|
||||
if (obj instanceof String) {
|
||||
result[0] = `'${obj.toString()}'`;
|
||||
} else if (obj instanceof Array) {
|
||||
return `${obj.constructor.name} ${this.array([...obj])}`;
|
||||
} else if (obj instanceof Number) {
|
||||
result[0] = obj.toString();
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (length > 25) {
|
||||
result.push("...");
|
||||
break;
|
||||
}
|
||||
const element = key + ": " + this.serializeItem(value);
|
||||
length += element.length;
|
||||
result.push(element);
|
||||
}
|
||||
for (const key of Object.getOwnPropertySymbols(obj)) {
|
||||
if (length > 25) {
|
||||
result.push("...");
|
||||
break;
|
||||
}
|
||||
const element = key.toString() + ": " + this.serializeItem(obj[key]);
|
||||
length += element.length;
|
||||
result.push(element);
|
||||
}
|
||||
const element = key + ": " + this.serializeItem(value);
|
||||
length += element.length;
|
||||
result.push(element);
|
||||
}
|
||||
for (const key of Object.getOwnPropertySymbols(obj)) {
|
||||
if (length > 25) {
|
||||
result.push("...");
|
||||
break;
|
||||
}
|
||||
const element = key.toString() + ": " + this.serializeItem(obj[key]);
|
||||
length += element.length;
|
||||
result.push(element);
|
||||
if (obj.constructor.name !== "Object") {
|
||||
return obj.constructor.name + " {" + result.join(", ") + "}";
|
||||
}
|
||||
return "{" + result.join(", ") + "}";
|
||||
},
|
||||
@@ -823,7 +834,7 @@
|
||||
child.contentType = "set";
|
||||
child.hasChildren = true;
|
||||
break;
|
||||
case obj instanceof Array:
|
||||
case obj.constructor.name === "Array":
|
||||
child.contentType = "array";
|
||||
child.hasChildren = obj.length > 0;
|
||||
break;
|
||||
@@ -834,7 +845,9 @@
|
||||
case obj instanceof Object:
|
||||
child.contentType = "object";
|
||||
child.hasChildren =
|
||||
Object.keys(obj).length || Object.getOwnPropertySymbols(obj).length;
|
||||
Object.keys(obj).length ||
|
||||
Object.getOwnPropertySymbols(obj).length ||
|
||||
obj.constructor.name !== "Object";
|
||||
break;
|
||||
default:
|
||||
child.contentType = typeof obj;
|
||||
|
||||
@@ -4,6 +4,28 @@ All notable changes to the "owl-vision" extension will be documented in this fil
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
|
||||
## [0.1.0] - 2024-11-06
|
||||
|
||||
### Added
|
||||
|
||||
- Basic autocomplete in xml files. This includes autocompletion for elements, components,
|
||||
props, attributes, and javascript expressions.
|
||||
|
||||
The current implementation, while relatively simple, has a couple of drawbacks:
|
||||
- Javascript imports are not resolved by the xml autocomplete, this means that it does not
|
||||
understand the types of imported functions or objects. That said, I've added custom support
|
||||
for frequently used Owl imports, namely `useState` and `useRef`. You can add more in
|
||||
the settings if needed.
|
||||
- The autocomplete is limited to templates directly linked to components, sub-templates
|
||||
used via t-call will not get autocompletion as no component/context can be bound to them.
|
||||
|
||||
- "Go To Definition" support for props and javascript expressions in xml
|
||||
- Support for the following directives: t-att, t-model, t-tag, t-debug, t-log
|
||||
|
||||
### Fixed
|
||||
|
||||
- Changed t-else syntax highlight from dynamic to static attribute
|
||||
|
||||
## [0.0.2] - 2023-2-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "Owl framework extension that highlights templates and ease navigation between components and templates.",
|
||||
"publisher": "Odoo",
|
||||
"license": "LGPL-3.0-only",
|
||||
"version": "0.0.2",
|
||||
"version": "0.1.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/odoo/owl/tree/master/tools/owl-vision"
|
||||
@@ -63,6 +63,13 @@
|
||||
"type": "string",
|
||||
"default": "**/node_modules/**,**/lib/**,**/tests/**",
|
||||
"description": "Files to exclude in search"
|
||||
},
|
||||
"owl-vision.autocomplete-mocks": {
|
||||
"order": 2,
|
||||
"type": "string",
|
||||
"editPresentation": "multilineText",
|
||||
"default": "/**\n* @template T\n* @param {T} obj\n* @returns {T}\n*/\nfunction useState(obj) {}\n\n/**\n* @typedef {Object} Ref\n* @property {HTMLElement} el\n*/\n/**\n* @returns {Ref}\n*/\nfunction useRef(name) {}",
|
||||
"description": "Mocks for functions or object that are imported but not resolved by the autcomplete. Add docstring comments for them to work properly."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -128,6 +135,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "20.2.5",
|
||||
"@types/vscode": "^1.73.0",
|
||||
"@types/xmldoc": "^1.1.9",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.8",
|
||||
"@typescript-eslint/parser": "^5.59.8",
|
||||
"@vscode/test-electron": "^2.3.2",
|
||||
@@ -135,5 +143,8 @@
|
||||
"esbuild": "^0.19.5",
|
||||
"eslint": "^8.41.0",
|
||||
"typescript": "^5.1.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"xmldoc": "^1.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ export const propsAttributes = createAttributePatterns("props-attributes", {
|
||||
export const owlAttributesDynamic = createAttributePatterns("owl-attributes-dynamic", {
|
||||
match: [
|
||||
"t-if",
|
||||
"t-else",
|
||||
"t-elif",
|
||||
"t-foreach",
|
||||
"t-as",
|
||||
@@ -75,6 +74,10 @@ export const owlAttributesDynamic = createAttributePatterns("owl-attributes-dyna
|
||||
"t-value",
|
||||
"t-portal",
|
||||
"t-slot-scope",
|
||||
"t-att",
|
||||
"t-tag",
|
||||
"t-log",
|
||||
"t-model",
|
||||
"t-att-[a-z_:.-]+",
|
||||
"t-on-[a-z_:.-]+"
|
||||
].join("|"),
|
||||
@@ -86,12 +89,13 @@ export const owlAttributesDynamic = createAttributePatterns("owl-attributes-dyna
|
||||
export const owlAttributesStatic = createAttributePatterns("owl-attributes-static", {
|
||||
match: [
|
||||
"t-name",
|
||||
"t-else",
|
||||
"t-ref",
|
||||
"t-set-slot",
|
||||
"t-model",
|
||||
"t-inherit",
|
||||
"t-inherit-mode",
|
||||
"t-translation"
|
||||
"t-translation",
|
||||
"t-debug",
|
||||
].join("|"),
|
||||
attributeName: "owl.attribute owl.attribute.static",
|
||||
});
|
||||
|
||||
@@ -1,18 +1,43 @@
|
||||
{
|
||||
"Basic owl component": {
|
||||
"Basic OWL Component": {
|
||||
"prefix": "owlcomponent",
|
||||
"scope": "javascript,typescript",
|
||||
"body": [
|
||||
"export class ${1:component-name} extends Component {",
|
||||
" static template = \"${2:template-name}\";",
|
||||
"import { Component } from \"@odoo/owl\";",
|
||||
"",
|
||||
"class ${1:${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g}} extends ${2:Component} {",
|
||||
"",
|
||||
" static template = \"${3:${RELATIVE_FILEPATH/(.*[\\|\\/])??([a-zA-Z_]+)([\\|\\/]static[\\|\\/].*)/${2}/g}}.${4:${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g}}\";",
|
||||
" static components = {};",
|
||||
" static props = {};",
|
||||
"",
|
||||
" setup() {",
|
||||
"",
|
||||
" ${5:super.setup();}",
|
||||
" }",
|
||||
"",
|
||||
" ${6:// Do Something}",
|
||||
"}",
|
||||
""
|
||||
|
||||
],
|
||||
"description": "The starting base for an owl component"
|
||||
},
|
||||
|
||||
"Basic OWL Template": {
|
||||
"prefix": "owltemplate",
|
||||
"scope": "xml",
|
||||
"body": [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" ?>",
|
||||
"",
|
||||
"<templates xml:space=\"preserve\">",
|
||||
"",
|
||||
" <t t-name=\"${2:${RELATIVE_FILEPATH/(.*[\\|\\/])??([a-zA-Z_]+)([\\|\\/]static[\\|\\/].*)/${2}/g}}.${3:${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g}}\">",
|
||||
" ${3:<h1>Hello World</h1>}",
|
||||
" </t>",
|
||||
"",
|
||||
"</templates>",
|
||||
""
|
||||
],
|
||||
"description": "Generate a basic OWL template XML file"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { getSelectedText, showStatusMessage, hideStatusMessage } from './utils';
|
||||
import { Search } from './search';
|
||||
|
||||
export class ComponentDefinitionProvider implements vscode.DefinitionProvider {
|
||||
|
||||
search: Search;
|
||||
|
||||
constructor(search: Search) {
|
||||
this.search = search;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface implementation to provide definition when ctrl+click on Component
|
||||
* tag in template.
|
||||
*/
|
||||
async provideDefinition(document: vscode.TextDocument, position: vscode.Position) {
|
||||
const currentWord = getSelectedText(/<\/?[A-Z][a-zA-Z]+/, document, position);
|
||||
if (!currentWord) {
|
||||
return;
|
||||
}
|
||||
const componentName = currentWord.replace(/[\/<]/g, "").trim();
|
||||
|
||||
showStatusMessage(`Searching for component "${componentName}"`);
|
||||
const result = await this.search.findComponent(componentName);
|
||||
hideStatusMessage();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { Search } from './search';
|
||||
import { ComponentDefinitionProvider } from './definiton_providers';
|
||||
import { OpenDirection } from './utils';
|
||||
import { OwlLanguageFeaturesProvider } from './language_features/language_features_provider';
|
||||
|
||||
export async function activate(context: vscode.ExtensionContext) {
|
||||
const search = new Search();
|
||||
|
||||
context.subscriptions.push(vscode.commands.registerCommand('owl-vision.switch', () => search.switch()));
|
||||
context.subscriptions.push(vscode.commands.registerCommand('owl-vision.switch-besides', () => search.switch(OpenDirection.Besides)));
|
||||
context.subscriptions.push(vscode.commands.registerCommand('owl-vision.switch-below', () => search.switch(OpenDirection.Below)));
|
||||
context.subscriptions.push(vscode.commands.registerCommand('owl-vision.find-component', () => search.findComponentCommand()));
|
||||
context.subscriptions.push(vscode.commands.registerCommand('owl-vision.find-template', () => search.findTemplateCommand()));
|
||||
|
||||
const componentDefProvider = new ComponentDefinitionProvider(search);
|
||||
context.subscriptions.push(vscode.languages.registerDefinitionProvider({ language: 'xml' }, componentDefProvider));
|
||||
context.subscriptions.push(vscode.languages.registerDefinitionProvider({ language: 'javascript' }, componentDefProvider));
|
||||
const languageFeaturesProvider = new OwlLanguageFeaturesProvider(search);
|
||||
context.subscriptions.push(vscode.languages.registerCompletionItemProvider({ language: 'xml', scheme: 'file' }, languageFeaturesProvider, '.', '<'));
|
||||
context.subscriptions.push(vscode.languages.registerDefinitionProvider({ language: 'xml', scheme: 'file' }, languageFeaturesProvider));
|
||||
}
|
||||
|
||||
export function deactivate() { }
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
import { CompletionItemKind } from "vscode"
|
||||
|
||||
export const owlComponentAttributes = [
|
||||
"t-if",
|
||||
"t-else",
|
||||
"t-elif",
|
||||
"t-foreach",
|
||||
"t-as",
|
||||
"t-key",
|
||||
"t-esc",
|
||||
"t-out",
|
||||
"t-props",
|
||||
"t-set",
|
||||
"t-value",
|
||||
"t-portal",
|
||||
"t-slot-scope",
|
||||
"t-log",
|
||||
].map(label => ({
|
||||
label: label,
|
||||
insertText: label + '=""',
|
||||
kind: CompletionItemKind.Property,
|
||||
}));
|
||||
|
||||
export const owlElementAttributes = [
|
||||
"t-component",
|
||||
"t-att",
|
||||
"t-tag",
|
||||
"t-model",
|
||||
].map(label => ({
|
||||
label: label,
|
||||
insertText: label + '=""',
|
||||
kind: CompletionItemKind.Property,
|
||||
}));
|
||||
|
||||
owlElementAttributes.push(...owlComponentAttributes);
|
||||
|
||||
/**
|
||||
To generate this list, run the following snippet on https://developer.mozilla.org/fr/docs/Web/Events
|
||||
|
||||
(function () {
|
||||
const events = [...document.querySelectorAll(".section-content li a")]
|
||||
.map(e => e.childNodes[0])
|
||||
.filter(n => n.nodeType === 3 && n.textContent.toLowerCase() == n.textContent)
|
||||
.map(n => "t-on-" + n.textContent.trim());
|
||||
return JSON.stringify([...new Set(events)], null, 2);
|
||||
}())
|
||||
*/
|
||||
export const events = [
|
||||
"t-on-abort",
|
||||
"t-on-ended",
|
||||
"t-on-addtrack",
|
||||
"t-on-change",
|
||||
"t-on-removetrack",
|
||||
"t-on-messageerror",
|
||||
"t-on-message",
|
||||
"t-on-animationcancel",
|
||||
"t-on-animationend",
|
||||
"t-on-animationiteration",
|
||||
"t-on-animationstart",
|
||||
"t-on-copy",
|
||||
"t-on-cut",
|
||||
"t-on-dragend",
|
||||
"t-on-dragenter",
|
||||
"t-on-dragleave",
|
||||
"t-on-dragover",
|
||||
"t-on-dragstart",
|
||||
"t-on-drag",
|
||||
"t-on-drop",
|
||||
"t-on-fullscreenchange",
|
||||
"t-on-fullscreenerror",
|
||||
"t-on-gotpointercapture",
|
||||
"t-on-keydown",
|
||||
"t-on-keypress",
|
||||
"t-on-keyup",
|
||||
"t-on-lostpointercapture",
|
||||
"t-on-paste",
|
||||
"t-on-pointercancel",
|
||||
"t-on-pointerdown",
|
||||
"t-on-pointerenter",
|
||||
"t-on-pointerleave",
|
||||
"t-on-pointerlockchange",
|
||||
"t-on-pointerlockerror",
|
||||
"t-on-pointermove",
|
||||
"t-on-pointerout",
|
||||
"t-on-pointerover",
|
||||
"t-on-pointerup",
|
||||
"t-on-readystatechange",
|
||||
"t-on-scroll",
|
||||
"t-on-selectionchange",
|
||||
"t-on-selectstart",
|
||||
"t-on-touchcancel",
|
||||
"t-on-touchend",
|
||||
"t-on-touchmove",
|
||||
"t-on-touchstart",
|
||||
"t-on-transitioncancel",
|
||||
"t-on-transitionend",
|
||||
"t-on-transitionrun",
|
||||
"t-on-transitionstart",
|
||||
"t-on-visibilitychange",
|
||||
"t-on-wheel",
|
||||
"t-on-afterscriptexecute",
|
||||
"t-on-auxclick",
|
||||
"t-on-beforescriptexecute",
|
||||
"t-on-blur",
|
||||
"t-on-click",
|
||||
"t-on-compositionend",
|
||||
"t-on-compositionstart",
|
||||
"t-on-compositionupdate",
|
||||
"t-on-contextmenu",
|
||||
"t-on-dblclick",
|
||||
"t-on-error",
|
||||
"t-on-focusin",
|
||||
"t-on-focusout",
|
||||
"t-on-focus",
|
||||
"t-on-gesturechange",
|
||||
"t-on-gestureend",
|
||||
"t-on-gesturestart",
|
||||
"t-on-mousedown",
|
||||
"t-on-mouseenter",
|
||||
"t-on-mouseleave",
|
||||
"t-on-mousemove",
|
||||
"t-on-mouseout",
|
||||
"t-on-mouseover",
|
||||
"t-on-mouseup",
|
||||
"t-on-mousewheel",
|
||||
"t-on-overflow",
|
||||
"t-on-select",
|
||||
"t-on-show",
|
||||
"t-on-underflow",
|
||||
"t-on-webkitmouseforcechanged",
|
||||
"t-on-webkitmouseforcedown",
|
||||
"t-on-webkitmouseforceup",
|
||||
"t-on-webkitmouseforcewillbegin",
|
||||
"t-on-open",
|
||||
"t-on-loadend",
|
||||
"t-on-loadstart",
|
||||
"t-on-load",
|
||||
"t-on-progress",
|
||||
"t-on-webglcontextcreationerror",
|
||||
"t-on-webglcontextlost",
|
||||
"t-on-webglcontextrestored",
|
||||
"t-on-toggle",
|
||||
"t-on-cancel",
|
||||
"t-on-close",
|
||||
"t-on-beforeinput",
|
||||
"t-on-input",
|
||||
"t-on-formdata",
|
||||
"t-on-reset",
|
||||
"t-on-submit",
|
||||
"t-on-invalid",
|
||||
"t-on-search",
|
||||
"t-on-canplaythrough",
|
||||
"t-on-canplay",
|
||||
"t-on-durationchange",
|
||||
"t-on-emptied",
|
||||
"t-on-loadeddata",
|
||||
"t-on-loadedmetadata",
|
||||
"t-on-pause",
|
||||
"t-on-playing",
|
||||
"t-on-play",
|
||||
"t-on-ratechange",
|
||||
"t-on-seeked",
|
||||
"t-on-seeking",
|
||||
"t-on-stalled",
|
||||
"t-on-suspend",
|
||||
"t-on-timeupdate",
|
||||
"t-on-volumechange",
|
||||
"t-on-waiting",
|
||||
"t-on-slotchange",
|
||||
"t-on-cuechange",
|
||||
"t-on-enterpictureinpicture",
|
||||
"t-on-leavepictureinpicture",
|
||||
"t-on-versionchange",
|
||||
"t-on-blocked",
|
||||
"t-on-upgradeneeded",
|
||||
"t-on-success",
|
||||
"t-on-complete",
|
||||
"t-on-devicechange",
|
||||
"t-on-mute",
|
||||
"t-on-unmute",
|
||||
"t-on-merchantvalidation",
|
||||
"t-on-paymentmethodchange",
|
||||
"t-on-shippingaddresschange",
|
||||
"t-on-shippingoptionchange",
|
||||
"t-on-payerdetailchange",
|
||||
"t-on-resourcetimingbufferfull",
|
||||
"t-on-resize",
|
||||
"t-on-bufferedamountlow",
|
||||
"t-on-closing",
|
||||
"t-on-tonechange",
|
||||
"t-on-gatheringstatechange",
|
||||
"t-on-selectedcandidatepairchange",
|
||||
"t-on-statechange",
|
||||
"t-on-addstream",
|
||||
"t-on-connectionstatechange",
|
||||
"t-on-datachannel",
|
||||
"t-on-icecandidateerror",
|
||||
"t-on-icecandidate",
|
||||
"t-on-iceconnectionstatechange",
|
||||
"t-on-icegatheringstatechange",
|
||||
"t-on-negotiationneeded",
|
||||
"t-on-removestream",
|
||||
"t-on-signalingstatechange",
|
||||
"t-on-track",
|
||||
"t-on-audioprocess",
|
||||
"t-on-activate",
|
||||
"t-on-contentdelete",
|
||||
"t-on-install",
|
||||
"t-on-notificationclick",
|
||||
"t-on-pushsubscriptionchange",
|
||||
"t-on-push",
|
||||
"t-on-connect",
|
||||
"t-on-audioend",
|
||||
"t-on-audiostart",
|
||||
"t-on-end",
|
||||
"t-on-nomatch",
|
||||
"t-on-result",
|
||||
"t-on-soundend",
|
||||
"t-on-soundstart",
|
||||
"t-on-speechend",
|
||||
"t-on-speechstart",
|
||||
"t-on-start",
|
||||
"t-on-voiceschanged",
|
||||
"t-on-boundary",
|
||||
"t-on-mark",
|
||||
"t-on-resume",
|
||||
"t-on-unload",
|
||||
"t-on-afterprint",
|
||||
"t-on-appinstalled",
|
||||
"t-on-beforeprint",
|
||||
"t-on-beforeunload",
|
||||
"t-on-devicemotion",
|
||||
"t-on-deviceorientation",
|
||||
"t-on-gamepadconnected",
|
||||
"t-on-gamepaddisconnected",
|
||||
"t-on-hashchange",
|
||||
"t-on-languagechange",
|
||||
"t-on-offline",
|
||||
"t-on-online",
|
||||
"t-on-orientationchange",
|
||||
"t-on-pagehide",
|
||||
"t-on-pageshow",
|
||||
"t-on-popstate",
|
||||
"t-on-rejectionhandled",
|
||||
"t-on-storage",
|
||||
"t-on-unhandledrejection",
|
||||
"t-on-vrdisplayactivate",
|
||||
"t-on-vrdisplayblur",
|
||||
"t-on-vrdisplayconnect",
|
||||
"t-on-vrdisplaydeactivate",
|
||||
"t-on-vrdisplaydisconnect",
|
||||
"t-on-vrdisplayfocus",
|
||||
"t-on-vrdisplaypointerrestricted",
|
||||
"t-on-vrdisplaypointerunrestricted",
|
||||
"t-on-vrdisplaypresentchange",
|
||||
"t-on-timeout",
|
||||
"t-on-inputsourceschange",
|
||||
"t-on-selectend",
|
||||
"t-on-squeezeend",
|
||||
"t-on-squeezestart",
|
||||
"t-on-squeeze"
|
||||
].map(label => ({
|
||||
label: label,
|
||||
insertText: label + '=""',
|
||||
kind: CompletionItemKind.Property,
|
||||
}))
|
||||
|
||||
|
||||
/**
|
||||
To generate the elements list, run the following snippet on https://developer.mozilla.org/en-US/docs/Web/HTML/Element
|
||||
|
||||
(function () {
|
||||
const names = [...document.querySelectorAll("section:not([aria-labelledby='obsolete_and_deprecated_elements']) td:nth-child(1)")]
|
||||
.flatMap(n => n.innerText.split(","))
|
||||
.map(n => n.trim().replace("<", "").replace(">", ""))
|
||||
return JSON.stringify([...new Set(names)], null, 2);
|
||||
})()
|
||||
*/
|
||||
export const elements = [
|
||||
"t",
|
||||
"link",
|
||||
"meta",
|
||||
"style",
|
||||
"title",
|
||||
"body",
|
||||
"address",
|
||||
"article",
|
||||
"aside",
|
||||
"footer",
|
||||
"header",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"hgroup",
|
||||
"main",
|
||||
"nav",
|
||||
"section",
|
||||
"search",
|
||||
"blockquote",
|
||||
"dd",
|
||||
"div",
|
||||
"dl",
|
||||
"dt",
|
||||
"figcaption",
|
||||
"figure",
|
||||
"hr",
|
||||
"li",
|
||||
"menu",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"ul",
|
||||
"a",
|
||||
"abbr",
|
||||
"b",
|
||||
"bdi",
|
||||
"bdo",
|
||||
"br",
|
||||
"cite",
|
||||
"code",
|
||||
"data",
|
||||
"dfn",
|
||||
"em",
|
||||
"i",
|
||||
"kbd",
|
||||
"mark",
|
||||
"q",
|
||||
"rp",
|
||||
"rt",
|
||||
"ruby",
|
||||
"s",
|
||||
"samp",
|
||||
"small",
|
||||
"span",
|
||||
"strong",
|
||||
"sub",
|
||||
"sup",
|
||||
"time",
|
||||
"u",
|
||||
"var",
|
||||
"wbr",
|
||||
"area",
|
||||
"audio",
|
||||
"img",
|
||||
"map",
|
||||
"track",
|
||||
"video",
|
||||
"embed",
|
||||
"iframe",
|
||||
"object",
|
||||
"picture",
|
||||
"portal",
|
||||
"source",
|
||||
"svg",
|
||||
"math",
|
||||
"canvas",
|
||||
"noscript",
|
||||
"script",
|
||||
"del",
|
||||
"ins",
|
||||
"caption",
|
||||
"col",
|
||||
"colgroup",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"tfoot",
|
||||
"th",
|
||||
"thead",
|
||||
"tr",
|
||||
"button",
|
||||
"datalist",
|
||||
"fieldset",
|
||||
"form",
|
||||
"input",
|
||||
"label",
|
||||
"legend",
|
||||
"meter",
|
||||
"optgroup",
|
||||
"option",
|
||||
"output",
|
||||
"progress",
|
||||
"select",
|
||||
"textarea",
|
||||
"details",
|
||||
"dialog",
|
||||
"summary",
|
||||
].map(label => ({
|
||||
label: label,
|
||||
insertText: label,
|
||||
kind: CompletionItemKind.Property,
|
||||
}));
|
||||
@@ -0,0 +1,422 @@
|
||||
import { CancellationToken, CompletionContext, CompletionItem, CompletionItemKind, CompletionItemProvider, CompletionList, DefinitionProvider, Location, Position, Range, TextDocument, TextDocumentContentProvider, Uri, commands, workspace } from "vscode";
|
||||
import { Search } from "../search";
|
||||
import { getSelectedText, hash, readFile } from "../utils";
|
||||
import { elements, events, owlComponentAttributes, owlElementAttributes } from "./items";
|
||||
import { ParseResultType, ParseResult, parse, getNodePath, parseXml } from "./parser";
|
||||
|
||||
/**
|
||||
* Commands return basic js object which needs to be converted
|
||||
* to actual CompletionItem instances, this methods streamlines
|
||||
* this process.
|
||||
*/
|
||||
function mapCompletionItems(items: any): CompletionItem[] {
|
||||
return items.map((i: any) => {
|
||||
const item = new CompletionItem(i.label, i.kind);
|
||||
item.sortText = i.sortText;
|
||||
item.detail = i.detail;
|
||||
item.filterText = i.filterText;
|
||||
item.insertText = i.insertText?.startsWith?.(".") ? i.insertText.substring(1) : i.insertText;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function filterComponentItems(items: CompletionItem[], excludedLabels: string[] = []): CompletionItem[] {
|
||||
return items.filter((item) => {
|
||||
return !excludedLabels.includes(item.label.toString()) && [
|
||||
CompletionItemKind.Field,
|
||||
CompletionItemKind.Method,
|
||||
CompletionItemKind.Variable,
|
||||
CompletionItemKind.Property,
|
||||
].includes(item.kind as number);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds "this." in front of the expression if needed and increments
|
||||
* the expression offset accordingly.
|
||||
*/
|
||||
function contextualize(properties: string[], expression: string, expressionOffset = 0) {
|
||||
const match = expression.match(/^([a-zA-Z_]+)\b/);
|
||||
if (!expression.startsWith("this.") && ((match && properties.includes(match[1])) || expression.match(/^\s*$/))) {
|
||||
expression = "this." + expression;
|
||||
expressionOffset += 5;
|
||||
}
|
||||
return { expression, expressionOffset };
|
||||
};
|
||||
|
||||
const Commands = {
|
||||
Completion: "vscode.executeCompletionItemProvider",
|
||||
Definition: "vscode.executeDefinitionProvider",
|
||||
}
|
||||
|
||||
export class OwlLanguageFeaturesProvider implements CompletionItemProvider, TextDocumentContentProvider, DefinitionProvider {
|
||||
|
||||
virtualDocuments = new Map();
|
||||
componentProperties = new Map();
|
||||
search: Search;
|
||||
|
||||
constructor(search: Search) {
|
||||
this.search = search;
|
||||
workspace.registerTextDocumentContentProvider("owl", this);
|
||||
}
|
||||
|
||||
/**
|
||||
* TextDocumentContentProvider interface implementation to provide
|
||||
* virtual documents to vscode commands.
|
||||
*/
|
||||
async provideTextDocumentContent(uri: Uri) {
|
||||
const id = uri.toString(true);
|
||||
return this.virtualDocuments.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* DefinitionProvider interface implementation.
|
||||
*
|
||||
* - If the target is a js expression, will try to find the definition
|
||||
* inside the current component.
|
||||
* - If the target is a component element, will try to find the definition
|
||||
* of the component.
|
||||
*/
|
||||
async provideDefinition(document: TextDocument, position: Position) {
|
||||
let offset = document.offsetAt(position);
|
||||
const documentText = document.getText();
|
||||
const parseResult = await parse(documentText, offset);
|
||||
|
||||
if (parseResult.type === ParseResultType.Expression) {
|
||||
const component = await this.search.getCurrentComponent();
|
||||
if (!component) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { xmlDocument, xmlNode } = parseXml(documentText, offset);
|
||||
const componentText = await readFile(component.uri);
|
||||
const virtualDocument = await this.getVirtualJsDocument(document.uri, component.componentName, componentText, xmlDocument, xmlNode, parseResult);
|
||||
|
||||
const definitions: any = await this.executeCommand(
|
||||
Commands.Definition,
|
||||
document.uri,
|
||||
virtualDocument.content,
|
||||
virtualDocument.offset
|
||||
);
|
||||
|
||||
if (definitions.length > 0) {
|
||||
const selectionRange = definitions[0].targetSelectionRange;
|
||||
const range = new Range(
|
||||
new Position(selectionRange.start.line, selectionRange.start.character),
|
||||
new Position(selectionRange.end.line, selectionRange.end.character),
|
||||
)
|
||||
return new Location(component.uri, range);
|
||||
}
|
||||
} else if (parseResult.type === ParseResultType.Attribute) {
|
||||
const { xmlNode } = parseXml(documentText, offset);
|
||||
|
||||
const childComponent = await this.search.findComponent(xmlNode.name);
|
||||
if (!childComponent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const modifiersRegex = new RegExp([
|
||||
"\\.bind",
|
||||
"\\.stop",
|
||||
"\\.prevent",
|
||||
"\\.self",
|
||||
"\\.capture",
|
||||
"\\.sythetic",
|
||||
].join("|"), "g");
|
||||
|
||||
let attributeName = getSelectedText(/\b[a-zA-Z0-9_\-.]+\b/, document, position)
|
||||
attributeName = attributeName?.replace(modifiersRegex, "") ?? "";
|
||||
|
||||
const componentText = await readFile(childComponent.uri);
|
||||
const content = `${componentText}\n${xmlNode.name}.props.${attributeName}`;
|
||||
const definitions: any = await this.executeCommand(Commands.Definition, document.uri, content);
|
||||
|
||||
if (definitions.length > 0) {
|
||||
const selectionRange = definitions[0].targetSelectionRange;
|
||||
const range = new Range(
|
||||
new Position(selectionRange.start.line, selectionRange.start.character),
|
||||
new Position(selectionRange.end.line, selectionRange.end.character),
|
||||
)
|
||||
return new Location(childComponent.uri, range);
|
||||
}
|
||||
} else if (parseResult.type === ParseResultType.Element) {
|
||||
const currentWord = getSelectedText(/<\/?[A-Z][a-zA-Z]+/, document, position);
|
||||
if (!currentWord) {
|
||||
return;
|
||||
}
|
||||
const componentName = currentWord.replace(/[\/<]/g, "").trim();
|
||||
return await this.search.findComponent(componentName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CompletionItemProvider interface implementation
|
||||
*
|
||||
* See {@link provideElementItems}, {@link provideAttributeItems} and {@link provideExpressionItems}
|
||||
* for further details.
|
||||
*/
|
||||
async provideCompletionItems(
|
||||
document: TextDocument,
|
||||
position: Position,
|
||||
token: CancellationToken,
|
||||
context: CompletionContext
|
||||
): Promise<CompletionItem[]> {
|
||||
const component = await this.search.getCurrentComponent();
|
||||
if (!component || token.isCancellationRequested) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const offset = document.offsetAt(position);
|
||||
const documentText = document.getText();
|
||||
|
||||
const parseResult = await parse(documentText, offset);
|
||||
const { xmlDocument, xmlNode } = parseXml(documentText, offset);
|
||||
|
||||
if (parseResult.type === ParseResultType.Expression) {
|
||||
return this.provideExpressionItems(document.uri, component.uri, component.componentName, xmlDocument, xmlNode, parseResult);
|
||||
} else if (parseResult.type === ParseResultType.Attribute) {
|
||||
return this.provideAttributeItems(document.uri, xmlNode);
|
||||
} else if (parseResult.type === ParseResultType.Element) {
|
||||
return this.provideElementItems(document.uri, component.uri, component.componentName, parseResult);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the completion items for attributes.
|
||||
* - Returns props if the element is a component
|
||||
* - Returns the owl directives based on the element type
|
||||
*/
|
||||
private async provideAttributeItems(
|
||||
documentUri: Uri,
|
||||
xmlNode: any,
|
||||
): Promise<CompletionItem[]> {
|
||||
if (!xmlNode || xmlNode.name === xmlNode.name.toLowerCase()) {
|
||||
return [...owlElementAttributes, ...events];
|
||||
}
|
||||
|
||||
const childComponent = await this.search.findComponent(xmlNode.name);
|
||||
if (!childComponent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const componentText = await readFile(childComponent.uri);
|
||||
const content = `${componentText}\n${xmlNode.name}.props.`;
|
||||
const list = await this.executeCommand(Commands.Completion, documentUri, content) as CompletionList;
|
||||
|
||||
const modifiersRegex = new RegExp([
|
||||
"\\.bind",
|
||||
"\\.stop",
|
||||
"\\.prevent",
|
||||
"\\.self",
|
||||
"\\.capture",
|
||||
"\\.sythetic",
|
||||
].join("|"), "g");
|
||||
|
||||
const excludedAttrs = [
|
||||
"slots",
|
||||
...Object.keys(xmlNode.attr).map(attr => attr.replace(modifiersRegex, ""))
|
||||
];
|
||||
|
||||
return mapCompletionItems(filterComponentItems([
|
||||
...owlComponentAttributes,
|
||||
...list.items
|
||||
], excludedAttrs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the completion items for elements, this includes
|
||||
* components, "t" and html elements.
|
||||
*/
|
||||
private async provideElementItems(
|
||||
documentUri: Uri,
|
||||
componentUri: Uri,
|
||||
componentName: string,
|
||||
parseResult: any,
|
||||
): Promise<CompletionItem[]> {
|
||||
const componentText = await readFile(componentUri);
|
||||
|
||||
const content = `${componentText}\n${componentName}.components.${parseResult.expression}`;
|
||||
const list = await this.executeCommand(Commands.Completion, documentUri, content) as CompletionList;
|
||||
|
||||
return mapCompletionItems([
|
||||
...elements,
|
||||
...filterComponentItems(list.items),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the completion items for a js expression
|
||||
*/
|
||||
private async provideExpressionItems(
|
||||
documentUri: Uri,
|
||||
componentUri: Uri,
|
||||
componentName: string,
|
||||
xmlDocument: any,
|
||||
xmlNode: any,
|
||||
parseResult: ParseResult,
|
||||
): Promise<CompletionItem[]> {
|
||||
let { attributeName } = parseResult;
|
||||
|
||||
const dynamicAttributeRegex = new RegExp([
|
||||
"t-if",
|
||||
"t-elif",
|
||||
"t-foreach",
|
||||
"t-as",
|
||||
"t-key",
|
||||
"t-esc",
|
||||
"t-out",
|
||||
"t-props",
|
||||
"t-component",
|
||||
"t-set",
|
||||
"t-value",
|
||||
"t-portal",
|
||||
"t-slot-scope",
|
||||
"t-att",
|
||||
"t-tag",
|
||||
"t-log",
|
||||
"t-model",
|
||||
"t-att-[a-z_:.-]+",
|
||||
"t-on-[a-z_:.-]+"
|
||||
].join("|"));
|
||||
|
||||
if (xmlNode.name === xmlNode.name.toLowerCase() && !dynamicAttributeRegex.test(attributeName)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const componentText = await readFile(componentUri);
|
||||
const virtualDocument = await this.getVirtualJsDocument(documentUri, componentName, componentText, xmlDocument, xmlNode, parseResult);
|
||||
|
||||
const completionList = await this.executeCommand(
|
||||
Commands.Completion,
|
||||
documentUri,
|
||||
virtualDocument.content,
|
||||
virtualDocument.offset
|
||||
) as CompletionList;
|
||||
|
||||
let items = filterComponentItems(completionList.items, ["__VIRTUAL__", "setup"]);
|
||||
|
||||
if (!/\bthis\./.test(parseResult.expression)) {
|
||||
items = items.map(item => {
|
||||
let insertText = item.insertText as string;
|
||||
if (/\bthis\./.test(insertText)) {
|
||||
item.insertText = insertText.replace(/\bthis\./, "");
|
||||
}
|
||||
return item;
|
||||
})
|
||||
}
|
||||
|
||||
return mapCompletionItems([...items]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a virtual document to provide the appropriate
|
||||
* completion items for a parsed js expression.
|
||||
*
|
||||
* This method:
|
||||
* - Adds default owl variables such as env and props
|
||||
* - Adds mocks for frequently used Owl imports (which cannot be resolved using commands)
|
||||
* - Adds local variables generated from Owl xml directives such as t-for or t-set
|
||||
* - Will try to add a "this." in front of the js expression if it was omitted so it can
|
||||
* be understood by vscode typescript server.
|
||||
*
|
||||
* The expression offset is also modified accordingly.
|
||||
*/
|
||||
async getVirtualJsDocument(
|
||||
documentUri: Uri,
|
||||
componentName: string,
|
||||
componentText: string,
|
||||
xmlDocument: any,
|
||||
xmlNode: any,
|
||||
parseResult: ParseResult,
|
||||
) {
|
||||
const properties = await this.getComponentProperties(documentUri, componentName, componentText);
|
||||
|
||||
// As imports do not work, use mocks for frequently used owl functions.
|
||||
let importReplacements = workspace.getConfiguration().get(`owl-vision.autocomplete-mocks`);
|
||||
|
||||
// As imports do not work, manually add "env" and "props"
|
||||
// to the current component instance.
|
||||
const localVariables = [
|
||||
"let env = {};",
|
||||
"this.env = env;",
|
||||
`let props = ${componentName}.props;`,
|
||||
`this.props = props;`,
|
||||
];
|
||||
|
||||
// Adds local variables generated based on the template
|
||||
const path = getNodePath(xmlDocument, xmlNode);
|
||||
for (const node of path) {
|
||||
if (node.attr["t-foreach"]) {
|
||||
let array = contextualize(properties, node.attr["t-foreach"]).expression;
|
||||
localVariables.push(`const ${node.attr["t-as"]} = ${array}[0];`);
|
||||
localVariables.push(`const ${node.attr["t-as"]}_index = 0;`);
|
||||
localVariables.push(`const ${node.attr["t-as"]}_first = ${array}[0];`);
|
||||
localVariables.push(`const ${node.attr["t-as"]}_last = ${array}.at(-1);`);
|
||||
localVariables.push(`const ${node.attr["t-as"]}_value = {};`);
|
||||
} else if (node.attr["t-set"]) {
|
||||
localVariables.push(`const ${node.attr["t-set"]} = ${node.attr["t-value"]};`);
|
||||
}
|
||||
}
|
||||
|
||||
const { expression, expressionOffset } = contextualize(properties, parseResult.expression, parseResult.expressionOffset);
|
||||
|
||||
return {
|
||||
offset: expressionOffset,
|
||||
content: `${componentText}
|
||||
${importReplacements}
|
||||
class __VIRTUAL__ extends ${componentName} { __VIRTUAL__() {
|
||||
${localVariables.join("\n")}
|
||||
${expression} }}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of properties for a given component class.
|
||||
* The result in cached in `componentProperties`.
|
||||
*
|
||||
* @param documentUri
|
||||
* @param componentName
|
||||
* @param componentText
|
||||
* @returns
|
||||
*/
|
||||
async getComponentProperties(
|
||||
documentUri: Uri,
|
||||
componentName: string,
|
||||
componentText: string,
|
||||
): Promise<string[]> {
|
||||
const check = hash(componentText);
|
||||
|
||||
let cached = this.componentProperties.get(componentName);
|
||||
if (!cached || cached.check !== check) {
|
||||
const contextExpression = `${componentText}\nclass __VIRTUAL__ extends ${componentName} { __VIRTUAL__() { \nthis. }}`;
|
||||
const contextItemsCompletion = await this.executeCommand(Commands.Completion, documentUri, contextExpression, 5) as CompletionList;
|
||||
|
||||
const items = filterComponentItems(contextItemsCompletion.items, ["__VIRTUAL__", "setup"]).map(item => item.label);
|
||||
|
||||
cached = { check, items };
|
||||
this.componentProperties.set(componentName, cached);
|
||||
}
|
||||
|
||||
return cached.items;
|
||||
}
|
||||
|
||||
private async executeCommand(commandId: string, uri: Uri, content: string, offset: any = undefined) {
|
||||
const lines = content.split(/\r\n|\r|\n/);
|
||||
const _offset = offset !== undefined ? offset : (lines.at(-1)?.length ?? 0);
|
||||
const position = new Position(lines.length - 1, _offset);
|
||||
|
||||
const originalUri = uri.toString(true);
|
||||
const hashValue = hash(content);
|
||||
|
||||
const id = `owl://js/${originalUri}_${hashValue}.js`;
|
||||
this.virtualDocuments.set(id, content);
|
||||
|
||||
return await commands.executeCommand(
|
||||
commandId,
|
||||
Uri.parse(`owl://js/${encodeURIComponent(originalUri)}_${hashValue}.js`),
|
||||
position
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { XmlDocument, XmlElement, XmlNode } from "xmldoc";
|
||||
|
||||
export enum ParseResultType {
|
||||
Expression,
|
||||
Attribute,
|
||||
Element
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
type: ParseResultType;
|
||||
expression: string;
|
||||
expressionOffset: number;
|
||||
attributeName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Will parse the document to find the selected expression based on an offset.
|
||||
* The result can be on of three types:
|
||||
*
|
||||
* Element: The offset is on a element tag name, the expression is the current
|
||||
* tagname or and empty string if it's just a opening tag.
|
||||
*
|
||||
* Attribute: The offset is inside the element but not in an attribute value,
|
||||
* the expression is the current attribute name if any.
|
||||
*
|
||||
* Expression: The offset is inside an attribute value, the expression is the value.
|
||||
*/
|
||||
export async function parse(
|
||||
documentText: string,
|
||||
offset: number,
|
||||
): Promise<ParseResult> {
|
||||
|
||||
// Check if the offset is preceded by "<xyz", if true returns a type Element
|
||||
// with the current name.
|
||||
const elementMatch = documentText.substring(0, offset).match(/<([a-zA-Z\-._]*)$/);
|
||||
if (elementMatch) {
|
||||
return {
|
||||
type: ParseResultType.Element,
|
||||
expression: elementMatch[1] || "",
|
||||
expressionOffset: elementMatch[1].length || 0,
|
||||
attributeName: "",
|
||||
};
|
||||
}
|
||||
|
||||
let {
|
||||
value: expression,
|
||||
offset: expressionOffset,
|
||||
from,
|
||||
} = getSection(documentText, offset, '="', '"');
|
||||
|
||||
// If the expression contains '"', it means we aren't inside an attribute
|
||||
// value.
|
||||
if (expression.includes('"')) {
|
||||
const attributeMatch = documentText.substring(0, offset).match(/\s([a-zA-Z\-._]*)$/);
|
||||
if (attributeMatch) {
|
||||
return {
|
||||
type: ParseResultType.Attribute,
|
||||
expression: attributeMatch[1] || "",
|
||||
expressionOffset: attributeMatch[1].length || 0,
|
||||
attributeName: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let attributeName = "";
|
||||
let i = from - 2;
|
||||
while (/\S/.test(documentText[i])) {
|
||||
attributeName = documentText[i] + attributeName;
|
||||
i--;
|
||||
}
|
||||
|
||||
return {
|
||||
type: ParseResultType.Expression,
|
||||
expression,
|
||||
expressionOffset,
|
||||
attributeName,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSection(text: string, offset: number, prefix: string, postfix: string) {
|
||||
const beforeText = text.substring(0, offset);
|
||||
let from = beforeText.lastIndexOf(prefix);
|
||||
const afterText = text.substring(offset);
|
||||
const to = beforeText.length + afterText.indexOf(postfix);
|
||||
|
||||
from = from + (prefix.length);
|
||||
|
||||
return {
|
||||
value: text.substring(from, to),
|
||||
offset: offset - from,
|
||||
from: from,
|
||||
to,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns 2 xml nodes:
|
||||
* xmlNode: Tries to create the current element based on a string offset,
|
||||
* even if the node is invalid.
|
||||
* xmlDocument: The document root element, only works if the document is
|
||||
* valid xml.
|
||||
*/
|
||||
export function parseXml(text: string, offset: number): any {
|
||||
let xmlDocument = undefined;
|
||||
try {
|
||||
xmlDocument = new XmlDocument(text);
|
||||
} catch (error) { }
|
||||
|
||||
let i = 0;
|
||||
while (text[offset + i] !== "<") {
|
||||
i--;
|
||||
}
|
||||
|
||||
let node = "";
|
||||
while (text[offset + i] !== ">" || text[offset + i - 1] === "=") {
|
||||
node += text[offset + i];
|
||||
i++;
|
||||
}
|
||||
|
||||
let xmlNode = undefined;
|
||||
try {
|
||||
xmlNode = new XmlDocument(`${node}${node.endsWith("/") ? '' : '/'}>`);
|
||||
} catch (error) { }
|
||||
|
||||
return { xmlDocument, xmlNode };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array representing the elements order from the document's
|
||||
* root to the specified element.
|
||||
*/
|
||||
export function getNodePath(xmlDocument: XmlDocument, xmlNode: any): Array<XmlElement> {
|
||||
let path: Array<XmlElement> = [];
|
||||
|
||||
const traverse = (node: XmlElement, currentPath: Array<XmlElement>) => {
|
||||
if (node.name === xmlNode.name && JSON.stringify(node.attr) === JSON.stringify(xmlNode.attr)) {
|
||||
path = currentPath;
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
if (child instanceof XmlElement && child.name) {
|
||||
traverse(child, currentPath.concat(child));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(xmlDocument, []);
|
||||
|
||||
return path;
|
||||
}
|
||||
@@ -94,6 +94,31 @@ export class Search {
|
||||
return await this.find(templateName, query, "xml");
|
||||
}
|
||||
|
||||
public async getCurrentComponent(): Promise<any | undefined> {
|
||||
if (!this.currentDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = this.currentDocument.getText();
|
||||
const templateName = this.getTemplateName(text, false);
|
||||
|
||||
if (templateName) {
|
||||
const component = await this.findComponentFromTemplateName(templateName);
|
||||
|
||||
if (component) {
|
||||
const componentFile = await workspace.fs.readFile(component.uri);
|
||||
const componentText = Buffer.from(componentFile).toString('utf8');
|
||||
const componentName = this.getComponentName(componentText, templateName);
|
||||
|
||||
return {
|
||||
uri: component.uri,
|
||||
templateName,
|
||||
componentName,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findComponentFromTemplateName(templateName: string): Promise<Location | undefined> {
|
||||
const query = this.buildQuery(`template\\s*=\\s*["']`, templateName, `["']`);
|
||||
return this.find(templateName, query, "js");
|
||||
@@ -107,6 +132,27 @@ export class Search {
|
||||
}
|
||||
}
|
||||
|
||||
private getComponentName(str: string, templateName: string): string {
|
||||
const templateNameRegex = new RegExp(`template\\s*=\\s*["'](${templateName})["']`, 'g');
|
||||
const templateIndex = [...str.matchAll(templateNameRegex)][0]?.index ?? 0;
|
||||
|
||||
const matches = [...str.matchAll(new RegExp(`class\\s+([A-Za-z_]+)\\sextends\\s+[A-Za-z_]+`, 'g'))];
|
||||
let result = "";
|
||||
let currentIndex = -1;
|
||||
for (const match of matches) {
|
||||
if (match.index > templateIndex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match.index > currentIndex) {
|
||||
result = match[1];
|
||||
currentIndex = match.index;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async find(
|
||||
name: string,
|
||||
searchQuery: string,
|
||||
|
||||
@@ -102,3 +102,20 @@ export async function showResult(result: vscode.Location, openDirection: OpenDir
|
||||
editor.revealRange(result.range);
|
||||
editor.selection = new vscode.Selection(result.range.start, result.range.end);
|
||||
}
|
||||
|
||||
export async function readFile(uri: vscode.Uri): Promise<string> {
|
||||
const data = await vscode.workspace.fs.readFile(uri);
|
||||
return Buffer.from(data).toString('utf8');
|
||||
}
|
||||
|
||||
export function hash(str: string) {
|
||||
var hash = 0,
|
||||
i, chr;
|
||||
if (str.length === 0) return hash;
|
||||
for (i = 0; i < str.length; i++) {
|
||||
chr = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + chr;
|
||||
hash |= 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
"patterns": [
|
||||
{
|
||||
"contentName": "meta.embedded.block.javascript string.quoted.double.xml",
|
||||
"begin": "(\\s*)(t-if|t-else|t-elif|t-foreach|t-as|t-key|t-esc|t-out|t-props|t-component|t-set|t-value|t-portal|t-slot-scope|t-att-[a-z_:.-]+|t-on-[a-z_:.-]+)(=)(\")",
|
||||
"begin": "(\\s*)(t-if|t-elif|t-foreach|t-as|t-key|t-esc|t-out|t-props|t-component|t-set|t-value|t-portal|t-slot-scope|t-att|t-tag|t-log|t-model|t-att-[a-z_:.-]+|t-on-[a-z_:.-]+)(=)(\")",
|
||||
"beginCaptures": {
|
||||
"2": {
|
||||
"name": "entity.other.attribute-name.localname.xml owl.attribute owl.attribute.dynamic"
|
||||
@@ -183,7 +183,7 @@
|
||||
},
|
||||
{
|
||||
"contentName": "meta.embedded.block.javascript string.quoted.single.xml",
|
||||
"begin": "(\\s*)(t-if|t-else|t-elif|t-foreach|t-as|t-key|t-esc|t-out|t-props|t-component|t-set|t-value|t-portal|t-slot-scope|t-att-[a-z_:.-]+|t-on-[a-z_:.-]+)(=)(')",
|
||||
"begin": "(\\s*)(t-if|t-elif|t-foreach|t-as|t-key|t-esc|t-out|t-props|t-component|t-set|t-value|t-portal|t-slot-scope|t-att|t-tag|t-log|t-model|t-att-[a-z_:.-]+|t-on-[a-z_:.-]+)(=)(')",
|
||||
"beginCaptures": {
|
||||
"2": {
|
||||
"name": "entity.other.attribute-name.localname.xml owl.attribute owl.attribute.dynamic"
|
||||
@@ -210,7 +210,7 @@
|
||||
"patterns": [
|
||||
{
|
||||
"contentName": "string.quoted.double.xml",
|
||||
"begin": "(\\s*)(t-name|t-ref|t-set-slot|t-model|t-inherit|t-inherit-mode|t-translation)(=)(\")",
|
||||
"begin": "(\\s*)(t-name|t-else|t-ref|t-set-slot|t-inherit|t-inherit-mode|t-translation|t-debug)(=)(\")",
|
||||
"beginCaptures": {
|
||||
"2": {
|
||||
"name": "entity.other.attribute-name.localname.xml owl.attribute owl.attribute.static"
|
||||
@@ -229,7 +229,7 @@
|
||||
},
|
||||
{
|
||||
"contentName": "string.quoted.single.xml",
|
||||
"begin": "(\\s*)(t-name|t-ref|t-set-slot|t-model|t-inherit|t-inherit-mode|t-translation)(=)(')",
|
||||
"begin": "(\\s*)(t-name|t-else|t-ref|t-set-slot|t-inherit|t-inherit-mode|t-translation|t-debug)(=)(')",
|
||||
"beginCaptures": {
|
||||
"2": {
|
||||
"name": "entity.other.attribute-name.localname.xml owl.attribute owl.attribute.static"
|
||||
|
||||