Compare commits

...

21 Commits

Author SHA1 Message Date
Géry Debongnie 4b9e6bad0b [REL] v2.1.1
# v2.1.1

 - [FIX] components: solve missing update (concurrency issue)
 - [DOC] remove old references to comp for useRef hook
 - [FIX] devtools: Fix build commands for windows users
 - [FIX] devtools: Fix devtools in detached window
 - [IMP] devtools: Add a button to navigate to the doc
 - [IMP] devtools: Add devtools documentation
 - [IMP] Allow app to be mounted in shadow DOM
 - [IMP] playground: allow sharing playground links
 - [IMP] runtime: allow validating object values using a type description
 - [FIX] devtools: Increase vertical padding of the search bar
 - [FIX] devtools: Hide the collapse all button
 - [FIX] devtools: Fix bad computation of highlight boxes on the page
 - [FIX] package: Auto-update package-lock.json
2023-04-17 11:08:57 +02:00
Géry Debongnie 9a5ff7a619 [FIX] components: solve missing update (concurrency issue)
Before this commit, in some specific situations, owl could skip updating
the DOM, even though it should. Here is what could happen:

- we have a parent component (A) and a child component (B)
- B depends on some reactive props from A
- we update some state that results in A being rendered, and B updated
- before this render is applied to the dom, we update some state again,
which causes A to be rendered again
- however, this new render is such that the B props are now identical,
so it will skip rendering B
- it will then apply the result of the render to the DOM => only A is
updated but B should also be updated!

The problem comes from the fact that Owl committed the props to the
component right after the rendering, to the new props were used in the
props comparison method to decide if B should be updated. This is
incorrect, we should always use the current props to decide what to do,
and only commit them to the component after it has been patched.
2023-04-17 11:06:31 +02:00
Géry Debongnie 5d40cc112d [DOC] remove old references to comp for useRef hook
Going from owl 1.0 to 2.0, we removed the possibility of having a
reference to a component. However, the documentation was not properly
updated to reflect that.
2023-04-13 10:39:20 +02:00
Julien Carion (juca) aa19897031 [FIX] devtools: Fix build commands for windows users
This commit adapts the commands used and the rollup file to be
compatible with windows console commands syntax.
2023-04-13 10:17:59 +02:00
Julien Carion (juca) afd7f957df [FIX] devtools: Fix devtools in detached window
This commit fixes the issues that were happening when using the
devtools in a detached window
2023-04-13 09:30:44 +02:00
Julien Carion (juca) cca4b4bb8e [IMP] devtools: Add a button to navigate to the doc
This commit adds a button to the devtools navbar which links
to the documentation.
2023-04-12 17:11:45 +02:00
Julien Carion (juca) b848a83777 [IMP] devtools: Add devtools documentation
This commit adds the documentation for the devtools extension.
2023-04-11 11:01:07 +02:00
tsm-odoo c0c40c0387 [IMP] Allow app to be mounted in shadow DOM
This commit allow owl Apps to be mounted into a shadow DOM.
2023-04-11 08:43:42 +02:00
Samuel Degueldre 606421776b [IMP] playground: allow sharing playground links
This is useful to share editable snippets of owl code, which can be used
to paste a reproduction for a given issue or give someone an interactive
example of some feature.
2023-04-06 11:48:36 +02:00
Samuel Degueldre 47009912df [IMP] runtime: allow validating object values using a type description
In JS, objects are often used as a mapping that gives fast lookup on the
keys, in those cases, the keys themselves are not known in advance, but
the values may have a shape that they're expected to follow. This commit
adds support for a `values` key in schema for objects, which will be
checked against all values in the object during validation.
2023-04-06 08:58:58 +02:00
Julien Carion (juca) fea8fcaf61 [FIX] devtools: Increase vertical padding of the search bar
This commit increases the vertical padding of the search bar in
order to be easier to click on.
2023-04-06 08:58:30 +02:00
Julien Carion (juca) e562f6a24f [FIX] devtools: Hide the collapse all button
This commit hides the collapse all button when the profiler is in
Events log mode since it doesn't do anything in this case
2023-04-06 08:58:30 +02:00
Julien Carion (juca) 23acf203e9 [FIX] devtools: Fix bad computation of highlight boxes on the page
This commit adds box-sizing: border-box to the highlight elements
created by the devtools on the page. This was making some of the
boxes overflow on some pages (like on POS sessions) because
the border-box box-sizing was not the default behavior on these
pages.
2023-04-06 08:58:30 +02:00
Julien Carion (juca) 94ae940e82 [FIX] package: Auto-update package-lock.json
This commit updates the version in package-lock.json and make it so
it is automatically and correctly updated at each new release.
2023-03-31 13:39:30 +02:00
Géry Debongnie 64de716b91 [REL] v2.1.0 2023-03-30 13:05:25 +02:00
Julien Carion (juca) 1b1597c49e [IMP] tools: Include the devtools into the release system
This commit adds the devtools as a zip file containing both chrome
and firefox versions of the extension in the release and also updates
the doc to install it easily.
2023-03-30 13:03:00 +02:00
Julien Carion ef5e4a0637 [ADD] tools: create owl-devtools extension
This commit adds the code of the owl devtools extension.
This extension can be added on chrome or on firefox for
developpers to be able to inspect the contents of all owl
applications that are present on any web page, see all the
components, interract with them and their content to some
extend and perform a bit of profiling with their renders.
2023-03-30 12:57:28 +02:00
Lucas Lefèvre a9323c3fcd [REF] hooks: avoid confusion for sync hooks
`onWillPatch`, `onWillUnmount`, `onWillDestroy` are synchronous hooks.
Owl will not wait any promise return by the callback.
Yet, the callback return type includes `Promise<void>`,
which is confusing as one might think it means the hooks is async.
2023-03-24 10:32:47 +01:00
Louis Wicket ce61e90135 [FIX] doc: add missing brackets 2023-03-22 15:19:00 +01:00
Géry Debongnie 8893e026d3 [REL] v2.0.9
# v2.0.9

 - [FIX] components: do not crash when binding anonymous function
2023-03-13 10:55:05 +01:00
Géry Debongnie 0024f33fa1 [FIX] components: do not crash when binding anonymous function
It is not really useful, but it is possible to bind an anonymous
function prop. However, with the recent change on how the bind feature
works, it now crashes.

This commit makes sure that we properly apply the `bind` operation to
the function, and not to the last term of the function.
2023-03-09 15:35:13 +01:00
108 changed files with 8506 additions and 278 deletions
+1 -1
View File
@@ -28,4 +28,4 @@ release-notes.md
.rpt2_cache
# useful in some cases
/temp
/temp
+22 -1
View File
@@ -113,6 +113,7 @@ Are you new to Owl? This is the place to start!
- [Why did Odoo build Owl?](doc/miscellaneous/why_owl.md)
- [Changelog (from owl 1.x to 2.x)](CHANGELOG.md)
- [Notes on compiled templates](doc/miscellaneous/compiled_template.md)
- [Owl devtools extension](doc/tools/devtools.md)
## Installing Owl
@@ -121,8 +122,28 @@ Owl is available on `npm` and can be installed with the following command:
```
npm install @odoo/owl
```
If you want to use a simple `<script>` tag, the last release can be downloaded here:
- [owl](https://github.com/odoo/owl/releases/latest)
## Installing Owl devtools
The Owl devtools browser extension is also available in the [release](https://github.com/odoo/owl/releases/latest):
Unzip the owl-devtools.zip file and follow the instructions depending on your browser:
### Chrome
Go to your chrome extensions admin panel, activate developer mode and click on `Load unpacked`.
Select the devtools-chrome folder and that's it, your extension is active!
There is a convenient refresh button on the extension card (still on the same admin page) to update your code.
Do note that if you got some problems, you may need to completly remove and reload the extension to completly refresh the extension.
### Firefox
Go to the address about:debugging#/runtime/this-firefox and click on `Load temporary Add-on...`.
Select any file in the devtools-firefox folder and that's it, your extension is active!
Here, you can use the reload button to refresh the extension.
Note that you may have to open another window or reload your tab to see the extension working.
Also note that the extension will only be active on pages that have a sufficient version of owl.
+7 -8
View File
@@ -112,17 +112,16 @@ of a component, rendered by Owl. It only work on a html element tagged by the
```xml
<div>
<input t-ref="someDiv"/>
<input t-ref="someInput"/>
<span>hello</span>
</div>
```
In this example, the component will be able to access the `div` and the component
`SubComponent` with the `useRef` hook:
In this example, the component will be able to access the `input` with the `useRef` hook:
```js
class Parent extends Component {
inputRef = useRef("someComponent");
inputRef = useRef("someInput");
someMethod() {
// here, if component is mounted, refs are active:
@@ -139,18 +138,18 @@ The `t-ref` directive also accepts dynamic values with string interpolation
`t-component` directives). For example,
```xml
<div t-ref="component_{{someCondition ? '1' : '2'}}"/>
<div t-ref="div_{{someCondition ? '1' : '2'}}"/>
```
Here, the references need to be set like this:
```js
this.ref1 = useRef("component_1");
this.ref2 = useRef("component_2");
this.ref1 = useRef("div_1");
this.ref2 = useRef("div_2");
```
References are only guaranteed to be active while the parent component is mounted.
If this is not the case, accessing `el` or `comp` on it will return `null`.
If this is not the case, accessing `el` on it will return `null`.
### `useSubEnv` and `useChildSubEnv`
+5
View File
@@ -212,6 +212,7 @@ For each key, a `prop` definition is either a boolean, a constructor, a list of
- `type`: the main type of the prop being validated
- `element`: if the type was `Array`, then the `element` key describes the type of each element in the array. If it is not set, then we only validate the array, not its elements,
- `shape`: if the type was `Object`, then the `shape` key describes the interface of the object. If it is not set, then we only validate the object, not its elements,
- `values`: if the type was `Object`, then the `values` key describes the interface of values in the object, this allows validating objects that are used as mappings, where keys are not known in advance but the shape of the values is.
- `validate`: this is a function which should return a boolean to determine if
the value is valid or not. Useful for custom validation logic.
- `optional`: if true, the prop is not mandatory
@@ -276,6 +277,10 @@ class ComponentB extends owl.Component {
name: {type: String, optional: true},
url: String
]}, // object, with keys id (number), name (string, optional) and url (string)
someObj3: {
type: Object,
values: { type: Array, element: String },
}, // object with arbitary keys where values are arrays of strings
someFlag: Boolean, // a boolean, mandatory (even if `false`)
someVal: [Boolean, Date], // either a boolean or a date
otherValue: true, // indicates that it is a prop
+1 -1
View File
@@ -477,7 +477,7 @@ not work with other iterables, such as `Set`. However, it is only a matter of
using the `...` javascript operator. For example:
```xml
<t t-foreach="...items" t-as="item">...</t>
<t t-foreach="[...items]" t-as="item">...</t>
```
The `...` operator will convert the `Set` (or any other iterables) into a list,
+56
View File
@@ -0,0 +1,56 @@
# Owl Devtools Browser extension
The owl devtools browser extension is an extension available on chrome or firefox which adds an owl tab
to the browser devtools in order to inspect all owl apps that are present on any web page, their components
and allows to interract with their data to a certain extend. There is also a profiler available to visualize
the components' lifecycle and be able to trace their origin.
See the [`devtools doc`](devtools_guide.md) for more information.
## Install the extension manually (for devs)
In the owl root folder:
```bash
npm install
```
For chrome:
```bash
npm run build:devtools-chrome
```
For firefox:
```bash
npm run build:devtools-firefox
```
You can also run:
```bash
npm run dev:devtools-chrome
```
or
```bash
npm run dev:devtools-firefox
```
to avoid recompiling owl and gain time if it has already been done.
To run the extension:
In google chrome: go to your chrome extensions admin panel, activate developer mode and click on `Load unpacked`.
Select the output folder (dist/devtools) and that's it, your extension is active!
There is a convenient refresh button on the extension card (still on the same admin page) to update your code.
Do note that if you got some problems, you may need to completly remove and reload the extension to completly refresh the extension.
In firefox: go to the address about:debugging#/runtime/this-firefox and click on `Load temporary Add-on...`.
Select any file of the output folder (dist/devtools) and that's it, your extension is active!
Here, you can use the reload button to refresh the extension.
Note that you may have to open another window or reload your tab to see the extension working.
Also note that the extension will only be active on pages that have a sufficient version of owl.
+150
View File
@@ -0,0 +1,150 @@
# Owl Devtools Guide
## Information popup
After having installed the extension, a new icon will be added to your extension bar.
If you don't see it, you can pin the extension using the extensions popup.
<img src="screenshots/extensions.png"/>
Clicking on the owl icon will open the information popup. This popup is useful
to know in advance whether owl is loaded in the tab or not. This is also indicated
by the icon itself: if it is flipped upside-down, it means that owl is not loaded in the
active tab. Do note that old versions of owl are not supported by the extension and will
therefore be indicated either as obsolete or absent by the extension popup.
<img src="screenshots/popup.png"/>
## First steps
When you are on a page where owl is detected, you can open your devtools either with
right-click -> Inspect or using F12. In the devtools menu, you can search for the Owl
tab which is added by the extension. It will be present by default at the end of the list but
you can drag and drop it at the position you want for easier navigation in the future.
<img src="screenshots/find_owl_tab.png"/>
When you open the tab, you arrive on the Components view by default which is one of the
two available tabs at the top. Here is an example of the devtools on the Odoo CRM app:
<img src="screenshots/crm.png"/>
## Components tab
The components tab is separated into two sub windows: the components tree in the left and
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:
<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
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.
<img src="screenshots/picker.png"/>
In the tree itself, the navigation is quite simple and is similar to the one in the Elements tab
of the browser's devtools. It is possible to navigate with the keyboard using the arrow keys and
multiple shortcuts are available in a custom menu when right-clicking on a component. This menu
allows to expand/fold all the children nodes of a component, fold its direct children only, inspect
the source code of the component, send it as a global variable in the console, go to the Elements tab
and focus on its content, force a rerender of the component, send its observed states to the console
as a global variable, inspect its compiled template in the Sources tab or send its raw template
to the console.
<img src="screenshots/menu.png"/>
The component details window in the right will show the component that is currently selected as well
as its env, props, observed states and all the other variables that are present on its instance.
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.
<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
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.
<img src="screenshots/function_menu.png"/>
There are several icons available to perform several of the actions described before in the components
tree context menu and all these actions are also available by opening the menu by right-clicking on the
component's name. Using the left click on the component's name will focus it in the components tree.
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.
<img src="screenshots/edit.png"/>
## Profiler
The profiler tab is the other tab of the owl devtools. It consists in an actions bar at the top and
a tree/list of events related to the owl components' renders. Here is an example of the events launched
when entering the Odoo Crm app.
<img src="screenshots/profiler.png"/>
In the initial state, no event is displayed. You need to activate the recording of events before they
are intercepted by the devtools using the record button.
<img src="screenshots/record.png"/>
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.
<img src="screenshots/events_log.png"/>
The third button is only visible in tree view and allows to fold all the render events that were recorded.
Some actions are also available when using the right-click on any event of the tree view for navigation
purpose in a similar fashion as in the components tree.
<img src="screenshots/tree_actions.png"/>
There is also the Trace Renderings and Trace Subscriptions features. These features are independant of the
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
<img src="screenshots/trace_rendering.png"/>
<img src="screenshots/trace_subscriptions.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.
<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.
This will eventually happen any time a tab stays opened for too long without being refreshed.
Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

+490 -216
View File
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@odoo/owl",
"version": "2.0.8",
"version": "2.1.1",
"description": "Odoo Web Library (OWL)",
"main": "dist/owl.cjs.js",
"module": "dist/owl.es.js",
@@ -16,6 +16,11 @@
"build:runtime": "rollup -c --failAfterWarnings runtime",
"build:compiler": "rollup -c --failAfterWarnings compiler",
"build": "npm run build:bundle",
"build:devtools": "rollup -c ./tools/devtools/rollup.config.js",
"dev:devtools-chrome": "npm run build:devtools -- --config-browser=chrome",
"dev:devtools-firefox": "npm run build:devtools -- --config-browser=firefox",
"build:devtools-chrome": "npm run dev:devtools-chrome -- --config-env=production",
"build:devtools-firefox": "npm run dev:devtools-firefox -- --config-env=production",
"test": "jest",
"test:debug": "node --inspect-brk node_modules/.bin/jest --runInBand --watch --testTimeout=5000000",
"test:watch": "jest --watch",
@@ -23,8 +28,8 @@
"playground": "npm run build && npm run playground:serve",
"preplayground:watch": "npm run build",
"playground:watch": "npm-run-all --parallel playground:serve \"build:* -- --watch\"",
"prettier": "prettier {src/*.ts,src/**/*.ts,tests/*.ts,tests/**/*.ts,doc/*.md,doc/**/*.md} --write",
"check-formatting": "prettier {src/*.ts,src/**/*.ts,tests/*.ts,tests/**/*.ts,doc/*.md,doc/**/*.md} --check",
"prettier": "prettier {src/*.ts,src/**/*.ts,tests/*.ts,tests/**/*.ts,doc/*.md,doc/**/*.md,tools/devtools/**/*.js} --write",
"check-formatting": "prettier {src/*.ts,src/**/*.ts,tests/*.ts,tests/**/*.ts,doc/*.md,doc/**/*.md,tools/devtools/**/*.js} --check",
"lint": "eslint src/**/*.ts tests/**/*.ts",
"publish": "npm run build && npm publish",
"release": "node tools/release.js",
@@ -56,7 +61,11 @@
"npm-run-all": "^4.1.5",
"prettier": "2.4.1",
"rollup": "^2.56.3",
"rollup-plugin-copy": "^3.3.0",
"rollup-plugin-delete": "^2.0.0",
"rollup-plugin-dts": "^4.2.2",
"rollup-plugin-execute": "^1.1.1",
"rollup-plugin-string": "^3.0.0",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript2": "^0.31.1",
"source-map-support": "^0.5.10",
+1 -1
View File
@@ -1135,7 +1135,7 @@ export class CodeGenerator {
name = _name;
switch (suffix) {
case "bind":
value = `${value}.bind(this)`;
value = `(${value}).bind(this)`;
break;
case "alike":
break;
+5 -2
View File
@@ -84,7 +84,10 @@ export class App<
this.props = config.props || ({} as P);
}
mount(target: HTMLElement, options?: MountOptions): Promise<Component<P, E> & InstanceType<T>> {
mount(
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 } });
@@ -99,7 +102,7 @@ export class App<
return new ComponentNode(Component, props, this, null, null);
}
mountNode(node: ComponentNode, target: HTMLElement, options?: MountOptions) {
mountNode(node: ComponentNode, target: HTMLElement | ShadowRoot, options?: MountOptions) {
const promise: any = new Promise((resolve, reject) => {
let isResolved = false;
// manually set a onMounted callback.
+2 -1
View File
@@ -1,3 +1,4 @@
import { inOwnerDocument } from "../utils";
import { config } from "./config";
type EventHandlerSetter = (this: HTMLElement, data: any) => void;
@@ -28,7 +29,7 @@ function createElementHandler(evName: string, capture: boolean = false): EventHa
function listener(ev: Event) {
const currentTarget = ev.currentTarget as HTMLElement;
if (!currentTarget || !currentTarget.ownerDocument.contains(currentTarget)) return;
if (!currentTarget || !inOwnerDocument(currentTarget)) return;
const data = (currentTarget as any)[eventKey];
if (!data) return;
config.mainEventHandler(data, ev, currentTarget);
+3 -2
View File
@@ -73,6 +73,7 @@ export class ComponentNode<P extends Props = any, E = any> implements VNode<Comp
forceNextRender: boolean = false;
parentKey: string | null;
props: P;
nextProps: P | null = null;
renderFn: Function;
parent: ComponentNode | null;
@@ -220,7 +221,7 @@ export class ComponentNode<P extends Props = any, E = any> implements VNode<Comp
}
async updateAndRender(props: P, parentFiber: Fiber) {
const rawProps = props;
this.nextProps = props;
props = Object.assign({}, props);
// update
const fiber = makeChildFiber(this, parentFiber);
@@ -245,7 +246,6 @@ export class ComponentNode<P extends Props = any, E = any> implements VNode<Comp
return;
}
component.props = props;
this.props = rawProps;
fiber.render();
const parentRoot = parentFiber.root!;
if (this.willPatch.length) {
@@ -327,6 +327,7 @@ export class ComponentNode<P extends Props = any, E = any> implements VNode<Comp
// by the component will be patched independently in the appropriate
// fiber.complete
this._patch();
this.props = this.nextProps!;
}
}
_patch() {
+2 -1
View File
@@ -1,6 +1,7 @@
import type { Env } from "./app";
import { getCurrent } from "./component_node";
import { onMounted, onPatched, onWillUnmount } from "./lifecycle_hooks";
import { inOwnerDocument } from "./utils";
// -----------------------------------------------------------------------------
// useRef
@@ -16,7 +17,7 @@ export function useRef<T extends HTMLElement = HTMLElement>(name: string): { el:
return {
get el(): T | null {
const el = refs[name];
return el?.ownerDocument.contains(el) ? el : null;
return inOwnerDocument(el) ? el : null;
},
};
}
+1 -1
View File
@@ -54,7 +54,7 @@ export {
onWillDestroy,
onError,
} from "./lifecycle_hooks";
export { validate } from "./validation";
export { validate, validateType } from "./validation";
export { OwlError } from "./error_handling";
export const __info__ = {
+3 -3
View File
@@ -63,7 +63,7 @@ export function onMounted(fn: () => void | any) {
node.mounted.push(decorate(fn.bind(node.component), "onMounted"));
}
export function onWillPatch(fn: () => Promise<void> | any | void) {
export function onWillPatch(fn: () => any | void) {
const node = getCurrent();
const decorate = node.app.dev ? wrapError : (fn: any) => fn;
node.willPatch.unshift(decorate(fn.bind(node.component), "onWillPatch"));
@@ -75,13 +75,13 @@ export function onPatched(fn: () => void | any) {
node.patched.push(decorate(fn.bind(node.component), "onPatched"));
}
export function onWillUnmount(fn: () => Promise<void> | void | any) {
export function onWillUnmount(fn: () => void | any) {
const node = getCurrent();
const decorate = node.app.dev ? wrapError : (fn: any) => fn;
node.willUnmount.unshift(decorate(fn.bind(node.component), "onWillUnmount"));
}
export function onWillDestroy(fn: () => Promise<void> | void | any) {
export function onWillDestroy(fn: () => void | any) {
const node = getCurrent();
const decorate = node.app.dev ? wrapError : (fn: any) => fn;
node.willDestroy.push(decorate(fn.bind(node.component), "onWillDestroy"));
+18 -3
View File
@@ -27,13 +27,28 @@ export function batched(callback: Callback): Callback {
};
}
export function validateTarget(target: HTMLElement) {
/**
* Determine whether the given element is contained in its ownerDocument:
* either directly or with a shadow root in between.
*/
export function inOwnerDocument(el?: HTMLElement) {
if (!el) {
return false;
}
if (el.ownerDocument.contains(el)) {
return true;
}
const rootNode = el.getRootNode();
return rootNode instanceof ShadowRoot && el.ownerDocument.contains(rootNode.host);
}
export function validateTarget(target: HTMLElement | ShadowRoot) {
// Get the document and HTMLElement corresponding to the target to allow mounting in iframes
const document = target && target.ownerDocument;
if (document) {
const HTMLElement = document.defaultView!.HTMLElement;
if (target instanceof HTMLElement) {
if (!document.body.contains(target)) {
if (target instanceof HTMLElement || target instanceof ShadowRoot) {
if (!document.body.contains(target instanceof HTMLElement ? target : target.host)) {
throw new OwlError("Cannot mount a component on a detached dom node");
}
return;
+15 -3
View File
@@ -17,6 +17,7 @@ interface TypeInfo {
validate?: Function;
shape?: Schema;
element?: TypeDescription;
values?: TypeDescription;
}
type ValueType = { value: any };
@@ -137,7 +138,7 @@ function validateArrayType(key: string, value: any, descr: TypeDescription): str
return null;
}
function validateType(key: string, value: any, descr: TypeDescription): string | null {
export function validateType(key: string, value: any, descr: TypeDescription): string | null {
if (value === undefined) {
return isOptional(descr) ? null : `'${key}' is undefined (should be a ${describe(descr)})`;
} else if (isBaseType(descr)) {
@@ -151,13 +152,24 @@ function validateType(key: string, value: any, descr: TypeDescription): string |
let result: string | null = null;
if ("element" in descr) {
result = validateArrayType(key, value, descr.element!);
} else if ("shape" in descr && !result) {
} else if ("shape" in descr) {
if (typeof value !== "object" || Array.isArray(value)) {
result = `'${key}' is not an object`;
} else {
const errors = validateSchema(value, descr.shape!);
if (errors.length) {
result = `'${key}' has not the correct shape (${errors.join(", ")})`;
result = `'${key}' doesn't have the correct shape (${errors.join(", ")})`;
}
}
} else if ("values" in descr) {
if (typeof value !== "object" || Array.isArray(value)) {
result = `'${key}' is not an object`;
} else {
const errors = Object.entries(value)
.map(([key, value]) => validateType(key, value, descr.values!))
.filter(Boolean);
if (errors.length) {
result = `some of the values in '${key}' are invalid (${errors.join(", ")})`;
}
}
}
+2 -2
View File
@@ -1,2 +1,2 @@
// do not modify manually. This value is updated by the release script.
export const version = "2.0.8";
// do not modify manually. This file is generated by the release script.
export const version = "2.1.1";
@@ -1252,6 +1252,31 @@ exports[`delayed render does not go through when t-component value changed 3`] =
}"
`;
exports[`delayed render is not cancelled by upcoming render 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(\`B\`, true, false, false, [\\"state\\",\\"isEmpty\\"]);
return function template(ctx, node, key = \\"\\") {
return comp1({state: ctx['state'],isEmpty: ctx['state'].groups.length===0}, key + \`__1\`, node, this, null);
}
}"
`;
exports[`delayed render is not cancelled by upcoming render 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
const b2 = text(ctx['props'].state.groups.length);
const b3 = text(ctx['props'].state.config.test);
return multi([b2, b3]);
}
}"
`;
exports[`delayed rendering, but then initial rendering is cancelled by yet another render 1`] = `
"function anonymous(app, bdom, helpers
) {
@@ -349,7 +349,7 @@ exports[`bound functions are considered 'alike' 1`] = `
return function template(ctx, node, key = \\"\\") {
const b2 = text(ctx['state'].val);
const b3 = comp1({fn: ctx['someFunction'].bind(this)}, key + \`__1\`, node, this, null);
const b3 = comp1({fn: (ctx['someFunction']).bind(this)}, key + \`__1\`, node, this, null);
return multi([b2, b3]);
}
}"
@@ -373,7 +373,7 @@ exports[`bound functions is not referentially equal after update 1`] = `
const comp1 = app.createComponent(\`Child\`, true, false, false, [\\"val\\"]);
return function template(ctx, node, key = \\"\\") {
return comp1({val: ctx['state'].val,fn: ctx['someFunction'].bind(this)}, key + \`__1\`, node, this, null);
return comp1({val: ctx['state'].val,fn: (ctx['someFunction']).bind(this)}, key + \`__1\`, node, this, null);
}
}"
`;
@@ -396,7 +396,7 @@ exports[`can bind function prop with bind suffix 1`] = `
const comp1 = app.createComponent(\`Child\`, true, false, false, []);
return function template(ctx, node, key = \\"\\") {
return comp1({doSomething: ctx['doSomething'].bind(this)}, key + \`__1\`, node, this, null);
return comp1({doSomething: (ctx['doSomething']).bind(this)}, key + \`__1\`, node, this, null);
}
}"
`;
@@ -411,3 +411,27 @@ exports[`can bind function prop with bind suffix 2`] = `
}
}"
`;
exports[`do not crash when binding anonymous function prop with bind 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 = \\"\\") {
const v1 = ctx['this'];
return comp1({doSomething: ((_val)=>v1.doSomething(_val)).bind(this)}, key + \`__1\`, node, this, null);
}
}"
`;
exports[`do not crash when binding anonymous function prop with bind suffix 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(\`child\`);
}
}"
`;
@@ -90,7 +90,7 @@ exports[`slots can define and call slots with bound params 1`] = `
return function template(ctx, node, key = \\"\\") {
const ctx1 = capture(ctx);
return comp1({slots: markRaw({'abc': {__render: slot1.bind(this), __ctx: ctx1, getValue: ctx['getValue'].bind(this)}})}, key + \`__1\`, node, this, null);
return comp1({slots: markRaw({'abc': {__render: slot1.bind(this), __ctx: ctx1, getValue: (ctx['getValue']).bind(this)}})}, key + \`__1\`, node, this, null);
}
}"
`;
@@ -1516,7 +1516,7 @@ exports[`slots simple default slot with params and bound function 2`] = `
let { callSlot } = helpers;
return function template(ctx, node, key = \\"\\") {
return callSlot(ctx, node, key, 'default', false, {fn: ctx['getValue'].bind(this)});
return callSlot(ctx, node, key, 'default', false, {fn: (ctx['getValue']).bind(this)});
}
}"
`;
@@ -556,7 +556,7 @@ exports[`t-call t-call-context: ComponentNode is not looked up in the context 2`
let ref1 = (el) => this.__owl__.setRef((\`myRef\`), el);
const b2 = block2([ref1]);
const ctx1 = capture(ctx);
const b6 = comp1({prop: ctx['method'].bind(this),slots: markRaw({'default': {__render: slot1.bind(this), __ctx: ctx1}})}, key + \`__1\`, node, this, null);
const b6 = comp1({prop: (ctx['method']).bind(this),slots: markRaw({'default': {__render: slot1.bind(this), __ctx: ctx1}})}, key + \`__1\`, node, this, null);
return multi([b2, b6]);
}
}"
+74
View File
@@ -4126,6 +4126,80 @@ test("delayed render does not go through when t-component value changed", async
]).toBeLogged();
});
test("delayed render is not cancelled by upcoming render", async () => {
let b: any;
class B extends Component {
static template = xml`
<t t-esc="props.state.groups.length"/>
<t t-esc="props.state.config.test"/>`;
setup() {
useLogLifecycle();
b = this;
}
}
class A extends Component {
static components = { B };
static template = xml`<B state="state" isEmpty="state.groups.length === 0"/>`;
state = useState({ groups: [], config: { test: "initial" } });
setup() {
useLogLifecycle();
}
}
await mount(A, fixture);
expect([
"A:setup",
"A:willStart",
"A:willRender",
"B:setup",
"B:willStart",
"A:rendered",
"B:willRender",
"B:rendered",
"B:mounted",
"A:mounted",
]).toBeLogged();
expect(fixture.innerHTML).toBe("0initial");
b.props.state.config.test = "red";
b.props.state.groups.push(1);
expect([]).toBeLogged();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
b.props.state.config.test = "black";
b.props.state.groups.push(1);
expect([
"A:willRender",
"B:willUpdateProps",
"A:rendered",
"B:willRender",
"B:rendered",
]).toBeLogged();
await nextTick();
expect(fixture.innerHTML).toBe("2black");
expect([
"A:willRender",
"B:willUpdateProps",
"A:rendered",
"B:willRender",
"B:rendered",
"A:willPatch",
"B:willPatch",
"B:patched",
"A:patched",
]).toBeLogged();
});
// test.skip("components with shouldUpdate=false", async () => {
// const state = { p: 1, cc: 10 };
+25
View File
@@ -200,6 +200,31 @@ test("can bind function prop with bind suffix", async () => {
expect(fixture.innerHTML).toBe("child");
});
test("do not crash when binding anonymous function prop with bind suffix", async () => {
class Child extends Component {
static template = xml`child`;
setup() {
this.props.doSomething(123);
}
}
let boundedThing: any = null;
class Parent extends Component {
static template = xml`<Child doSomething.bind="(val) => this.doSomething(val)"/>`;
static components = { Child };
doSomething(val: number) {
expect(val).toBe(123);
boundedThing = this;
}
}
const parent = await mount(Parent, fixture);
expect(boundedThing).toBe(parent);
expect(fixture.innerHTML).toBe("child");
});
test("bound functions is not referentially equal after update", async () => {
let isEqual = false;
class Child extends Component {
+5 -5
View File
@@ -404,7 +404,7 @@ describe("props validation", () => {
await mountProm;
expect(error!).toBeDefined();
expect(error!.message).toBe(
"Invalid props for component 'SubComp': 'p' has not the correct shape (unknown key 'extra')"
"Invalid props for component 'SubComp': 'p' doesn't have the correct shape (unknown key 'extra')"
);
props = { p: { id: "1", url: "url" } };
app = new App(Parent, { test: true });
@@ -413,7 +413,7 @@ describe("props validation", () => {
await mountProm;
expect(error!).toBeDefined();
expect(error!.message).toBe(
"Invalid props for component 'SubComp': 'p' has not the correct shape ('id' is not a number)"
"Invalid props for component 'SubComp': 'p' doesn't have the correct shape ('id' is not a number)"
);
error = undefined;
props = { p: { id: 1 } };
@@ -423,7 +423,7 @@ describe("props validation", () => {
await mountProm;
expect(error!).toBeDefined();
expect(error!.message).toBe(
"Invalid props for component 'SubComp': 'p' has not the correct shape ('url' is missing (should be a string))"
"Invalid props for component 'SubComp': 'p' doesn't have the correct shape ('url' is missing (should be a string))"
);
});
@@ -470,7 +470,7 @@ describe("props validation", () => {
await mountProm;
expect(error!).toBeDefined();
expect(error!.message).toBe(
"Invalid props for component 'SubComp': 'p' has not the correct shape ('url' is not a boolean or list of numbers)"
"Invalid props for component 'SubComp': 'p' doesn't have the correct shape ('url' is not a boolean or list of numbers)"
);
});
@@ -502,7 +502,7 @@ describe("props validation", () => {
}
expect(error!).toBeDefined();
expect(error!.message).toBe(
"Invalid props for component 'TestComponent': 'myprop[0]' has not the correct shape (unknown key 'a')"
"Invalid props for component 'TestComponent': 'myprop[0]' doesn't have the correct shape (unknown key 'a')"
);
});
@@ -0,0 +1,42 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`shadow_dom can bind event handler 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<button block-handler-0=\\"click\\">Click</button>\`);
return function template(ctx, node, key = \\"\\") {
let hdlr1 = [ctx['add'], ctx];
return block1([hdlr1]);
}
}"
`;
exports[`shadow_dom can mount app 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div class=\\"my-div\\"/>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`shadow_dom useRef hook 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div class=\\"my-div\\" block-ref=\\"0\\"/>\`);
return function template(ctx, node, key = \\"\\") {
let ref1 = (el) => this.__owl__.setRef((\`refName\`), el);
return block1([ref1]);
}
}"
`;
+67
View File
@@ -0,0 +1,67 @@
import { App, Component, useRef, xml } from "../../src";
import { status } from "../../src/runtime/status";
import { makeTestFixture, snapshotEverything } from "../helpers";
let fixture: HTMLElement;
snapshotEverything();
beforeEach(() => {
fixture = makeTestFixture();
});
describe("shadow_dom", () => {
test("can mount app", async () => {
class SomeComponent extends Component {
static template = xml`<div class="my-div"/>`;
}
const container = document.createElement("div");
fixture.appendChild(container);
const shadow = container.attachShadow({ mode: "open" });
const app = new App(SomeComponent);
const comp = await app.mount(shadow);
const div = shadow.querySelector(".my-div");
expect(div).not.toBe(null);
expect(shadow.contains(div)).toBe(true);
app.destroy();
expect(shadow.contains(div)).toBe(false);
expect(status(comp)).toBe("destroyed");
});
test("can bind event handler", async () => {
let a = 1;
class SomeComponent extends Component {
static template = xml`<button t-on-click="add">Click</button>`;
add() {
a = 3;
}
}
const container = document.createElement("div");
fixture.appendChild(container);
const shadow = container.attachShadow({ mode: "open" });
await new App(SomeComponent).mount(shadow);
expect(a).toBe(1);
shadow.querySelector("button")!.click();
expect(a).toBe(3);
});
test("useRef hook", async () => {
let comp: SomeComponent;
class SomeComponent extends Component {
static template = xml`<div t-ref="refName" class="my-div"/>`;
div = useRef("refName");
setup() {
comp = this;
}
}
const container = document.createElement("div");
fixture.appendChild(container);
const shadow = container.attachShadow({ mode: "open" });
const mountedProm = new App(SomeComponent).mount(shadow);
expect(comp!.div.el).toBe(null);
await mountedProm;
expect(comp!.div.el).toBe(shadow.querySelector(".my-div"));
});
});
+4
View File
@@ -92,6 +92,10 @@ export function isLinkValid(link: MarkDownLink, current: FileData, files: FileDa
// no check on external links
return true;
}
if (current.name.endsWith(".png")) {
// no check on png files
return true;
}
// Step 1: extract path, name, hash
// path = ['doc', 'architecture]
// name = 'rendering.md'
+41 -7
View File
@@ -145,17 +145,51 @@ describe("validateSchema", () => {
const schema: Schema = { p: { type: Object, shape: { id: Number, url: String } } };
expect(validateSchema({ p: [] }, schema)).toEqual(["'p' is not an object"]);
expect(validateSchema({ p: {} }, schema)).toEqual([
"'p' has not the correct shape ('id' is missing (should be a number), 'url' is missing (should be a string))",
"'p' doesn't have the correct shape ('id' is missing (should be a number), 'url' is missing (should be a string))",
]);
expect(validateSchema({ p: { id: 1, url: "asf" } }, schema)).toEqual([]);
expect(validateSchema({ p: { id: 1, url: 1 } }, schema)).toEqual([
"'p' has not the correct shape ('url' is not a string)",
"'p' doesn't have the correct shape ('url' is not a string)",
]);
expect(validateSchema({ p: undefined }, schema)).toEqual([
"'p' is undefined (should be a object)",
]);
});
test("objects with a values schema", () => {
const schema: Schema = {
p: { type: Object, values: { type: Object, shape: { id: Number, url: String } } },
};
expect(validateSchema({ p: [] }, schema)).toEqual(["'p' is not an object"]);
expect(validateSchema({ p: {} }, schema)).toEqual([]);
expect(validateSchema({ p: { id: 1, url: "asf" } }, schema)).toEqual([
"some of the values in 'p' are invalid ('id' is not an object, 'url' is not an object)",
]);
expect(
validateSchema(
{
p: {
a: { id: 1, url: "asf" },
},
},
schema
)
).toEqual([]);
expect(
validateSchema(
{
p: {
a: { id: 1, url: "asf" },
b: { id: 1, url: 1 },
},
},
schema
)
).toEqual([
"some of the values in 'p' are invalid ('b' doesn't have the correct shape ('url' is not a string))",
]);
});
test("objects with more complex shape", () => {
const schema: Schema = {
p: {
@@ -168,10 +202,10 @@ describe("validateSchema", () => {
};
expect(validateSchema({ p: [] }, schema)).toEqual(["'p' is not an object"]);
expect(validateSchema({ p: {} }, schema)).toEqual([
"'p' has not the correct shape ('id' is missing (should be a number), 'url' is missing (should be a boolean or list of numbers))",
"'p' doesn't have the correct shape ('id' is missing (should be a number), 'url' is missing (should be a boolean or list of numbers))",
]);
expect(validateSchema({ p: { id: 1, url: "asf" } }, schema)).toEqual([
"'p' has not the correct shape ('url' is not a boolean or list of numbers)",
"'p' doesn't have the correct shape ('url' is not a boolean or list of numbers)",
]);
expect(validateSchema({ p: { id: 1, url: true } }, schema)).toEqual([]);
expect(validateSchema({ p: undefined }, schema)).toEqual([
@@ -183,11 +217,11 @@ describe("validateSchema", () => {
const schema: Schema = { p: { type: Object, shape: { id: Number, "*": true } } };
expect(validateSchema({ p: [] }, schema)).toEqual(["'p' is not an object"]);
expect(validateSchema({ p: {} }, schema)).toEqual([
"'p' has not the correct shape ('id' is missing (should be a number))",
"'p' doesn't have the correct shape ('id' is missing (should be a number))",
]);
expect(validateSchema({ p: { id: 1 } }, schema)).toEqual([]);
expect(validateSchema({ p: { id: "asdf" } }, schema)).toEqual([
"'p' has not the correct shape ('id' is not a number)",
"'p' doesn't have the correct shape ('id' is not a number)",
]);
expect(validateSchema({ p: { id: 1, url: 1 } }, schema)).toEqual([]);
expect(validateSchema({ p: undefined }, schema)).toEqual([
@@ -222,7 +256,7 @@ describe("validateSchema", () => {
expect(validateSchema({ p: [{}] }, schema)).toEqual([]);
expect(validateSchema({ p: [{ num: 1 }] }, schema)).toEqual([]);
expect(validateSchema({ p: [{ num: true }] }, schema)).toEqual([
"'p[0]' has not the correct shape ('num' is not a number)",
"'p[0]' doesn't have the correct shape ('num' is not a number)",
]);
});
+2 -2
View File
@@ -85,7 +85,7 @@ async function compileTemplates(files) {
const fnName = slugify(name);
try {
const fn = compile(template).toString().replace('anonymous', fnName);
templates.push(`owl.App.registerTemplate("${name}", ${fn});\n`);
templates.push(`"${name}": ${fn},\n`);
} catch (e) {
errors.push({ name, fileName, e });
}
@@ -99,7 +99,7 @@ async function compileTemplates(files) {
}
console.log(`${templates.length} templates compiled`);
return templates.join("\n");
return `export const templates = {\n ${templates.join("\n")} \n}`;
}
const templatesPath = process.argv[2];
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+32
View File
@@ -0,0 +1,32 @@
{
"name": "Owl devtools",
"version": "1.0",
"manifest_version": 3,
"description": "Chrome devtools extension for Odoo Owl framework",
"icons": {
"128": "assets/icon128.png"
},
"action": {
"default_icon": {
"128": "assets/icon_disabled128.png"
},
"default_title": "Owl devtools",
"default_popup": "popup_app/popup.html"
},
"permissions": ["scripting", "storage"],
"host_permissions": ["http://*/*", "https://*/*"],
"content_security_policy": {
"script-src": "self",
"object-src": "self"
},
"background": {
"service_worker": "background.js"
},
"devtools_page": "devtools_app/devtools.html",
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"]
}
]
}
+39
View File
@@ -0,0 +1,39 @@
{
"name": "Owl devtools",
"version": "1.0",
"description": "Firefox devtools extension for Odoo Owl framework",
"manifest_version": 2,
"browser_specific_settings": {
"gecko": {
"id": "@owl-devtools",
"strict_min_version": "74.0"
}
},
"icons": {
"128": "assets/icon128.png"
},
"browser_action": {
"default_icon": {
"128": "assets/icon_disabled128.png"
},
"default_title": "Owl devtools",
"default_popup": "popup_app/popup.html"
},
"web_accessible_resources": [
"popup_app/popup.html",
"devtools_app/devtools.html",
"devtools_app/components_panel.html"
],
"permissions": ["storage", "scripting", "<all_urls>"],
"background": {
"scripts": ["background.js"]
},
"devtools_page": "devtools_app/devtools.html",
"content_security_policy": "script-src 'self' 'unsafe-eval' blob:; object-src 'self'",
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"]
}
]
}
+86
View File
@@ -0,0 +1,86 @@
import terser from "rollup-plugin-terser";
import copy from "rollup-plugin-copy";
import execute from "rollup-plugin-execute";
import del from "rollup-plugin-delete";
import { string } from "rollup-plugin-string";
const isWindows = process.platform === "win32";
export default ({ "config-browser": browser, "config-env": env }) => {
const isProduction = env === "production";
const isChrome = browser === "chrome";
const filesToMove = [
{ src: "tools/devtools/assets/**/*", dest: "dist/devtools/assets/" },
{
src: "tools/devtools/src/devtools_app/devtools.html",
dest: "dist/devtools/devtools_app",
},
{
src: "tools/devtools/src/devtools_app/devtools_panel.html",
dest: "dist/devtools/devtools_app",
},
{
src: "tools/devtools/src/page_scripts/owl_devtools_global_hook.js",
dest: "dist/devtools/page_scripts",
},
{ src: "tools/devtools/src/fonts/*", dest: "dist/devtools/fonts/" },
{ src: "tools/devtools/src/popup_app/popup.html", dest: "dist/devtools/popup_app" },
{ src: "tools/devtools/src/background.html", dest: "dist/devtools" },
{ src: "tools/devtools/src/main.css", dest: "dist/devtools/popup_app" },
{ src: "tools/devtools/src/main.css", dest: "dist/devtools/devtools_app" },
{
src: isChrome
? "tools/devtools/manifest-chrome.json"
: "tools/devtools/manifest-firefox.json",
dest: "dist/devtools",
rename: "manifest.json",
},
];
function generateRule(input, format = "esm") {
return {
input: input,
output: [
{
format: format,
file: input.replace("tools/devtools/src", "dist/devtools"),
},
],
plugins: [
string({
include: "**/page_scripts/owl_devtools_global_hook.js",
}),
isProduction && terser.terser(),
],
};
}
const commands = new Array(2);
commands[1] = isWindows
? "npm run compile_templates -- tools\\devtools\\src && move templates.js tools\\devtools\\assets\\templates.js"
: "npm run compile_templates -- tools/devtools/src && mv templates.js tools/devtools/assets/templates.js";
const firstRule = generateRule("tools/devtools/src/page_scripts/owl_devtools_global_hook.js");
if (isProduction) {
commands[0] = isWindows
? "npm run build && copy dist\\owl.iife.js tools\\devtools\\assets\\owl.js && npm run build:compiler"
: "npm run build && cp dist/owl.iife.js tools/devtools/assets/owl.js && npm run build:compiler";
} else {
commands[0] = isWindows
? "copy dist\\owl.iife.js tools\\devtools\\assets\\owl.js"
: "cp dist/owl.iife.js tools/devtools/assets/owl.js";
}
firstRule.plugins.push(execute(commands, true));
const secondRule = generateRule("tools/devtools/src/content.js");
secondRule.plugins.push(copy({ targets: filesToMove }));
const lastRule = generateRule("tools/devtools/src/background.js");
lastRule.plugins.push(del({ targets: "tools/devtools/assets/*.js" }));
return [
firstRule,
secondRule,
generateRule("tools/devtools/src/devtools_app/devtools.js"),
generateRule("tools/devtools/src/utils.js"),
generateRule("tools/devtools/src/devtools_app/devtools_panel.js"),
generateRule("tools/devtools/src/popup_app/popup.js"),
lastRule,
];
};
+1
View File
@@ -0,0 +1 @@
<script type="module" src="./background.js"></script>
+112
View File
@@ -0,0 +1,112 @@
import { IS_FIREFOX, getActiveTabURL } from "./utils";
let owlStatus = 0;
const browserInstance = IS_FIREFOX ? browser : chrome;
// Used to keep track of the tabs where the owl devtools have been opened
const activePanels = new Set();
// Load the devtools global hook this way when running on manifest v3 chrome
if (!IS_FIREFOX) {
chrome.scripting.registerContentScripts([
{
id: "owlDevtoolsGLobalHook",
matches: ["<all_urls>"],
js: ["page_scripts/owl_devtools_global_hook.js"],
world: chrome.scripting.ExecutionWorld.MAIN,
},
]);
}
// Update the owlStatus variable and the extension icon accordingly
function setOwlStatus(status) {
owlStatus = status;
if (IS_FIREFOX) {
browser.browserAction.setIcon({
path: owlStatus === 2 ? "assets/icon128.png" : "assets/icon_disabled128.png",
});
} else {
browserInstance.action.setIcon({
path: owlStatus === 2 ? "assets/icon128.png" : "assets/icon_disabled128.png",
});
}
}
// Delete from the set when the tab is closed
browserInstance.tabs.onRemoved.addListener((tabId) => {
activePanels.delete(tabId);
});
// Check owl status on tab update
browserInstance.tabs.onUpdated.addListener((tab) => {
browserInstance.tabs.get(tab, (tabData) => {
if (tabData.status === "complete") {
setOwlStatus(0);
checkOwlStatus(tabData.id);
}
});
});
// Check owl status on tab activation
browserInstance.tabs.onActivated.addListener((activeInfo) => {
setOwlStatus(0);
checkOwlStatus(activeInfo.tabId);
});
// send a message to the window which will be intercepted by the page script and will result in a response of type owlStatus
function checkOwlStatus(tabId) {
browserInstance.scripting.executeScript({
target: { tabId: tabId },
func: () => {
window.postMessage({
source: "owl-devtools-background",
type: "checkOwlStatus",
});
},
});
}
// Messages handler for the background script
browserInstance.runtime.onMessage.addListener(async (message, sender, sendResponse) => {
// Send back the owl status to the sender
if (message.type === "getOwlStatus") {
sendResponse({ result: owlStatus });
return true;
} else if (message.type === "owlStatus") {
setOwlStatus(message.data);
// Dummy message to test if the extension context is still valid
} else if (message.type === "keepAlive") {
return;
// Open the devtools documentation in a new tab
} else if (message.type === "openDoc") {
browserInstance.tabs.create(
{ url: "https://github.com/odoo/owl/blob/master/doc/tools/devtools_guide.md", active: false },
function (tab) {
browserInstance.tabs.update(tab.id, { active: true });
}
);
return;
// Relay the received message to the devtools app
} else if (message.type === "newDevtoolsPanel") {
const tab = await getActiveTabURL();
activePanels.add(tab);
// This is solely for firefox which doesnt allow access to the chrome.tabs api inside devtools
} else if (message.type === "getActiveTabURL") {
getActiveTabURL().then((tab) => {
sendResponse({ result: tab });
});
return true;
} else {
const tab = await getActiveTabURL();
if (!activePanels.has(tab)) {
return;
}
const port = browserInstance.runtime.connect({ name: "OwlDevtoolsPort" });
port.postMessage(
message.data
? { type: message.type, data: message.data, origin: message.origin }
: { type: message.type, origin: message.origin }
);
}
});
+30
View File
@@ -0,0 +1,30 @@
import globalHook from "./page_scripts/owl_devtools_global_hook";
import { IS_FIREFOX } from "./utils";
// Relays the owlDevtools__... type top window messages to the background script so that it can relay it to the devtools app
window.addEventListener(
"message",
function (event) {
if (event.data.type && event.data.source === "owl-devtools") {
try {
chrome.runtime.sendMessage(
event.data.data
? { type: event.data.type, data: event.data.data, origin: event.data.origin }
: { type: event.data.type, origin: event.data.origin }
);
} catch (e) {
// Extension context invalidated, cannot be handled here since the whole communication system
// inside the extension is dead in this case.
}
}
},
false
);
// Load the devtools global hook this way when running on firefox
if (IS_FIREFOX) {
const script = document.createElement("script");
script.textContent = globalHook;
document.documentElement.appendChild(script);
script.parentNode.removeChild(script);
}
@@ -0,0 +1 @@
<script src="devtools.js"></script>
@@ -0,0 +1,33 @@
import { IS_FIREFOX } from "../utils";
let created = false;
let browserInstance = IS_FIREFOX ? browser : chrome;
// Try to load the owl panel each 1000 ms in case it (re)appears on the page later on
const checkInterval = setInterval(createPanelsIfOwl, 1000);
createPanelsIfOwl();
// Create the owl devtools panel if owl on the page is available at a sufficient version
function createPanelsIfOwl() {
if (created) {
clearInterval(checkInterval);
return;
}
browserInstance.devtools.inspectedWindow.eval(
"window.__OWL_DEVTOOLS__?.Fiber !== undefined;",
async (hasOwl) => {
if (!hasOwl || created) {
return;
}
clearInterval(checkInterval);
created = true;
browserInstance.devtools.panels.create(
"Owl",
"../../assets/icon128.png",
IS_FIREFOX ? "devtools_panel.html" : "devtools_app/devtools_panel.html"
);
browserInstance.runtime.sendMessage({ type: "newDevtoolsPanel" });
}
);
}
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="../assets/font-awesome.min.css">
<link rel="stylesheet" href="../assets/bootstrap.min.css">
<link rel="stylesheet" href="main.css" />
</head>
<body>
<script src="../assets/owl.js"></script>
<script src="devtools_panel.js"></script>
</body>
</html>
@@ -0,0 +1,10 @@
/** @odoo-module **/
import { DevtoolsWindow } from "./devtools_window/devtools_window";
const { mount } = owl;
import { templates } from "../../assets/templates.js";
for (const template in templates) {
owl.App.registerTemplate(template, templates[template]);
}
mount(DevtoolsWindow, document.body, { dev: true });
@@ -0,0 +1,24 @@
import { useStore } from "../../../store/store";
const { Component } = owl;
export class ComponentSearchBar extends Component {
static template = "devtools.ComponentSearchBar";
setup() {
this.store = useStore();
}
updateSearch(event) {
if (event.key !== "Enter") {
this.store.updateSearch(event.target.value);
}
}
// Go to the next search result repeatedly while enter is pressed
onSearchKeyDown(event) {
if (event.key === "Enter") {
this.store.componentSearch.getNextSearch();
}
}
}
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="devtools.ComponentSearchBar" owl="1">
<div class="pointer-icon ms-1 px-2 py-1" t-on-click.stop='() => this.store.toggleSelector()'>
<i title="Select an element in the page to inspect the corresponding component" class="fa fa-mouse-pointer" t-attf-style="color: {{store.componentSearch.activeSelector ? 'var(--active-icon)' : 'var(--text-color)'}};"></i>
</div>
<div class="icons-separator"/>
<div class="d-flex align-items-center ms-2 flex-grow-1">
<i class="fa fa-search search-icon" aria-hidden="true"></i>
<input type="text" class="search-input ms-1 w-100 border-0 h-100" placeholder="Search" t-on-keyup.stop="updateSearch" t-on-keydown.stop="onSearchKeyDown" t-att-value="store.componentSearch.search"/>
<t t-if="store.componentSearch.search.length > 0">
<t t-esc="store.componentSearch.searchResults.length ? store.componentSearch.searchIndex + 1 : 0"/>|<t t-esc="store.componentSearch.searchResults.length"/>
<i class="fa fa-angle-up lg-icon utility-icon ms-1 p-1" t-on-click.stop="() => this.store.componentSearch.getPrevSearch()"></i>
<i class="fa fa-angle-down lg-icon utility-icon p-1" t-on-click.stop="() => this.store.componentSearch.getNextSearch()"></i>
<i class="fa fa-times lg-icon utility-icon p-1 me-2" t-on-click.stop='() => this.store.updateSearch("")'></i>
</t>
</div>
</t>
</templates>
@@ -0,0 +1,67 @@
/** @odoo-module **/
const { Component, onWillUnmount, useExternalListener } = owl;
import { TreeElement } from "./tree_element/tree_element";
import { DetailsWindow } from "./details_window/details_window";
import { ComponentSearchBar } from "./component_search_bar/component_search_bar";
import { useStore } from "../../store/store";
export class ComponentsTab extends Component {
static template = "devtools.ComponentsTab";
static components = { TreeElement, DetailsWindow, ComponentSearchBar };
setup() {
this.store = useStore();
this.flushRendersTimeout = false;
useExternalListener(document, "keydown", this.onKeyboardEvent);
onWillUnmount(() => {
window.removeEventListener("mousemove", this.onMouseMove);
window.removeEventListener("mouseup", this.onMouseUp);
});
}
// Apply the right action depending on which arrow key is pressed (on keydown)
onKeyboardEvent(event) {
switch (event.key) {
case "ArrowLeft":
this.store.toggleOrSelectPrevElement(true);
event.preventDefault();
break;
case "ArrowUp":
this.store.toggleOrSelectPrevElement(false);
event.preventDefault();
break;
case "ArrowRight":
this.store.toggleOrSelectNextElement(true);
event.preventDefault();
break;
case "ArrowDown":
this.store.toggleOrSelectNextElement(false);
event.preventDefault();
break;
}
}
onMouseDown = () => {
// Add event listeners for mouse move and mouse up events
// to allow the user to drag the split screen border
window.addEventListener("mousemove", this.onMouseMove);
window.addEventListener("mouseup", this.onMouseUp);
};
// Adjust the position of the split between the left and right right window of the components tab
onMouseMove = (event) => {
this.store.splitPosition = Math.max(
Math.min((event.clientX / window.innerWidth) * 100, 85),
15
);
};
onMouseUp = () => {
// Remove the event listeners when the user releases the mouse button
window.removeEventListener("mousemove", this.onMouseMove);
window.removeEventListener("mouseup", this.onMouseUp);
};
}
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="devtools.ComponentsTab" owl="1">
<t t-if="store.apps.length === 0">
<div class="status-message d-flex justify-content-center align-items-center">
There are no apps currently running.
</div>
</t>
<t t-else="">
<div class="position-relative overflow-hidden d-flex flex-row h-100">
<div class="split-screen-left d-flex flex-column" t-attf-style="width:{{store.splitPosition}}%;">
<div class="panel-top d-flex align-items-center">
<ComponentSearchBar/>
</div>
<div class="overflow-auto h-100 font-monospace">
<div id="tree-wrapper">
<t t-foreach="store.apps" t-as="app" t-key="app_index">
<TreeElement component="app"/>
</t>
</div>
</div>
</div>
<div
class="split-screen-border user-select-none"
t-on-mousedown="onMouseDown"
/>
<div class="split-screen-right d-flex flex-column font-monospace" t-attf-style="width:{{100 - store.splitPosition}}%;">
<DetailsWindow t-if="store.activeComponent"/>
<t t-else="">
<div class="status-message d-flex justify-content-center align-items-center">
There was an error while processing this component.
</div>
</t>
</div>
</div>
</t>
</t>
</templates>
@@ -0,0 +1,33 @@
const { Component, useRef, useEffect } = owl;
import { useStore } from "../../../store/store";
import { ObjectTreeElement } from "./object_tree_element/object_tree_element";
import { Subscriptions } from "./subscriptions/subscriptions";
export class DetailsWindow extends Component {
static template = "devtools.DetailsWindow";
static components = { ObjectTreeElement, Subscriptions };
setup() {
this.store = useStore();
this.contextMenu = useRef("contextmenu");
this.contextMenuId = this.store.contextMenu.id++;
this.contextMenuEvent;
// Open the context menu when the ids match
useEffect(
(menuId) => {
if (menuId === this.contextMenuId) {
this.store.contextMenu.open(this.contextMenuEvent, this.contextMenu.el);
}
},
() => [this.store.contextMenu.activeMenu]
);
}
openMenu(ev) {
this.contextMenuEvent = ev;
this.store.contextMenu.activeMenu = this.contextMenuId;
}
toggleCategory(ev, category) {
this.store.activeComponent[category].toggled = !this.store.activeComponent[category].toggled;
}
}
@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="devtools.DetailsWindow" owl="1">
<div class="panel-top d-flex align-items-center">
<div class="ms-1 p-1 text-truncate" style="width: 100%;">
<span t-if="store.activeComponent.path.length > 1">&lt;</span>
<b style="color: var(--component-color); cursor: pointer;" t-on-mouseover.stop="() => this.store.highlightComponent(this.store.activeComponent.path)" t-on-click.stop="() => this.store.focusSelectedComponent()" t-on-contextmenu.prevent="openMenu" t-esc="store.activeComponent.name"/>
<span t-if="store.activeComponent.path.length > 1">&gt;</span>
<span class="version" t-else="">owl=<t t-esc="store.activeComponent.version"/></span>
</div>
<t t-if="store.activeComponent.path.length > 1">
<i title="Inspect component in the Elements tab" class="fa fa-eye utility-icon p-1" t-on-click.stop="() => this.store.inspectComponent('DOM')"></i>
<i title="Store component as global variable in the console" class="fa fa-bug utility-icon p-1" t-on-click.stop="() => this.store.logObjectInConsole()"></i>
<i title="Inspect source code of component" class="fa fa-file-code-o utility-icon p-1" t-on-click.stop="() => this.store.inspectComponent('source')"></i>
<i title="Trigger rerender of the component" class="fa fa-refresh utility-icon p-1" t-on-click.stop="() => this.store.refreshComponent()"></i>
</t>
<t t-else="">
<i title="Store app as global variable in the console" class="fa fa-bug utility-icon p-1" t-on-click.stop="() => this.store.logObjectInConsole()"></i>
<i title="Inspect source code of the app" class="fa fa-file-code-o utility-icon p-1" t-on-click.stop="() => this.store.inspectComponent('source')"></i>
</t>
</div>
<div class="details-container">
<div t-if="store.activeComponent.env.children.length > 0" id="env" class="details-panel ps-2 py-1">
<div class="d-flex mb-2">
<div class="w-100" t-on-click.stop="(ev) => this.toggleCategory(ev, 'env')">
<i class="fa mx-1 pointer-icon"
t-att-class="{'fa-caret-right': !store.activeComponent.env.toggled, 'fa-caret-down': store.activeComponent.env.toggled}"
/><b>env</b>
</div>
</div>
<t t-if="store.activeComponent.env.toggled" t-foreach="store.activeComponent.env.children" t-as="env" t-key="env_index">
<ObjectTreeElement object="env"/>
</t>
</div>
<div t-if="store.activeComponent.props.children.length > 0" class="details-panel ps-2 py-1">
<div class="d-flex mb-2">
<div class="w-100" t-on-click.stop="(ev) => this.toggleCategory(ev, 'props')">
<i class="fa mx-1 pointer-icon"
t-att-class="{'fa-caret-right': !store.activeComponent.props.toggled, 'fa-caret-down': store.activeComponent.props.toggled}"
/><b>props</b>
</div>
</div>
<t t-if="store.activeComponent.props.toggled" t-foreach="store.activeComponent.props.children" t-as="prop" t-key="prop_index">
<ObjectTreeElement object="prop"/>
</t>
</div>
<div t-if="store.activeComponent.subscriptions.children.length > 0" id="subscriptions" class="details-panel ps-2 py-1">
<div class="d-flex">
<div title="Shows all the targets that will trigger a re-render of the component when one of its associated keys is modified" class="w-100 text-truncate" t-on-click.stop="(ev) => this.toggleCategory(ev, 'subscriptions')">
<i class="fa mx-1 pointer-icon"
t-att-class="{'fa-caret-right': !store.activeComponent.subscriptions.toggled, 'fa-caret-down': store.activeComponent.subscriptions.toggled}"
/><b>observed state</b>
</div>
<i title="Store observed states as global variable in the console" class="fa fa-bug utility-icon p-1" t-on-click.stop="() => this.store.logObjectInConsole([...this.store.activeComponent.path, {type: 'item', value: 'subscriptions'}])"></i>
</div>
<Subscriptions t-if="store.activeComponent.subscriptions.toggled"/>
</div>
<div t-if="store.activeComponent.instance.children.length > 0" id="instance" class="details-panel ps-2 py-1">
<div class="d-flex mb-2">
<div class="w-100 text-truncate" t-on-click.stop="(ev) => this.toggleCategory(ev, 'instance')">
<i class="fa mx-1 pointer-icon"
t-att-class="{'fa-caret-right': !store.activeComponent.instance.toggled, 'fa-caret-down': store.activeComponent.instance.toggled}"
/><b>instance</b>
</div>
<t t-if="store.activeComponent.path.length > 1">
<i title="Inspect compiled template" class="fa fa-hashtag utility-icon p-1" t-on-click.stop="() => this.store.inspectComponent('compiled template')"></i>
<i title="Send raw template to console" class="fa fa-file-word-o utility-icon p-1" t-on-click.stop="() => this.store.inspectComponent('raw template')"></i>
</t>
</div>
<t t-if="store.activeComponent.instance.toggled" t-foreach="store.activeComponent.instance.children" t-as="instance" t-key="instance_index">
<ObjectTreeElement object="instance"/>
</t>
</div>
</div>
<div t-if="store.contextMenu.activeMenu === contextMenuId" class="custom-menu" t-attf-style="top: {{store.contextMenu.top}}; left: {{store.contextMenu.left}}" t-ref="contextmenu">
<ul class="my-1">
<li t-on-click.stop="() => this.store.inspectComponent('source', store.activeComponent.path)" class="custom-menu-item py-1 px-4">Inspect source code</li>
<t t-if="store.activeComponent.path.length !== 1">
<li t-on-click.stop="() => this.store.logObjectInConsole([...store.activeComponent.path, { type: 'item', value: 'component'}])" class="custom-menu-item py-1 px-4">Store as global variable</li>
<li t-on-click.stop="() => this.store.inspectComponent('DOM', store.activeComponent.path)" class="custom-menu-item py-1 px-4">Inspect in Elements tab</li>
<li t-on-click.stop="() => this.store.refreshComponent(store.activeComponent.path)" class="custom-menu-item py-1 px-4">Force rerender</li>
<li t-on-click.stop="() => this.store.logObjectInConsole([...store.activeComponent.path, { type: 'item', value: 'subscriptions'}])" class="custom-menu-item py-1 px-4">Store observed states as global variable</li>
<li t-on-click.stop="() => this.store.inspectComponent('compiled template', store.activeComponent.path)" class="custom-menu-item py-1 px-4">Inspect compiled template</li>
<li t-on-click.stop="() => this.store.inspectComponent('raw template', store.activeComponent.path)" class="custom-menu-item py-1 px-4">Log raw template</li>
</t>
<t t-else="">
<li t-on-click.stop="() => this.store.logObjectInConsole([...store.activeComponent.path])" class="custom-menu-item py-1 px-4">Store as global variable</li>
</t>
</ul>
</div>
</t>
</templates>
@@ -0,0 +1,87 @@
import { useStore } from "../../../../store/store";
const { Component, useState, useEffect, useRef } = owl;
export class ObjectTreeElement extends Component {
static template = "devtools.ObjectTreeElement";
static components = { ObjectTreeElement };
setup() {
this.state = useState({
editMode: false,
menuTop: 0,
menuLeft: 0,
});
this.contextMenu = useRef("contextmenu");
const inputRef = useRef("input");
this.store = useStore();
this.contextMenuId = this.store.contextMenu.id++;
this.contextMenuEvent,
useEffect(
(menuId) => {
if (menuId === this.contextMenuId) {
this.store.contextMenu.open(this.contextMenuEvent, this.contextMenu.el);
}
},
() => [this.store.contextMenu.activeMenu]
);
useEffect(
(editMode) => {
// Focus on the input when it is created
if (editMode) {
inputRef.el.select();
}
},
() => [this.state.editMode]
);
}
get pathAsString() {
return JSON.stringify(this.props.object.path);
}
get objectName() {
return this.props.object.name;
}
get objectLineClass() {
// Prototype items will be dyed down to appear less important
if (this.pathAsString.includes('{"type":"prototype",')) {
return { attenuate: true };
}
// Same for subscription items which are not present in the keys while the keys will be bold
if (this.props.object.objectType === "subscription" && this.props.object.depth > 0) {
if (this.props.keys.includes(this.props.object.name.toString())) {
return { "fw-bolder": true };
}
return { attenuate: true };
}
return {};
}
get objectPadding() {
return this.props.object.depth * 0.8 + 0.3;
}
openMenu(ev) {
this.contextMenuEvent = ev;
this.store.contextMenu.activeMenu = this.contextMenuId;
}
setupEditMode() {
if (!this.state.editMode) {
if (!this.props.object.hasChildren) {
this.state.editMode = true;
}
}
}
editObject(ev) {
let value = ev.target.value;
if (ev.keyCode === 13 && value !== "") {
this.store.editObjectTreeElement(this.props.object.path, value, this.props.object.objectType);
this.state.editMode = false;
}
}
}
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="devtools.ObjectTreeElement" owl="1">
<div class="m-0 p-0 text-nowrap w-100 object-line"
t-att-class="objectLineClass"
t-on-click.stop="() => this.store.toggleObjectTreeElementsDisplay(this.props.object)"
t-on-contextmenu.prevent="openMenu"
>
<div t-attf-style="padding-left: {{objectPadding}}rem">
<i class="fa px-1 pointer-icon caret"
t-att-class="{'fa-caret-right': !props.object.toggled, 'fa-caret-down': props.object.toggled}"
t-attf-style="visibility: {{props.object.hasChildren ? '' : 'hidden'}};"
/>
<t t-esc="objectName"/>
<t t-if="props.object.content.length > 0">: </t>
<t t-if="props.object.contentType == 'getter'">
<span class="getter-content object-content" t-att-class="objectLineClass" t-on-click.stop="() => this.store.loadGetterContent(this.props.object)">
<t t-esc="props.object.content"/>
</span>
</t>
<t t-else="">
<span class="object-content" t-att-class="objectLineClass" t-on-dblclick.stop="setupEditMode">
<t t-if="state.editMode">
<input t-attf-id="objectEditionInput/{{pathAsString}}" t-ref="input" type="text" placeholder="" t-att-value="props.object.content" t-on-keydown.stop="editObject"/>
</t>
<t t-else="">
<t t-esc="props.object.content"/>
</t>
</span>
</t>
</div>
</div>
<div t-if="store.contextMenu.activeMenu === contextMenuId" class="custom-menu" t-attf-style="top: {{store.contextMenu.top}}; left: {{store.contextMenu.left}}" t-ref="contextmenu">
<ul class="my-1">
<li t-on-click="() => this.store.logObjectInConsole(this.props.object.path)" class="custom-menu-item py-1 px-4 text-nowrap">Store as global variable</li>
<t t-if='props.object.contentType == "function"'>
<li t-on-click="() => this.store.inspectFunctionSource(this.props.object.path)" class="custom-menu-item py-1 px-4 text-nowrap">Inspect function source code</li>
</t>
</ul>
</div>
<t t-if="props.object.toggled">
<t t-foreach="props.object.children" t-as="child" t-key="child.name">
<ObjectTreeElement t-if="props.object.objectType === 'subscription'" object="child" keys="props.keys"/>
<ObjectTreeElement t-else="" object="child"/>
</t>
</t>
</t>
</templates>
@@ -0,0 +1,30 @@
const { Component } = owl;
import { useStore } from "../../../../store/store";
import { ObjectTreeElement } from "../object_tree_element/object_tree_element";
export class Subscriptions extends Component {
static template = "devtools.Subscriptions";
static components = { ObjectTreeElement };
setup() {
this.store = useStore();
}
// Used to display the keys in a compact way
keysContent(index) {
const keys = this.store.activeComponent.subscriptions.children[index].keys;
let content = JSON.stringify(keys);
const maxLength = 50;
content = content.replace(/,/g, ", ");
if (content.length > maxLength) {
content = content.slice(0, content.lastIndexOf(",", maxLength - 5)) + ", ...]";
}
return content;
}
expandKeys(event, index) {
this.store.activeComponent.subscriptions.children[index].keysExpanded =
!this.store.activeComponent.subscriptions.children[index].keysExpanded;
}
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="devtools.Subscriptions" owl="1">
<div id="subscriptionsPanel">
<t t-foreach="store.activeComponent.subscriptions.children" t-as="subscription" t-key="subscription_index">
<div class="my-2">
<div class="my-0 p-0 object-line" t-on-click.stop="(ev) => this.expandKeys(ev, subscription_index)">
<span class="ps-1 text-nowrap">
<i class="fa fa-caret-right ms-1" t-attf-style="cursor: pointer;{{subscription.keysExpanded ? 'transform: rotate(90deg);' : ''}}"></i>
keys: <span class="key-name"><t t-esc="this.keysContent(subscription_index)"/></span>
</span>
</div>
<div t-foreach="subscription.keys" t-as="key" t-key="key_index" class="my-0 p-0 object-line" t-attf-style="display: {{subscription.keysExpanded ? 'flex' : 'none'}}">
<div style="transform: translateX(calc(1.1rem))" class="key-content">
<i class="fa fa-caret-right mx-1" t-attf-style="cursor: pointer; visibility: hidden;"></i>
<t t-esc="key"/>
</div>
</div>
<ObjectTreeElement object="subscription.target" keys="subscription.keys"/>
</div>
</t>
</div>
</t>
</templates>
@@ -0,0 +1,50 @@
/** @odoo-module **/
const { Component, onWillRender } = owl;
export class HighlightText extends Component {
setup() {
onWillRender(() => {
const splitText = this.splitFuzzySearch(this.props.originalText, this.props.searchValue);
this.splitText =
this.props.searchValue.length && splitText.length > 1
? splitText
: [this.props.originalText];
});
}
// Logic to split the text to highlight it according to a fuzzy search pattern
splitFuzzySearch(text, search) {
if (!search || search.length === 0) {
return [text];
}
let splits = [""];
let searchIndex = 0;
for (const letter of text) {
if (
!(searchIndex >= search.length) &&
(letter === search[searchIndex] || letter === search[searchIndex].toUpperCase())
) {
if (splits.length % 2) {
splits.push(letter);
} else {
splits[splits.length - 1] += letter;
}
searchIndex++;
} else {
if (splits.length % 2) {
splits[splits.length - 1] += letter;
} else {
splits.push(letter);
}
}
}
return splits;
}
}
HighlightText.template = "utils.HighlightText";
HighlightText.props = {
originalText: String,
searchValue: String,
};
HighlightText.highlightClass = "highlight-search";
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="utils.HighlightText" owl="1">
<t t-foreach="splitText" t-as="name" t-key="name_index">
<b t-if="name_index % 2" t-esc="name" t-att-class="constructor.highlightClass"/>
<t t-else="" t-esc="name"/>
</t>
</t>
</templates>
@@ -0,0 +1,108 @@
/** @odoo-module **/
import { isElementInCenterViewport, minimizeKey } from "../../../../utils";
import { useStore } from "../../../store/store";
import { HighlightText } from "./highlight_text/highlight_text";
const { Component, useRef, useState, useEffect, onMounted } = owl;
export class TreeElement extends Component {
static template = "devtools.TreeElement";
static components = { TreeElement, HighlightText };
setup() {
this.state = useState({
searched: false,
});
this.store = useStore();
this.contextMenu = useRef("contextmenu");
this.element = useRef("element");
this.contextMenuId = this.store.contextMenu.id++;
this.contextMenuEvent;
this.stringifiedPath = JSON.stringify(this.props.component.path);
// Scroll to the selected element when it changes
onMounted(() => {
if (this.props.component.selected) {
this.element.el.scrollIntoView({ block: "center", behavior: "auto" });
}
});
useEffect(
(selected) => {
if (selected) {
if (!isElementInCenterViewport(this.element.el)) {
this.element.el.scrollIntoView({ block: "center", behavior: "smooth" });
}
}
this.store.selectedElement = this.element.el;
},
() => [this.props.component.selected]
);
// Open the context menu when the ids match
useEffect(
(menuId) => {
if (menuId === this.contextMenuId) {
this.store.contextMenu.open(this.contextMenuEvent, this.contextMenu.el);
}
},
() => [this.store.contextMenu.activeMenu]
);
// Effect to apply a short highlight effect to the component when it is rendered
useEffect(
() => {
if (this.store.renderPaths.has(this.stringifiedPath)) {
const treeElement = this.element.el;
treeElement.classList.add("render-highlight");
setTimeout(() => {
treeElement.classList.add("highlight-fade");
treeElement.classList.remove("render-highlight");
setTimeout(() => {
treeElement.classList.remove("highlight-fade");
this.blockHighlight = false;
}, 500);
}, 50);
}
},
() => [this.store.renderPaths.size]
);
// Used to know when the component is in the search bar results
useEffect(
(searchResults) => {
if (searchResults.includes(this.props.component.path)) {
this.state.searched = true;
} else {
this.state.searched = false;
}
},
() => [this.store.componentSearch.searchResults]
);
}
get componentPadding() {
return this.props.component.depth * 0.8;
}
get minimizedKey() {
return minimizeKey(this.props.component.key);
}
openMenu(ev) {
this.contextMenuEvent = ev;
this.store.contextMenu.activeMenu = this.contextMenuId;
}
// Expand/fold the component node
toggleDisplay() {
this.props.component.toggled = !this.props.component.toggled;
}
// Used to select the component node
toggleComponent() {
if (this.store.settings.toggleOnSelected) {
this.toggleDisplay();
}
if (!this.props.component.selected) {
this.store.selectComponent(this.props.component.path);
}
}
}
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="devtools.TreeElement" owl="1">
<div t-ref="element"
t-att-class="{'component-selected': props.component.selected,'component-highlighted': props.component.highlighted}"
class="tree-component m-0 p-0 w-100 text-nowrap user-select-none"
t-on-contextmenu.prevent="openMenu"
t-on-mouseover.stop="() => this.store.highlightComponent(props.component.path)"
t-on-click.stop="toggleComponent"
>
<div class="component-wrapper" t-attf-style="padding-left: {{componentPadding}}rem">
<i class="fa px-1 pointer-icon caret"
t-att-class="{'fa-caret-right': !props.component.toggled, 'fa-caret-down': props.component.toggled}"
t-on-click.stop="toggleDisplay"
t-attf-style="{{props.component.children.length > 0 ? '' : 'visibility: hidden;'}}"
/>
<span t-if="props.component.depth">&lt;</span>
<span style="color: var(--component-color);">
<HighlightText originalText="props.component.name" searchValue="state.searched ? store.componentSearch.search : ''"/>
<t t-if="minimizedKey.length > 0">
<span t-if="minimizedKey.length > 0" style="color: var(--key-name);"> key</span>=<span style="color: var(--key-content);">
<t t-esc="minimizedKey"/>
</span>
</t>
</span>
<span t-if="props.component.depth">&gt;</span>
<span class="version" t-else="">owl=<t t-esc="props.component.version"/></span>
</div>
<div t-if="store.contextMenu.activeMenu === contextMenuId" class="custom-menu" t-attf-style="top: {{store.contextMenu.top}}; left: {{store.contextMenu.left}}" t-ref="contextmenu">
<ul class="my-1">
<li t-on-click.stop="() => this.store.toggleComponentAndChildren(props.component, true)" class="custom-menu-item py-1 px-4">Expand children</li>
<li t-on-click.stop="() => this.store.toggleComponentAndChildren(props.component, false)" class="custom-menu-item py-1 px-4">Fold all children</li>
<li t-on-click.stop="() => this.store.foldDirectChildren(props.component)" class="custom-menu-item py-1 px-4">Fold direct children</li>
<li t-on-click.stop="() => this.store.inspectComponent('source', props.component.path)" class="custom-menu-item py-1 px-4">Inspect source code</li>
<t t-if="props.component.path.length !== 1">
<li t-on-click.stop="() => this.store.logObjectInConsole([...props.component.path, { type: 'item', value: 'component'}])" class="custom-menu-item py-1 px-4">Store as global variable</li>
<li t-on-click.stop="() => this.store.inspectComponent('DOM', props.component.path)" class="custom-menu-item py-1 px-4">Inspect in Elements tab</li>
<li t-on-click.stop="() => this.store.refreshComponent(props.component.path)" class="custom-menu-item py-1 px-4">Force rerender</li>
<li t-on-click.stop="() => this.store.logObjectInConsole([...props.component.path, { type: 'item', value: 'subscriptions'}])" class="custom-menu-item py-1 px-4">Store observed states as global variable</li>
<li t-on-click.stop="() => this.store.inspectComponent('compiled template', props.component.path)" class="custom-menu-item py-1 px-4">Inspect compiled template</li>
<li t-on-click.stop="() => this.store.inspectComponent('raw template', props.component.path)" class="custom-menu-item py-1 px-4">Log raw template</li>
</t>
<t t-else="">
<li t-on-click.stop="() => this.store.logObjectInConsole([...props.component.path])" class="custom-menu-item py-1 px-4">Store as global variable</li>
</t>
</ul>
</div>
</div>
<t t-if="props.component.toggled">
<t t-foreach="props.component.children" t-as="child" t-key="child.key">
<TreeElement component="child_value"/>
</t>
</t>
</t>
</templates>
@@ -0,0 +1,19 @@
const { Component } = owl;
import { ComponentsTab } from "./components_tab/components_tab";
import { Tab } from "./tab/tab";
import { ProfilerTab } from "./profiler_tab/profiler_tab";
import { useStore } from "../store/store";
export class DevtoolsWindow extends Component {
static props = [];
static template = "devtools.DevtoolsWindow";
static components = { ComponentsTab, Tab, ProfilerTab };
setup() {
this.store = useStore();
}
selectFrame(ev) {
const val = ev.target.value;
this.store.selectFrame(val);
}
}
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="devtools.DevtoolsWindow" owl="1">
<div id="container" class="d-flex w-100 h-100 flex-column position-absolute overflow-hidden" t-on-mouseover.stop="() => this.store.removeHighlights()" t-on-mouseout.stop="() => this.store.removeHighlights()">
<t t-if="!store.extensionContextStatus">
<div class="status-message d-flex justify-content-center align-items-center">
Extension context is invalid. Please close the devtools and reload the page.
</div>
</t>
<t t-elif="store.owlStatus">
<div class="panel-top d-flex align-items-center custom-navbar">
<Tab tabName="'ComponentsTab'"/>
<Tab tabName="'ProfilerTab'"/>
<select t-if="store.frameUrls.length > 1" class="form-select form-select-sm custom-select navbar-select border-0" t-on-change="selectFrame">
<t t-foreach="store.frameUrls" t-as="frame" t-key="frame_index">
<option t-att-value="frame"><t t-esc="frame"/></option>
</t>
</select>
<i class="ms-auto p-1 me-1 lg-icon fa fa-question-circle pointer-icon" title="Open devtools doc" t-on-click.stop="() => this.store.openDocumentation()"></i>
<i class="p-1 me-1 lg-icon fa pointer-icon" title="Toggle dark mode" t-att-class="{ 'fa-sun-o': store.settings.darkMode, 'fa-moon-o' : !store.settings.darkMode}" t-on-click.stop="() => this.store.toggleDarkMode()"></i>
<i class="p-1 me-1 lg-icon fa fa-repeat pointer-icon" title="Refresh extension" t-on-click.stop="() => this.store.refreshExtension()"></i>
</div>
<ComponentsTab t-if="store.page === 'ComponentsTab'"/>
<ProfilerTab t-if="store.page === 'ProfilerTab'"/>
</t>
<t t-else="">
<div class="status-message d-flex justify-content-center align-items-center">
Owl is not loaded on this page.
</div>
</t>
</div>
</t>
</templates>
@@ -0,0 +1,66 @@
import { minimizeKey } from "../../../../utils";
import { useStore } from "../../../store/store";
const { Component, useEffect, useRef } = owl;
export class Event extends Component {
static template = "devtools.Event";
setup() {
this.store = useStore();
this.componentContextMenu = useRef("componentContextmenu");
this.componentContextMenuId = this.store.contextMenu.id++;
this.contextMenuEvent,
useEffect(
(menuId) => {
if (menuId === this.componentContextMenuId) {
this.store.contextMenu.open(this.contextMenuEvent, this.componentContextMenu.el);
}
},
() => [this.store.contextMenu.activeMenu]
);
}
// Formatting for displaying the key of the component
get minimizedKey() {
return minimizeKey(this.props.event.key);
}
// Same for the origin component
get originMinimizedKey() {
return minimizeKey(this.props.event.origin.key);
}
get renderTime() {
if (Number.isInteger(this.props.event.time)) {
if (this.props.event.time === 0) {
return "<1";
} else {
return this.props.event.time;
}
} else {
if (this.props.event.time < 0.1) {
return "<0.1";
} else {
return this.props.event.time.toFixed(1);
}
}
}
// Expand/fold the event
toggleDisplay() {
if (this.props.event.origin) {
this.props.event.toggled = !this.props.event.toggled;
}
}
openComponentMenu(ev) {
if (this.props.event.type === "destroy") {
return;
} else {
ev.preventDefault();
this.contextMenuEvent = ev;
this.store.contextMenu.activeMenu = this.componentContextMenuId;
}
}
}
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="devtools.Event" owl="1">
<div class="event-container">
<div class="my-0 p-0 object-line" t-on-click.stop="toggleDisplay">
<div class="ps-2 text-nowrap">
<i class="fa px-1 pointer-icon caret"
t-att-class="{'fa-caret-right': !props.event.toggled, 'fa-caret-down': props.event.toggled}"
t-attf-style="visibility: {{props.event.origin ? '' : 'hidden'}};"
/>
<t t-esc="props.event.type"/>:
&lt;<span style="cursor:pointer; color: var(--component-color);"
t-on-click.stop="() => this.store.selectComponent(props.event.path)"
t-on-mouseover.stop="() => this.store.highlightComponent(props.event.path)"
t-on-contextmenu="openComponentMenu"
t-esc="props.event.component"
/>
<t t-if="minimizedKey.length > 0">
<span t-if="minimizedKey.length > 0" style="color: var(--key-name);"> key</span>=<span style="color: var(--key-content);">
<t t-esc="minimizedKey"/>
</span>
</t>&gt;
<span>
(<t t-esc="renderTime"/>ms)
</span>
</div>
</div>
<t t-if="props.event.toggled">
<div class="my-0 pt-1 object-line">
<i class="fa fa-caret-right mx-1 pe-2" style="visibility: hidden;"></i>
<span>
origin:
&lt;<span style="cursor:pointer; color: var(--component-color);"
t-on-click.stop="() => this.store.selectComponent(props.event.origin.path)"
t-on-mouseover.stop="() => this.store.highlightComponent(props.event.origin.path)"
t-esc="props.event.origin.component"
/>
<t t-if="originMinimizedKey.length > 0">
<span style="color: var(--key-name);"> key</span>=<span style="color: var(--key-content);">
<t t-esc="originMinimizedKey"/>
</span>
</t>&gt;
</span>
</div>
</t>
<div t-if="store.contextMenu.activeMenu === componentContextMenuId" class="custom-menu" t-attf-style="top: {{store.contextMenu.top}}; left: {{store.contextMenu.left}}" t-ref="componentContextmenu">
<ul class="my-1">
<li t-on-click.stop="() => this.store.inspectComponent('source', props.event.path)" class="custom-menu-item py-1 px-4">Inspect source code</li>
<t t-if="props.event.path.length !== 1">
<li t-on-click.stop="() => this.store.logObjectInConsole([...props.event.path, { type: 'item', value: 'component'}])" class="custom-menu-item py-1 px-4">Store as global variable</li>
<li t-on-click.stop="() => this.store.inspectComponent('DOM', props.event.path)" class="custom-menu-item py-1 px-4">Inspect in Elements tab</li>
<li t-on-click.stop="() => this.store.refreshComponent(props.event.path)" class="custom-menu-item py-1 px-4">Force rerender</li>
<li t-on-click.stop="() => this.store.logObjectInConsole([...props.event.path, { type: 'item', value: 'subscriptions'}])" class="custom-menu-item py-1 px-4">Store observed states as global variable</li>
<li t-on-click.stop="() => this.store.inspectComponent('compiled template', props.event.path)" class="custom-menu-item py-1 px-4">Inspect compiled template</li>
<li t-on-click.stop="() => this.store.inspectComponent('raw template', props.event.path)" class="custom-menu-item py-1 px-4">Log raw template</li>
</t>
<t t-else="">
<li t-on-click.stop="() => this.store.logObjectInConsole([...props.event.path])" class="custom-menu-item py-1 px-4">Store as global variable</li>
</t>
</ul>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,78 @@
import { minimizeKey } from "../../../../utils";
import { useStore } from "../../../store/store";
const { Component, useRef, useEffect } = owl;
export class EventNode extends Component {
static template = "devtools.EventNode";
static components = { EventNode };
setup() {
this.store = useStore();
this.nodeContextMenu = useRef("nodeContextMenu");
this.nodeContextMenuId = this.store.contextMenu.id++;
this.componentContextMenu = useRef("componentContextmenu");
this.componentContextMenuId = this.store.contextMenu.id++;
this.contextMenuEvent,
useEffect(
(menuId) => {
if (menuId === this.nodeContextMenuId) {
this.store.contextMenu.open(this.contextMenuEvent, this.nodeContextMenu.el);
}
if (menuId === this.componentContextMenuId) {
this.store.contextMenu.open(this.contextMenuEvent, this.componentContextMenu.el);
}
},
() => [this.store.contextMenu.activeMenu]
);
}
get eventPadding() {
return this.props.event.depth * 0.8 + 0.3;
}
openNodeMenu(ev) {
if (this.props.event.children.length) {
ev.preventDefault();
this.contextMenuEvent = ev;
this.store.contextMenu.activeMenu = this.nodeContextMenuId;
}
}
openComponentMenu(ev) {
if (this.props.event.type === "destroy") {
return;
} else {
ev.preventDefault();
this.contextMenuEvent = ev;
this.store.contextMenu.activeMenu = this.componentContextMenuId;
}
}
// Expand/fold the event node
toggleDisplay() {
this.props.event.toggled = !this.props.event.toggled;
}
// Formatting for displaying the key of the component
get minimizedKey() {
return minimizeKey(this.props.event.key);
}
get renderTime() {
if (Number.isInteger(this.props.event.time)) {
if (this.props.event.time === 0) {
return "<1";
} else {
return this.props.event.time;
}
} else {
if (this.props.event.time < 1.0) {
return "<1";
} else {
return this.props.event.time.toFixed(1);
}
}
}
}
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="devtools.EventNode" owl="1">
<div class="my-0 p-0 object-line"
t-on-click.stop="toggleDisplay"
t-on-contextmenu="openNodeMenu"
>
<div class="text-nowrap" t-attf-style="padding-left: {{eventPadding}}rem">
<i class="fa px-1 pointer-icon caret"
t-att-class="{'fa-caret-right': !props.event.toggled, 'fa-caret-down': props.event.toggled}"
t-attf-style="visibility: {{props.event.children.length > 0 ? '' : 'hidden'}};"
/>
<span>
<t t-esc="props.event.type"/>:
&lt;<span style="cursor:pointer; color: var(--component-color);"
t-on-click.stop="() => this.store.selectComponent(props.event.path)"
t-on-mouseover.stop="() => this.store.highlightComponent(props.event.path)"
t-on-contextmenu.stop="openComponentMenu"
t-esc="props.event.component"/>
<t t-if="minimizedKey.length > 0">
<span t-if="minimizedKey.length > 0" style="color: var(--key-name);"> key</span>=<span style="color: var(--key-content);">
<t t-esc="minimizedKey"/>
</span>
</t>&gt;
<span>
(<t t-esc="renderTime"/>ms)
</span>
</span>
</div>
</div>
<div t-if="store.contextMenu.activeMenu === nodeContextMenuId" class="custom-menu" t-attf-style="top: {{store.contextMenu.top}}; left: {{store.contextMenu.left}}" t-ref="nodeContextMenu">
<ul class="my-1">
<li t-on-click.stop="() => this.store.toggleEventAndChildren(props.event, true)" class="custom-menu-item py-1 px-4">Expand children</li>
<li t-on-click.stop="() => this.store.toggleEventAndChildren(props.event, false)" class="custom-menu-item py-1 px-4">Fold all children</li>
<li t-on-click.stop="() => this.store.foldDirectChildren(props.event)" class="custom-menu-item py-1 px-4">Fold direct children</li>
</ul>
</div>
<div t-if="store.contextMenu.activeMenu === componentContextMenuId" class="custom-menu" t-attf-style="top: {{store.contextMenu.top}}; left: {{store.contextMenu.left}}" t-ref="componentContextmenu">
<ul class="my-1">
<li t-on-click.stop="() => this.store.inspectComponent('source', props.event.path)" class="custom-menu-item py-1 px-4">Inspect source code</li>
<t t-if="props.event.path.length !== 1">
<li t-on-click.stop="() => this.store.logObjectInConsole([...props.event.path, { type: 'item', value: 'component'}])" class="custom-menu-item py-1 px-4">Store as global variable</li>
<li t-on-click.stop="() => this.store.inspectComponent('DOM', props.event.path)" class="custom-menu-item py-1 px-4">Inspect in Elements tab</li>
<li t-on-click.stop="() => this.store.refreshComponent(props.event.path)" class="custom-menu-item py-1 px-4">Force rerender</li>
<li t-on-click.stop="() => this.store.logObjectInConsole([...props.event.path, { type: 'item', value: 'subscriptions'}])" class="custom-menu-item py-1 px-4">Store observed states as global variable</li>
<li t-on-click.stop="() => this.store.inspectComponent('compiled template', props.event.path)" class="custom-menu-item py-1 px-4">Inspect compiled template</li>
<li t-on-click.stop="() => this.store.inspectComponent('raw template', props.event.path)" class="custom-menu-item py-1 px-4">Log raw template</li>
</t>
<t t-else="">
<li t-on-click.stop="() => this.store.logObjectInConsole([...props.event.path])" class="custom-menu-item py-1 px-4">Store as global variable</li>
</t>
</ul>
</div>
<t t-if="props.event.toggled">
<t t-foreach="props.event.children" t-as="child" t-key="child.id">
<EventNode event="child"/>
</t>
</t>
</t>
</templates>
@@ -0,0 +1,23 @@
import { useStore } from "../../../store/store";
const { Component } = owl;
export class EventSearchBar extends Component {
static template = "devtools.EventSearchBar";
setup() {
this.store = useStore();
}
// On keyup
updateSearch(event) {
if (!(event.keyCode === 13)) {
const search = event.target.value;
this.store.updateSearch(search);
}
}
clearSearch() {
this.store.updateSearch("");
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="devtools.EventSearchBar" owl="1">
<div class="search-bar-wrapper">
<i class="fa fa-search search-icon" aria-hidden="true"></i>
<input type="text" class="search-input" placeholder="Search" t-on-keyup.stop="updateSearch" t-att-value="store.eventSearch.search"/>
<t t-if="store.eventSearch.search.length > 0">
<i class="fa fa-times lg-icon utility-icon pe-2" t-on-click.stop="clearSearch"></i>
</t>
</div>
</t>
</templates>
@@ -0,0 +1,29 @@
const { Component } = owl;
import { useStore } from "../../store/store";
import { Event } from "./event/event";
import { EventNode } from "./event_node/event_node";
import { EventSearchBar } from "./event_search_bar/event_search_bar";
export class ProfilerTab extends Component {
static template = "devtools.ProfilerTab";
static components = { Event, EventNode, EventSearchBar };
setup() {
this.store = useStore();
}
showHelp() {
return this.store.events.length < 1 && !this.store.activeRecorder;
}
selectDisplayMode(ev) {
const val = ev.target.value;
if (val === "Tree") {
this.store.buildEventsTree();
this.store.eventsTreeView = true;
} else {
this.store.eventsTreeView = false;
}
}
}
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="devtools.ProfilerTab" owl="1">
<div class="position-relative overflow-hidden d-flex flex-column h-100">
<div class="panel-top d-flex align-items-center">
<i title="Start/Stop Recording" class="fa fa-circle pointer-icon ms-1 p-1" t-attf-style="color: {{store.activeRecorder ? 'var(--active-recorder)' : 'var(--text-color)'}};" t-on-click.stop="() => this.store.toggleRecording()" aria-hidden="true"></i>
<i title="Clear events" class="fa fa-ban pointer-icon p-1 px-2" t-on-click.stop="() => this.store.clearEventsConsole()" aria-hidden="true"></i>
<div class="icons-separator mx-1"/>
<select class="form-select form-select-sm custom-select border-0" t-on-change="selectDisplayMode">
<option t-att-selected="store.eventsTreeView" value="Tree">Tree view</option>
<option t-att-selected="!store.eventsTreeView" value="List">Events log</option>
</select>
<i title="Collapse All" type="button" class="fa fa-list me-2" t-on-click="() => this.store.collapseAll()" t-attf-style="{{store.eventsTreeView ? '' : 'visibility: hidden;'}}"></i>
<div class="icons-separator"/>
<label class="mx-2 form-check-label pointer-icon" title="Trace renderings in console">
<input type="checkbox" class="form-check-input me-1" t-att-checked="store.traceRenderings" t-on-input="() => this.store.toggleTracing()"/> Trace Renderings
</label>
<label class="mx-2 form-check-label pointer-icon" title="Trace subscriptions in console (warning: it is VERY verbose)">
<input type="checkbox" class="form-check-input me-1" t-att-checked="store.traceSubscriptions" t-on-input="() => this.store.toggleSubscriptionTracing()"/> Trace Subscriptions
</label>
<!-- <EventSearchBar/> -->
</div>
<div class="events-container h-100 font-monospace">
<t t-if="showHelp()">
<div class="status-message d-flex justify-content-center align-items-center">
<div>
Click on the <i class="fa fa-circle" style="font-size: 0.8em;"></i> button to start recording events.
</div>
</div>
</t>
<t t-else="">
<t t-if="store.eventsTreeView">
<t t-foreach="store.eventsTree" t-as="event" t-key="event.id">
<EventNode event="event"/>
</t>
</t>
<t t-else="">
<t t-foreach="store.events" t-as="event" t-key="event_index">
<Event event="event"/>
</t>
</t>
</t>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,32 @@
/** @odoo-module **/
import { useStore } from "../../store/store";
const { Component } = owl;
export class Tab extends Component {
static props = ["tabName"];
static template = "devtools.Tab";
setup() {
this.store = useStore();
}
get active() {
return this.props.tabName === this.store.page;
}
get name() {
switch (this.props.tabName) {
case "ComponentsTab":
return "Components";
case "ProfilerTab":
return "Profiler";
}
}
selectTab(ev) {
this.store.switchTab(this.props.tabName);
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="devtools.Tab" owl="1">
<div t-on-click="selectTab" class="navbar-btn d-block" t-att-class="active
? 'btn-selected'
: ''">
<t t-esc="name" />
</div>
</t>
</templates>
@@ -0,0 +1,987 @@
const { reactive, useState, toRaw } = owl;
import { fuzzySearch, IS_FIREFOX, getActiveTabURL } from "../../utils";
import globalHook from "../../page_scripts/owl_devtools_global_hook";
const browserInstance = IS_FIREFOX ? browser : chrome;
// Main store which contains all states that needs to be maintained throughout all components in the devtools app
export const store = reactive({
devtoolsId: 0,
settings: {
expandByDefault: true,
toggleOnSelected: false,
darkmode: false,
},
contextMenu: {
top: 0,
left: 0,
id: 0,
activeMenu: -1,
// Opens the context menu corresponding with the given menu html element
open(event, menu) {
const menuWidth = menu.offsetWidth;
const menuHeight = menu.offsetHeight;
let x = event.clientX;
let y = event.clientY;
if (x + menuWidth > window.innerWidth) {
x = window.innerWidth - menuWidth;
}
if (y + menuHeight > window.innerHeight) {
y = window.innerHeight - menuHeight;
}
this.left = x + "px";
// Need 25px offset because of the main navbar from the browser devtools
this.top = y - 25 + "px";
},
// Close the currently displayed context menu
close() {
this.activeMenu = -1;
},
},
isFirefox: IS_FIREFOX,
frameUrls: ["top"],
activeFrame: "top",
page: "ComponentsTab",
events: [],
eventsTreeView: true,
eventsTree: [],
activeRecorder: false,
owlStatus: true,
extensionContextStatus: true,
splitPosition: window.innerWidth > window.innerHeight ? 45 : 60,
apps: [],
traceRenderings: false,
traceSubscriptions: false,
activeComponent: {
path: ["0"],
name: "App",
subscriptions: { toggled: true, children: [] },
props: { toggled: true, children: [] },
env: { toggled: false, children: [] },
instance: { toggled: true, children: [] },
version: "1.0",
},
selectedElement: null,
componentSearch: {
search: "",
searchResults: [],
searchIndex: 0,
activeSelector: false,
getNextSearch() {
if (this.searchIndex > -1 && this.searchIndex < this.searchResults.length - 1) {
store.setSearchIndex(this.searchIndex + 1);
} else if (this.searchIndex === this.searchResults.length - 1) {
store.setSearchIndex(0);
}
},
getPrevSearch() {
if (this.searchIndex > 0) {
store.setSearchIndex(this.searchIndex - 1);
} else if (this.searchIndex === 0) {
store.setSearchIndex(this.searchResults.length - 1);
}
},
},
// eventSearch: {
// search: "",
// searchResults: [],
// filters: [],
// },
renderPaths: new Set(),
// Used to navigate between the Components tab and the Events tab
switchTab(componentName) {
this.page = componentName;
this.componentSearch.activeSelector = false;
evalFunctionInWindow("disableHTMLSelector", [], this.activeFrame);
},
// Load all data related to the components tree using the global hook loaded on the page
// Use fromOld to specify if we want to keep most of the toggled/selected data of the old tree
// when generating the new one
async loadComponentsTree(fromOld) {
if (IS_FIREFOX) {
await evalInWindow("window.$0 = $0;", this.activeFrame);
}
const apps = await evalFunctionInWindow(
"getComponentsTree",
fromOld && this.activeComponent ? [this.activeComponent.path, this.apps] : [],
this.activeFrame
);
this.apps = apps ? apps : [];
if (!fromOld && this.settings.expandByDefault) {
this.apps.forEach((tree) => expandNodes(tree));
}
const component = await evalFunctionInWindow(
"getComponentDetails",
fromOld && this.activeComponent ? [this.activeComponent.path, this.activeComponent] : [],
this.activeFrame
);
this.activeComponent = component;
},
// Select a component by retrieving its details from the page based on its path
async selectComponent(path) {
// Deselect all components
this.apps.forEach((app) => {
app.selected = false;
app.highlighted = false;
app.children.forEach((child) => {
deselectComponent(child);
});
});
let component;
// element is the app here
if (path.length === 1) {
component = this.apps[path[0]];
// the second element in the path is always the root of the app so no need to check
} else {
component = this.apps[path[0]].children[0];
}
for (let i = 2; i < path.length; i++) {
component.toggled = true;
const result = component.children.find((child) => child.key === path[i]);
if (result) {
component = result;
} else {
break;
}
}
component.selected = true;
highlightChildren(component);
const details = await evalFunctionInWindow(
"getComponentDetails",
[component.path],
this.activeFrame
);
this.activeComponent = details;
if (!this.activeComponent) {
await this.loadComponentsTree(false);
}
if (this.page !== "ComponentsTab") {
this.switchTab("ComponentsTab");
}
},
// Update the search state value with the current search string and trigger the search
updateSearch(search) {
this.componentSearch.search = search;
this.componentSearch.searchResults = [];
this.apps.forEach((app) => this.getComponentSearchResults(search, app));
if (this.componentSearch.searchResults.length > 0) {
this.componentSearch.searchIndex = 0;
this.selectComponent(this.componentSearch.searchResults[0]);
this.apps.forEach((app) => foldNodes(app));
for (const result of this.componentSearch.searchResults) {
this.toggleComponentParents(result);
}
} else {
this.componentSearch.searchIndex = -1;
}
},
// Search for results in the components tree given the current search string (in a fuzzy way)
getComponentSearchResults(search, node) {
if (search.length < 1) {
return;
}
if (fuzzySearch(node.name, search)) {
this.componentSearch.searchResults.push(node.path);
}
if (node.children) {
node.children.forEach((child) => this.getComponentSearchResults(search, child));
}
},
// Same but only record the component names for events
// getComponentNameSearchResults(search, node) {
// if (search.length < 1) return;
// if (fuzzySearch(node.name, search)) {
// if(!this.eventSearch.searchResults.includes(node.name))
// this.eventSearch.searchResults.push(node.name);
// }
// if (node.children) {
// node.children.forEach((child) => this.getComponentNameSearchResults(search, child));
// }
// },
// updateEventSearch(search){
// this.eventSearch.search = search;
// this.eventSearch.searchResults = [];
// this.apps.forEach((app) => this.getComponentNameSearchResults(search, app));
// [""]
// },
// Toggle all parent components of the specified one to make sure it is visible in the tree
toggleComponentParents(path) {
let cp = path.slice(2);
this.apps[path[0]].toggled = true;
let component = this.apps[path[0]].children[0];
for (const key of cp) {
component.toggled = true;
component = component.children.find((child) => child.key === key);
}
},
// Returns access to the specified component in the tree
getComponentByPath(path) {
let component;
if (path.length < 2) {
component = this.apps[path[0]];
} else {
component = this.apps[path[0]].children[0];
}
let cp = path.slice(2);
for (const key of cp) {
component = component.children.find((child) => child.key === key);
}
return component;
},
// expand/fold the component and its children based on toggle
toggleComponentAndChildren(component, toggle) {
if (toggle) {
expandNodes(component);
} else {
foldNodes(component);
}
},
foldDirectChildren(element) {
for (const child of element.children) {
child.toggled = false;
}
},
// Action related to the left(toggle)/up(not toggle) arrow keys for navigation purpose
// The resulting behaviour is the same as in the Elements tab of the chrome devtools
toggleOrSelectPrevElement(toggle) {
if (toggle) {
const component = this.getComponentByPath(this.activeComponent.path);
if (component.children.length > 0 && component.toggled) {
component.toggled = false;
} else if (this.activeComponent.path.length > 1) {
this.selectComponent(this.activeComponent.path.slice(0, -1));
}
return;
}
const parentPath = [...this.activeComponent.path];
const key = parentPath.pop();
// If component is an app, find descendant with the highest successive children indexes of the app above
// or do nothing if there is no app above
if (parentPath.length === 0) {
const parent = this.apps;
const index = Number(key);
if (index > 0) {
let sibling = parent[index - 1];
while (sibling.toggled && sibling.children.length) {
sibling = sibling.children[sibling.children.length - 1];
}
this.selectComponent(sibling.path);
}
} else {
const parent = this.getComponentByPath(parentPath);
const index = parent.children.findIndex((child) => child.key === key);
if (index > 0) {
let sibling = parent.children[index - 1];
while (sibling.toggled && sibling.children.length) {
sibling = sibling.children[sibling.children.length - 1];
}
this.selectComponent(sibling.path);
} else {
this.selectComponent(parent.path);
}
}
},
// Action related to the right(toggle)/down(not toggle) arrow keys for navigation purpose
// The resulting behaviour is the same as in the Elements tab of the chrome devtools
toggleOrSelectNextElement(toggle) {
let component = this.getComponentByPath(this.activeComponent.path);
if (toggle) {
if (!component.children.length) {
return;
} else if (!component.toggled) {
component.toggled = true;
return;
}
}
// If component has children and is toggled, select its first child
if (component.toggled && component.children.length) {
this.selectComponent(component.children[0].path);
} else {
const parentPath = [...this.activeComponent.path];
while (true) {
const key = parentPath.pop();
if (parentPath.length === 0) {
const index = Number(key);
if (index < this.apps.length - 1) {
this.selectComponent(this.apps[index + 1].path);
}
return;
}
const parent = this.getComponentByPath(parentPath);
const index = parent.children.findIndex((child) => child.key === key);
if (index < parent.children.length - 1 && index > -1) {
this.selectComponent(parent.children[index + 1].path);
return;
}
}
}
},
// Set the search index to the provided one in order to select the current searched component
setSearchIndex(index) {
this.componentSearch.searchIndex = index;
this.selectComponent(this.componentSearch.searchResults[index]);
},
// Replace the (...) content of a getter with the value returned by the corresponding get method
async loadGetterContent(obj) {
const result = await evalFunctionInWindow("loadGetterContent", [obj], this.activeFrame);
Object.keys(obj).forEach((key) => {
obj[key] = result[key];
});
obj.children = [];
},
// Expand the children of the input object property and load it from page if necessary
async toggleObjectTreeElementsDisplay(obj) {
if (!obj.hasChildren) {
return;
}
// Since it is sometimes impossible (and always ineffective) to load all descendants of a property
// when the component details are loaded, we need to populate the children of a property only when
// it is first expanded. Do note that it has a limit to how deep we can expand (when reaching a circular dependancy)
if (obj.hasChildren && obj.children.length === 0) {
const children = await evalFunctionInWindow(
"loadObjectChildren",
[obj.path, obj.depth, obj.contentType, obj.objectType, this.activeComponent],
this.activeFrame
);
obj.children = children;
}
obj.toggled = !obj.toggled;
},
// Toggle the selector tool which is used to select a component based on the hovered Dom element
toggleSelector() {
this.componentSearch.activeSelector = !this.componentSearch.activeSelector;
evalFunctionInWindow(
this.componentSearch.activeSelector ? "enableHTMLSelector" : "disableHTMLSelector",
[],
this.activeFrame
);
},
// Update the value of the given object with the new provided one
editObjectTreeElement(path, value, objectType) {
evalFunctionInWindow("editObject", [path, value, objectType], this.activeFrame);
},
// toggle the tracing mode which will record all root render events and send their trace in the console
async toggleTracing() {
this.traceRenderings = await evalFunctionInWindow(
"toggleTracing",
[!this.traceRenderings],
this.activeFrame
);
},
// toggle subscriptions tracing mode which will record all new subscriptions
async toggleSubscriptionTracing() {
this.traceSubscriptions = await evalFunctionInWindow(
"toggleSubscriptionTracing",
[!this.traceSubscriptions],
this.activeFrame
);
},
// Checks for all iframes in the page, register it and load the scripts inside if not already done
async updateIFrameList() {
const frames = await evalFunctionInWindow("getIFrameUrls");
this.frameUrls = ["top"];
if (this.activeFrame !== "top") {
this.selectFrame("top");
}
for (const frame of frames) {
const hasOwl = await evalInWindow("window.__OWL_DEVTOOLS__?.Fiber !== undefined;", frame);
if (hasOwl) {
const scriptsLoaded = await evalInWindow(
"window.__OWL__DEVTOOLS_GLOBAL_HOOK__ !== undefined;",
frame
);
if (!scriptsLoaded) {
await loadScripts(frame);
}
evalInWindow(
`__OWL__DEVTOOLS_GLOBAL_HOOK__.devtoolsId = ${
store.devtoolsId
}; __OWL__DEVTOOLS_GLOBAL_HOOK__.frame = ${JSON.stringify(frame)};`,
frame
);
if (!this.frameUrls.includes(frame)) {
this.frameUrls = [...this.frameUrls, frame];
}
}
}
},
// Remove all the highlight boxes created above the html elements in the page to show components
removeHighlights() {
if (this.owlStatus && !this.invalidContext) {
evalFunctionInWindow("removeHighlights", [], this.activeFrame);
}
},
// Reset the context of all values in the devtools tab and load the one from the given frame
selectFrame(frame) {
this.removeHighlights();
evalFunctionInWindow("toggleEventsRecording", [false, 0], this.activeFrame);
evalFunctionInWindow("toggleTracing", [false], this.activeFrame);
evalFunctionInWindow("toggleSubscriptionTracing", [false], this.activeFrame);
this.events = [];
this.eventsTree = [];
this.activeFrame = frame;
store.loadComponentsTree(false);
evalFunctionInWindow(
"toggleEventsRecording",
[this.activeRecorder, this.events.length],
this.activeFrame
);
evalFunctionInWindow("toggleTracing", [this.traceRenderings], this.activeFrame);
evalFunctionInWindow("toggleSubscriptionTracing", [this.traceSubscriptions], this.activeFrame);
},
// Constructs the tree that represents the currently recorded events to see them as a tree instead of a temporally accurate list
buildEventsTree() {
let tree = [];
for (const event of this.events) {
let eventNode = Object.assign({}, event);
eventNode.children = [];
eventNode.toggled = true;
if (!eventNode.origin) {
eventNode.depth = 0;
tree.push(eventNode);
} else {
// This is litteraly an array.find but which starts searching from the end of the array
for (let i = tree.length - 1; i >= 0; i--) {
if (arraysEqual(eventNode.origin.path, tree[i].path)) {
// path from the origin event (root render) to the direct parent of the current event
const relativePath = eventNode.path.slice(
tree[i].path.length,
eventNode.path.length - 1
);
let parent = tree[i];
// find the direct parent in the tree
for (const key of relativePath) {
parent = parent.children.find((child) => child.key === key);
}
eventNode.depth = parent.depth + 1;
parent.children.push(eventNode);
break;
}
}
}
}
this.eventsTree = tree;
},
// expand/fold the event tree node based on toggle
toggleEventAndChildren(event, toggle) {
if (toggle) {
expandNodes(event);
} else {
foldNodes(event);
}
},
collapseAll() {
for (let event of this.eventsTree) {
event.toggled = false;
}
},
// Reset all the relevant data about the page currently stored
resetData() {
this.loadComponentsTree(false);
this.events = [];
this.eventsTree = [];
this.activeRecorder = false;
evalFunctionInWindow("toggleEventsRecording", [false, 0]);
evalFunctionInWindow("toggleTracing", [false]);
},
// Triggers manually the rendering of the selected component
refreshComponent(path = this.activeComponent.path) {
evalFunctionInWindow("refreshComponent", [path], this.activeFrame);
},
// Allows to log any object in the console, defaults to the active component (or app)
logObjectInConsole(path) {
if (!path) {
if (this.activeComponent.path.length > 1) {
path = [...this.activeComponent.path, { type: "item", value: "component" }];
} else {
path = this.activeComponent.path;
}
}
evalFunctionInWindow("sendObjectToConsole", [path], this.activeFrame);
},
// inspect the source code of the object given by its path
async inspectFunctionSource(path) {
await evalFunctionInWindow("inspectFunctionSource", [path], this.activeFrame);
if (IS_FIREFOX) {
await evalInWindow("inspect(window.$temp);", this.activeFrame);
}
},
// Inspect the given component's data based on the given type
async inspectComponent(type, path = this.activeComponent.path) {
switch (type) {
case "DOM":
await evalFunctionInWindow("inspectComponentDOM", [path], this.activeFrame);
break;
case "source":
if (path.length > 1) {
await evalFunctionInWindow(
"inspectFunctionSource",
[
[
...path,
{ type: "item", value: "component" },
{ type: "item", value: "constructor" },
],
],
this.activeFrame
);
} else {
await evalFunctionInWindow(
"inspectFunctionSource",
[[...path, { type: "item", value: "constructor" }]],
this.activeFrame
);
}
break;
case "compiled template":
await evalFunctionInWindow("inspectComponentCompiledTemplate", [path], this.activeFrame);
break;
case "raw template":
await evalFunctionInWindow("inspectComponentRawTemplate", [path], this.activeFrame);
break;
}
if (IS_FIREFOX && type !== "raw template") {
await evalInWindow("inspect(window.$temp);", this.activeFrame);
}
},
// Trigger the highlight on the component in the page
highlightComponent(path) {
evalFunctionInWindow("highlightComponent", [path], this.activeFrame);
},
// Center the view around the currently selected component
focusSelectedComponent() {
this.selectedElement.scrollIntoView({ block: "center", behavior: "smooth" });
},
// Toggle the recording of events in the page
async toggleRecording() {
this.activeRecorder = await evalFunctionInWindow(
"toggleEventsRecording",
[!this.activeRecorder, this.events.length],
this.activeFrame
);
},
// Reset all events data
clearEventsConsole() {
this.events = [];
this.eventsTree = [];
evalFunctionInWindow("resetEvents", [], this.activeFrame);
},
// Refresh the whole extension
async refreshExtension() {
await loadScripts();
this.resetData();
},
// Toggle dark mode in the extension and store result in the storage
toggleDarkMode() {
this.settings.darkMode = !this.settings.darkMode;
if (this.settings.darkMode) {
document.querySelector("html").classList.add("dark-mode");
} else {
document.querySelector("html").classList.remove("dark-mode");
}
browserInstance.storage.local.set({ owl_devtools_dark_mode: this.settings.darkMode });
},
openDocumentation() {
browserInstance.runtime.sendMessage({ type: "openDoc" });
},
});
// Instantiate the store
export function useStore() {
return useState(store);
}
init();
async function init() {
store.devtoolsId = await getTabURL();
evalInWindow("__OWL__DEVTOOLS_GLOBAL_HOOK__.devtoolsId = " + store.devtoolsId + ";");
// We want to load the base components tree when the devtools tab is first opened
store.loadComponentsTree(false);
// We also want to detect the different iframes at first loading of the devtools tab
store.updateIFrameList();
// Global listeners to close the currently shown context menu when the user clicks or opens another
document.addEventListener("click", () => store.contextMenu.close(), { capture: true });
document.addEventListener("contextmenu", () => store.contextMenu.close(), { capture: true });
// Make sure the events recorder is at its initial state in every frame
for (const frame of store.frameUrls) {
evalFunctionInWindow("toggleEventsRecording", [false, 0], frame);
}
loadSettings();
}
// Heartbeat message to test whether the extension context is still valid or not
setInterval(() => {
if (store.extensionContextStatus) {
try {
browserInstance.runtime.sendMessage({ type: "keepAlive" });
} catch (e) {
store.extensionContextStatus = false;
}
}
}, 1000);
let flushRendersTimeout = false;
// Connect to the port to communicate to the background script
browserInstance.runtime.onConnect.addListener((port) => {
if (!port.name === "OwlDevtoolsPort") {
return;
}
port.onMessage.addListener(async (msg) => {
// Reload the tree after checking if the scripts are loaded when this message is received
if (msg.type === "Reload") {
const tab = await getTabURL();
// Since this message is sent to all devtools windows, only take it into account when this is the active tab
if (tab !== store.devtoolsId) {
return;
}
store.owlStatus = await evalInWindow("window.__OWL__DEVTOOLS_GLOBAL_HOOK__ !== undefined;");
if (store.owlStatus) {
evalInWindow("__OWL__DEVTOOLS_GLOBAL_HOOK__.devtoolsId = " + store.devtoolsId + ";");
store.resetData();
}
}
// Received when a frame has been delayed when loading the scripts due to owl being lazy loaded
if (msg.type === "FrameReady") {
const tab = await getTabURL();
// Same as for the reload message
if (tab !== store.devtoolsId) {
return;
}
store.updateIFrameList();
store.owlStatus = true;
store.resetData();
}
// We need to reload the components tree when the set of apps in the page is modified
if (msg.type === "RefreshApps") {
store.loadComponentsTree(true);
}
// Filter out the messages that are not destined to this devtools tab. The messages above may be sent before
// the devtoolsId is set
if (msg.origin.id !== store.devtoolsId) {
return;
}
// When message of type Flush is received, overwrite the component tree with the new one from page
// A flush message is sent everytime a component is rendered on the page
if (msg.type === "Flush") {
if (msg.origin.frame !== store.activeFrame) {
return;
}
if (!(Array.isArray(msg.data) && msg.data.every((val) => typeof val === "string"))) {
return;
}
// This determines which components will have a short highlight effect in the tree to indicate they have been rendered
store.renderPaths.add(JSON.stringify(msg.data));
clearTimeout(flushRendersTimeout);
flushRendersTimeout = setTimeout(() => {
store.renderPaths.clear();
}, 100);
store.loadComponentsTree(true);
}
// Select the component based on the path received with the SelectElement message
if (msg.type === "SelectElement") {
if (!(Array.isArray(msg.data) && msg.data.every((val) => typeof val === "string"))) {
return;
}
store.selectComponent(msg.data);
}
// Stop the DOM element selector tool upon receiving the StopSelector message
if (msg.type === "StopSelector") {
store.componentSearch.activeSelector = false;
}
// Logic for recording an event when the event message is received
if (msg.type === "Event") {
let events = msg.data;
loadEvents(events);
}
// If we know a new iframe has been added to the page, load scripts into it and update the
// frames list if it has been directly loaded.
if (msg.type === "NewIFrame") {
const isLoaded = await loadScripts(msg.data);
if (isLoaded) {
store.updateIFrameList();
}
}
});
});
// Load all settings from the chrome sync storage
async function loadSettings() {
let storage = await browserInstance.storage.local.get();
if (storage.owl_devtools_dark_mode === undefined) {
// Load dark mode based on the global settings of the chrome devtools
darkMode = browserInstance.devtools.panels.themeName === "dark";
} else {
darkMode = storage.owl_devtools_dark_mode;
}
store.settings.darkMode = darkMode;
if (darkMode) {
document.querySelector("html").classList.add("dark-mode");
} else {
document.querySelector("html").classList.remove("dark-mode");
}
}
// Function to handle and store a batch of events coming from the page
function loadEvents(events) {
if (!Array.isArray(events)) {
return;
}
for (const event of events) {
// Check if the event data has the right shape for security purpose
if (
!isObjectWithShape(event, {
type: "string",
component: "string",
key: "string",
path: "object",
time: "number",
id: "number",
}) ||
!(Array.isArray(event.path) && event.path.every((val) => typeof val === "string"))
) {
return;
}
event.origin = null;
event.toggled = false;
// Logic to retrace the origin of the event if it is not a root render event
if (!event.type.includes("render")) {
for (let i = store.events.length - 1; i >= 0; i--) {
if (
!store.events[i].origin &&
event.path.join("/").includes(store.events[i].path.join("/"))
) {
event.origin = toRaw(store.events[i]);
break;
}
if (
store.events[i].origin &&
event.path.join("/").includes(store.events[i].origin.path.join("/"))
) {
event.origin = toRaw(store.events[i].origin);
break;
}
}
}
// Add the event to the events tree immediatly when the events view is in tree mode
if (store.eventsTreeView) {
let eventNode = Object.assign({}, event);
eventNode.children = [];
eventNode.toggled = true;
if (!eventNode.origin) {
eventNode.depth = 0;
store.eventsTree.push(eventNode);
} else {
// Similar to when we're constructing the whole tree
for (let i = store.eventsTree.length - 1; i >= 0; i--) {
if (eventNode.origin.path.join("/") === store.eventsTree[i].path.join("/")) {
const relativePath = eventNode.path.slice(
store.eventsTree[i].path.length,
eventNode.path.length - 1
);
let parent = store.eventsTree[i];
for (const key of relativePath) {
parent = parent.children.find((child) => child.key === key);
}
eventNode.depth = parent.depth + 1;
parent.children.push(eventNode);
break;
}
}
}
}
// Make sure we add the event while keeping the whole list ordered by id
addEventSorted(event);
}
}
// Deselect component and remove highlight on all children
function deselectComponent(component) {
component.selected = false;
component.highlighted = false;
for (const child of component.children) {
deselectComponent(child);
}
}
// Used to check if the given object has the right shape
function isObjectWithShape(obj, shape) {
if (typeof obj !== "object" || Array.isArray(obj)) {
return false;
}
return Object.keys(shape).every(
(key) => obj.hasOwnProperty(key) && typeof obj[key] === shape[key]
);
}
// A binary search algorith to efficiently add an event in the events array while keeping it sorted
function addEventSorted(item) {
let low = 0;
let high = store.events.length;
while (low < high) {
let mid = Math.floor((low + high) / 2);
if (store.events[mid].id < item.id) {
low = mid + 1;
} else {
high = mid;
}
}
store.events.splice(low, 0, item);
}
// Apply highlight recursively to all children of a selected component
function highlightChildren(component) {
component.children.forEach((child) => {
child.highlighted = true;
highlightChildren(child);
});
}
// Expand the node given in entry and all of its children
function expandNodes(node) {
node.toggled = true;
for (const child of node.children) {
expandNodes(child);
}
}
// Fold the node given in entry and all of its children
function foldNodes(node) {
node.toggled = false;
for (const child of node.children) {
foldNodes(child);
}
}
// Load the scripts in the specified frame
async function loadScripts(frameUrl) {
return await evalInWindow(globalHook, frameUrl);
}
// Shallow array equality
function arraysEqual(arr1, arr2) {
if (arr1.length !== arr2.length) {
// Check if the arrays are of the same length
return false;
}
return arr1.every((val, i) => val === arr2[i]); // Compare each element of the arrays
}
async function getTabURL() {
if (IS_FIREFOX) {
// This happens in firefox when the method is called inside devtools so we ask the background to execute it instead
browserInstance.runtime.sendMessage({ type: "getActiveTabURL" }).then((response) => {
return response.result;
});
} else {
return await getActiveTabURL();
}
}
// General method for executing functions from the loaded scripts in the right frame of the page
// using the __OWL__DEVTOOLS_GLOBAL_HOOK__. Take the function's args as an array.
async function evalFunctionInWindow(fn, args = [], frameUrl = "top") {
const stringifiedArgs = [...args].map((arg) => {
arg = JSON.stringify(arg);
return arg;
});
const argsString = "(" + stringifiedArgs.join(", ") + ");";
let script = `__OWL__DEVTOOLS_GLOBAL_HOOK__.${fn}${argsString}`;
return await new Promise((resolve, reject) => {
if (frameUrl !== "top") {
browserInstance.devtools.inspectedWindow.eval(
script,
{ frameURL: frameUrl },
(result, isException) => {
if (!isException) {
resolve(result);
} else {
reject(script);
}
}
);
} else {
browserInstance.devtools.inspectedWindow.eval(script, (result, isException) => {
if (!isException) {
resolve(result);
} else {
reject(script);
}
});
}
});
}
// General method for executing code in the window using chrome.devtools.inspectedWindow.eval.
async function evalInWindow(code, frameUrl = "top") {
return await new Promise((resolve, reject) => {
if (frameUrl !== "top") {
browserInstance.devtools.inspectedWindow.eval(
code,
{ frameURL: frameUrl },
(result, isException) => {
if (!isException) {
resolve(result);
} else {
reject(code);
}
}
);
} else {
browserInstance.devtools.inspectedWindow.eval(code, (result, isException) => {
if (!isException) {
resolve(result);
} else {
reject(code);
}
});
}
});
}
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+292
View File
@@ -0,0 +1,292 @@
@font-face {
font-family: 'Fira Mono';
src: url('../assets/FiraMono-Medium.ttf') format('truetype');
}
:root {
--border-color: lightgray;
--navbar-selected: #5f9cf0;
--component-hover: #d4e3f2;
--bs-font-monospace: "Fira Mono";
--text-color: #494949;
--object-color: rgb(119, 34, 255);
--prototype-color: rgb(179 133 255);
--text-proto-color: #818181;
--highlight-color: yellow;
--component-color: rgb(48, 74, 219);
--key-name: rgb(192 166 118);
--key-content: rgb(141 112 188 / 59%);
--subscription-key: rgb(34, 49, 255);
--component-highlighted: #f0f8fb;
--component-selected: #d4e3f2;
--active-icon: rgb(41, 134, 255);
--active-recorder: rgb(255, 31, 31);
--navbar-bg: #f1f3f4;
--background-color: white;
--text-selected: white;
--menu-highlight-bg: rgb(201, 201, 201);
--version-bg: teal;
/* to change the color here, put it in stroke='%23[color in hexadecimal]' */
--select-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23444444' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
}
.dark-mode {
--border-color: #838383;
--navbar-selected: #33a3ff;
--component-hover: #597179;
--text-color: #c9c9c9;
--object-color: rgb(188 147 255);
--prototype-color: rgb(134 105 180);
--text-proto-color: #818181;
--highlight-color: yellow;
--component-color: rgb(135 153 255);
--key-name: rgb(171 212 253 / 51%);
--key-content: rgb(227 213 252 / 60%);
--subscription-key: rgb(154 161 255);
--component-highlighted: #525d61;
--component-selected: #287e99;
--active-icon: rgb(93 163 255);
--active-recorder: rgb(255, 31, 31);
--navbar-bg: #484a4b;
--background-color: rgb(45 45 45);
--text-selected: #c9c9c9;
--menu-highlight-bg: rgb(65, 65, 65);
--version-bg: #805900ad;
color-scheme: dark;
/* to change the color here, put it in stroke='%23[color in hexadecimal]' */
--select-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23c9c9c9' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
}
.caret {
width: 0.9rem;
}
#container {
font-size: 0.75em;
color: var(--text-color);
background-color: var(--background-color);
}
.status-message {
min-height: 70vh;
font-size: 1.1em;
}
.custom-navbar {
font-size: 12px;
padding: 0 !important;
background: var(--navbar-bg);
}
.form-select {
color: var(--text-color) !important;
}
.navbar-btn {
height: 23px;
padding: 0.5rem;
padding-top: 0.2em;
color: var(--text-color);
border-right: solid 1px lightgrey;
user-select: none;
cursor: pointer;
}
.navbar-btn:hover {
color: var(--navbar-selected);
}
.btn-selected {
background-color: var(--navbar-selected);
color: var(--text-selected);
font-weight: bold;
}
.btn-selected:hover{
color: var(--text-selected);
}
#tree-wrapper {
min-width: max-content;
}
.tree-component:hover {
background-color: var(--component-hover);
}
.object-line.attenuate {
color: var(--text-proto-color);
}
.object-content {
color: var(--object-color);
}
.object-content.attenuate {
color: var(--prototype-color);
}
.event-container {
border-bottom: 1px solid rgb(240, 238, 238);
padding-top: 2px!important;
padding-bottom: 2px!important;
font-size: 11px;
}
.getter-content:hover {
text-decoration: underline;
}
.render-highlight {
background-color: var(--highlight-color) !important;
}
.highlight-fade {
transition: background-color 0.5s ease-out;
}
.highlight-search {
background-color: var(--highlight-color);
}
.key-name {
color: var(--key-content);
}
.custom-select {
max-width: max-content;
outline: none !important;
box-shadow: none !important;
color: var(--text-color) !important;
background-color: var(--background-color);
background-image: var(--select-icon);
font-size: 12px;
}
.navbar-select {
font-size: .7rem !important;
padding-top: 0.35rem !important;
border-right: solid 1px var(--border-color) !important;
}
:focus {
outline: none !important;
box-shadow: none !important;
}
.details-panel {
border-top: 1px solid var(--border-color);
}
.details-panel:first-child {
border: none;
}
.component-highlighted {
background-color: var(--component-highlighted);
}
.component-selected {
background-color: var(--component-selected);
}
.split-screen-border {
width: 10px;
cursor: col-resize;
margin: 0px -5px;
z-index: 1;
}
.blank-space {
height: 100px;
}
.key-content {
color: var(--subscription-key);
}
.version {
margin-left: 5px;
background-color: var(--version-bg);
color: white;
padding: 2px 3px;
border-radius: 4px;
font-size: smaller;
}
.panel-top {
border-bottom: 1px solid var(--border-color);
}
.details-container {
overflow-y: auto;
overflow-x: hidden;
}
.events-container {
overflow-y: auto;
overflow-x: hidden;
}
.split-screen-right {
margin: 0;
border-left: 1px solid var(--border-color);
}
.search-input {
background-color: var(--background-color);
color: var(--text-color);
padding: 0.4rem 0rem;
}
.search-icon {
position: relative;
bottom: 1px;
}
.utility-icon {
cursor: pointer;
font-size: 1.2em;
}
.lg-icon {
font-size: 1.4em;
}
.fa-caret-right {
font-size: 1.15em;
}
.icons-separator {
width: 1px;
height: 17px;
background-color: var(--border-color);
user-select: none;
}
.pointer-icon {
cursor: pointer;
}
.custom-menu {
position: absolute;
color: var(--text-color);
background-color: var(--background-color);
border: 1px solid gray;
z-index: 1;
box-shadow: 1px 2px 5px #888;
font-family: var(--bs-font-sans-serif);
}
.custom-menu ul {
list-style-type: none;
padding: 0;
}
.custom-menu-item {
cursor: pointer;
}
.custom-menu-item:hover {
background-color: var(--menu-highlight-bg);
}
File diff suppressed because it is too large Load Diff

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