diff --git a/.gitignore b/.gitignore index b6e47617de1..966147abab3 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ dmypy.json # Pyre type checker .pyre/ + +# mac os garbage +.DS_Store diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000000..277f7c9898f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.languageServer": "None" +} diff --git a/1_learn_owl.md b/1_learn_owl.md new file mode 100644 index 00000000000..9354066240a --- /dev/null +++ b/1_learn_owl.md @@ -0,0 +1,560 @@ +# Module 1: Learn Owl 🦉 + +This chapter introduces the Owl framework, a tailor-made component system for +Odoo. The main building blocks of Owl are components and templates. In Owl, +every part of the user interface is managed by a component: they hold the logic +and define the templates that are used to render the user interface. In +practice, a component is represented by a small JavaScript class subclassing the +Component class. + +To get started, you need a running Odoo server and a development environment +setup. Before getting into the exercises, make sure you have a working setup. + +Start your development environment with a new database, on the `master` branch, +and make sure to add this repository in the addons path. Then, install the +`awesome_owl` addon. Once it is done, you can open the `/awesome_owl` route +(typically on `localhost:8069/awesome_owl`). If you see the `hello world` +message, you are ready to start! + +The `awesome_owl addon` provides a simplified environment that only contains Owl +and a few other files. The goal is to learn Owl itself, without relying on Odoo +web client code. + +## Content + +- [Resources](#resources) +- [Example: a Counter component](#example-a-counter-component) +- [1. Displaying a Counter](#1-displaying-a-counter) +- [2. Extract Counter in a sub component](#2-extract-counter-in-a-sub-component) +- [3. A simple Card component](#3-a-simple-card-component) +- [4. Using markup to display html](#4-using-markup-to-display-html) +- [5. Props validation](#5-props-validation) +- [6. The sum of two Counter](#6-the-sum-of-two-counter) +- [6B. Bonus Project](#6b-bonus-project) +- [7. A todo list](#7-a-todo-list) +- [8. Use dynamic attributes](#8-use-dynamic-attributes) +- [9. Adding a todo](#9-adding-a-todo) +- [10. Focusing the input](#10-focusing-the-input) +- [11. Toggling todos](#11-toggling-todos) +- [12. Deleting todos](#12-deleting-todos) +- [13. Improved state management](#13-improved-state-management) +- [13B. Bonus Project: Todo class](#13b-bonus-project-todo-class) +- [14. Generic Card with slots](#14-generic-card-with-slots) +- [15. Minimizing card content](#15-minimizing-card-content) + +## Resources + +- [Owl repository](https://github.com/odoo/owl) +- [Owl documentation](https://github.com/odoo/owl#documentation) + +## Example: a Counter component + +First, let us have a look at a simple example. The Counter component shown below +is a component that maintains an internal number value, displays it, and updates +it whenever the user clicks on the button. + +```js +import { Component, useState } from "@odoo/owl"; + +export class Counter extends Component { + static template = "my_module.Counter"; + + setup() { + this.state = useState({ value: 0 }); + } + + increment() { + this.state.value++; + } +} +``` + +The Counter component specifies the name of a template that represents its html. +It is written in XML using the QWeb language: + +```xml + + +

Counter:

+ +
+
+``` + +## 1. Displaying a counter + +![A Counter component](_images/counter.png) + +As a first exercise, let us modify the `Playground` component located in +`awesome_owl/static/src/` to turn it into a counter. To see the result, you can +go to the `/awesome_owl` route with your browser. + +1. Modify `playground.js` so that it acts as a counter like in the example + above. Keep `Playground` for the class name. You will need to use the + `useState` hook so that the component is updated whenever the button is + clicked. +2. In the same component, create an increment method. +3. Modify the template in `playground.xml` so that it displays your counter + variable. Use + [`t-esc`](https://github.com/odoo/owl/blob/master/doc/reference/templates.md#outputting-data) + to output the data. +4. Add a button in the template and specify a + [`t-on-click`](https://github.com/odoo/owl/blob/master/doc/reference/event_handling.md#event-handling) + attribute in the button to trigger the increment method whenever the button + is clicked. + +This exercise showcases an important feature of Owl: the reactivity system. The +`useState` function wraps a value in a proxy so Owl can keep track of which +component needs which part of the state, so it can be updated whenever a value +has been changed. Try removing the `useState` function and see what happens. + +**Tip:** the Odoo JavaScript files downloaded by the browser are minified. For +debugging purpose, it’s easier when the files are not minified. Switch to debug +mode with assets so that the files are not minified. + +## 2. Extract Counter in a sub component + +For now we have the logic of a counter in the `Playground` component, but it is +not reusable. Let us see how to create a +[sub-component](https://github.com/odoo/owl/blob/master/doc/reference/component.md#sub-components) +from it: + +1. Extract the counter code from the `Playground` component into a new `Counter` + component. +2. You can do it in the same file first, but once it’s done, update your code to + move the `Counter` in its own folder and file. Import it relatively from + ./counter/counter. +3. Make sure the template is in its own file, with the same name. +4. Use in the template of the Playground component to add two + counters in your playground. + +![Double Counter](_images/double_counter.png) + +**Tip:** by convention, most components code, template and css should have the +same snake-cased name as the component. For example, if we have a `TodoList` +component, its code should be in `todo_list.js`, `todo_list.xml` and if +necessary, `todo_list.scss` + +## 3. A simple Card component + +Components are really the most natural way to divide a complicated user +interface into multiple reusable pieces. But to make them truly useful, it is +necessary to be able to communicate some information between them. Let us see +how a parent component can provide information to a sub component by using +attributes (most commonly known as +[props](https://github.com/odoo/owl/blob/master/doc/reference/props.md)). + +The goal of this exercise is to create a `Card` component, that takes two props: +`title` and `content`. For example, here is how it could be used: + +```xml + +``` + +The above example should produce some html using bootstrap that look like this: + +```html +
+
+
my title
+

some content

+
+
+``` + +1. Create a `Card` component +2. Import it in `Playground` and display a few cards in its template + +![Simple Card](_images/simple_card.png) + +## 4. Using markup to display html + +If you used `t-esc` in the previous exercise, then you may have noticed that Owl +automatically escapes its content. For example, if you try to display some html +like this: `` with +`this.html = "
some content
""`, the resulting output will simply +display the html as a string. + +In this case, since the `Card` component may be used to display any kind of +content, it makes sense to allow the user to display some html. This is done +with the +[`t-out`](https://github.com/odoo/owl/blob/master/doc/reference/templates.md#outputting-data) +directive. + +However, displaying arbitrary content as html is dangerous, it could be used to +inject malicious code, so by default, Owl will always escape a string unless it +has been explicitely marked as safe with the `markup` function. + +1. Update `Card` to use `t-out`, +2. Update `Playground` to import `markup`, and use it on some html values +3. Make sure that you see that normal strings are always escaped, unlike + markuped strings. + +![Markup](_images/markup.png) + +**Note:** the `t-esc` directive can still be used in Owl templates. It is +slightly faster than `t-out`. + +## 5. Props validation + +The `Card` component has an implicit API. It expects to receive two strings in +its props object: the `title` and the `content`. Let us make that API more +explicit. We can add a props definition that will let Owl perform a validation +step in +[dev mode](https://github.com/odoo/owl/blob/master/doc/reference/app.md#dev-mode). +You can activate the dev mode in the +[App configuration](https://github.com/odoo/owl/blob/master/doc/reference/app.md#configuration) +(but it is activated by default on the `awesome_owl` playground). + +It is a good practice to do props validation for every component. + +1. Add + [props validation](https://github.com/odoo/owl/blob/master/doc/reference/props.md#props-validation) + to the Card component. +2. Rename the `title` props into something else in the playground template, then + check in the Console tab of your browser’s dev tools that you can see an + error. + +## 6. The sum of two Counter + +We saw in a previous exercise that `props` can be used to provide information +from a parent to a child component. Now, let us see how we can communicate +information in the opposite direction: in this exercise, we want to display two +`Counter` components, and below them, the sum of their values. So, the parent +component (`Playground`) need to be informed whenever one of the `Counter` value +is changed. + +This can be done by using a +[callback prop](https://github.com/odoo/owl/blob/master/doc/reference/props.md#binding-function-props): +a prop that is a function meant to be called back. The child component can +choose to call that function with any argument. In our case, we will simply add +an optional `onChange` prop that will be called whenever the `Counter` component +is incremented. + +1. Add prop validation to the `Counter` component: it should accept an optional + `onChange` function prop. +2. Update the `Counter` component to call the `onChange` prop (if it exists) + whenever it is incremented. +3. Modify the `Playground` component to maintain a local state value (`sum`), + initially set to 2, and display it in its template +4. Implement an `incrementSum` method in `Playground` +5. Give that method as a prop to two (or more!) sub `Counter` components. + +![Sum of counters](_images/sum_counter.png) + +**Important:** there is a subtlety with callback props: they usually should be +defined with the .bind suffix. See the +[documentation](https://github.com/odoo/owl/blob/master/doc/reference/props.md#binding-function-props). + +## 6B. Bonus Project + +The code for the previous exercise is designed from a pedagogical perspective, +but the design is actually somewhat strange/fragile. This is because we are in a +situation where a parent component need to compute some value that are actually +owned by its children. So, we end up with a fragile design, where we use events +to coordinate components. + +A better solution would be to reorganize the code so that the playground hold a +list of values, and give them to each `Counter`. + +1. Move the state from each `Counter` component to the `Playground` component +2. Use a getter to define the sum of each value as a derived state + +## 7. A todo list + +Let us now discover various features of Owl by creating a todo list. We need two +components: a `TodoList` component that will display a list of `TodoItem` +components. The list of todos is a state that should be maintained by the +`TodoList`. + +For this tutorial, a `todo` is an object that contains three values: + +- an `id` (number), +- a `description` (string), +- and a flag `isCompleted` (boolean): + +```js +{ id: 3, description: "buy milk", isCompleted: false } +``` + +1. Create a `TodoList` and a `TodoItem` components. +2. The `TodoItem` component should receive a `todo` as a prop, and display its + `id` and `description` in a `div`. +3. For now, hardcode the list of todos: + + ```js + // in TodoList + this.todos = useState([ + { id: 3, description: "buy milk", isCompleted: false }, + ]); + ``` + +4. Use + [t-foreach](https://github.com/odoo/owl/blob/master/doc/reference/templates.md#loops) + to display each todo in a `TodoItem`. +5. Display a `TodoList` in the playground. +6. Add props validation to `TodoItem`. + +![Todo List](_images/todo_list.png) + +**Tip:** since the `TodoList` and `TodoItem` components are so tightly coupled, +it makes sense to put them in the same folder. + +**Note:** the `t-foreach` directive is not exactly the same in Owl as the QWeb +python implementation: it requires a `t-key` unique value, so that Owl can +properly reconcile each element. + +## 8. Use dynamic attributes + +For now, the `TodoItem` component does not visually show if the todo is +completed. Let us do that by using a +[dynamic attributes](https://github.com/odoo/owl/blob/master/doc/reference/templates.md#dynamic-attributes). + +1. Add the Bootstrap classes `text-muted` and `text-decoration-line-through` on + the `TodoItem` root element if it is completed. +2. Change the hardcoded `this.todos` value to check that it is properly + displayed. + +Even though the directive is named `t-att` (for attribute), it can be used to +set a class value (and other html properties such as the value of an input). + +![Muted Todo](_images/muted_todo.png) + +**Tip:** Owl let you combine static class values with dynamic values. The +following example will work as expected: + +```xml +
+``` + +See also: +[Owl: Dynamic class attributes](https://github.com/odoo/owl/blob/master/doc/reference/templates.md#dynamic-class-attribute) + +## 9. Adding a todo + +So far, the todos in our list are hard-coded. Let us make it more useful by +allowing the user to add a todo to the list. + +![Creating a todo](_images/create_todo.png) + +1. Remove the hardcoded values in the `TodoList` component: + +```js +this.todos = useState([]); +``` + +2. Add an input above the task list with placeholder _Enter a new task_. +3. Add an + [event handler](https://github.com/odoo/owl/blob/master/doc/reference/event_handling.md) + on the keyup event named addTodo. +4. Implement `addTodo` to check if enter was pressed (`ev.keyCode === 13`), and + in that case, create a new todo with the current content of the input as the + description and clear the input of all content. +5. Make sure the todo has a unique id. It can be just a counter that increments + at each todo. +6. Bonus point: don’t do anything if the input is empty. + +See also: +[Owl reactivity](https://github.com/odoo/owl/blob/master/doc/reference/reactivity.md) + +## 10. Focusing the input + +Let’s see how we can access the DOM with +[t-ref](https://github.com/odoo/owl/blob/master/doc/reference/refs.md) and +[useRef](https://github.com/odoo/owl/blob/master/doc/reference/refs.md). The +main idea is that you need to mark the target element in the component template +with a `t-ref`: + +```xml +
hello
+``` + +Then you can access it in the JS with the +[useRef](https://github.com/odoo/owl/blob/master/doc/reference/hooks.md#useref) +hook. However, there is a problem if you think about it: the actual html element +for a component does not exist when the component is created. It only exists +when the component is mounted. But hooks have to be called in the setup method. +So, `useRef` returns an object that contains a `el` (for element) key that is +only defined when the component is mounted. + +```js +setup() { + this.myRef = useRef('some_name'); + onMounted(() => { + console.log(this.myRef.el); + }); +} +``` + +1. Focus the `input` from the previous exercise. This should be done from the + `TodoList` component (note that there is a `focus` method on the input html + element). +2. Bonus point: extract the code into a specialized hook `useAutofocus` in a new + `utils.js` file. + +![Autofocus](_images/autofocus.png) + +**Tip:** Refs are usually suffixed by `Ref` to make it obvious that they are +special objects: + +```js +this.inputRef = useRef("input"); +``` + +## 11. Toggling todos + +Now, let’s add a new feature: mark a todo as completed. This is actually +trickier than one might think. The owner of the state is not the same as the +component that displays it. So, the `TodoItem` component needs to communicate to +its parent that the todo state needs to be toggled. One classic way to do this +is by adding a +[callback prop](https://github.com/odoo/owl/blob/master/doc/reference/props.md#binding-function-props) +`toggleState`. + +1. Add an input with the attribute `type="checkbox"` before the id of the task, + which must be checked if the state `isCompleted` is true. +2. Add a callback props `toggleState` to `TodoItem`. +3. Add a `change` event handler on the input in the `TodoItem` component and + make sure it calls the `toggleState` function with the todo `id`. +4. Make it work! + +![Toggling a todo](_images/toggle_todo.png) + +**Tip:** Owl does not create attributes computed with the `t-att` directive if +its expression evaluates to a falsy value. + +## 12. Deleting todos + +The final touch is to let the user delete a todo. + +1. Add a new callback prop `removeTodo` in `TodoItem`. +2. Insert `` in the template of the + `TodoItem` component. +3. Whenever the user clicks on it, it should call the `removeTodo` method. +4. Make it work! + +![Deleting a todo](_images/delete_todo.png) + +**Tip:** If you’re using an array to store your todo list, you can use the +JavaScript `splice` function to remove a todo from it. + +```js +// find the index of the element to delete +const index = list.findIndex((elem) => elem.id === elemId); +if (index >= 0) { + // remove the element at index from list + list.splice(index, 1); +} +``` + +## 13. Improved state management + +Note: this exercise (and the next one) is more advanced. Feel free to skip it. + +So far, the `TodoList` has a simple architecture: a parent component `TodoList` +that holds the state and the update methods (add/remove/toggle), and a child +component `TodoItem`. This is fine for most situations, but at some point, if we +expect more and more complex features to be implemented, it is useful to +separate the _state management_ (or _model_) code from the UI code. + +1. Define a `TodoModel` class. It should have a list of todos, and four methods: + `getTodo`, `add`, `remove`, `toggle`. +2. Move all state related code from `TodoList` to `TodoModel` +3. Modify `TodoList` to instantiate a `TodoModel` in a `useState`: + + ```js + // useState is here to make the model reactive + this.model = useState(new TodoModel()); + ``` + +4. Update `TodoItem` to take 2 props: `model` and `id` +5. Make it work! + +Congratulation, your state management code is now separate from your UI code! + +**Tip:** it is often useful to use a `t-set` directive in a template to compute +once an important value. For example, in the template for `TodoItem`, we can do +this: + +```xml + +``` + +## 13B. Bonus Project: Todo class + +The previous exercise successfully refactored the code to make it easier to +maintain. However, there is still something quite awkward: the `TodoItem` +receive two props, the model and the id for the todo. It is necessary to allow +the `TodoItem` to toggle and to remove the todo. + +It would be nicer if we could package the state and its update function in one +convenient object. That's what OOP is for! It turns out that using simple +classes work well with Owl and the reactivity system (as long as you stay away +from private fields). + +1. In `todo_model.js`, define the following `Todo` class: + + ```js + export class Todo { + static nextId = 1; + + constructor(model, description) { + this._model = model; + this.id = Todo.nextId++; + this.description = description; + this.isCompleted = false; + } + + toggle() { + this.isCompleted = !this.isCompleted; + } + + remove() { + this._model.remove(this.id); + } + } + ``` + +2. Adapt `TodoModel` to use it +3. Remove `toggle` method from `TodoModel` (no longer necessary) +4. adapt `TodoItem` component to only receive a `Todo` instance as props +5. make it work! + +This is a very useful pattern when working with complicated objects. + +## 14. Generic Card with slots + +In a previous exercise, we built a simple `Card` component. But it is honestly +quite limited. What if we want to display some arbitrary content inside a card, +such as a sub-component? Well, it does not work, since the content of the card +is described by a string. It would however be very convenient if we could +describe the content as a piece of template. + +This is exactly what Owl’s +[slot](https://github.com/odoo/owl/blob/master/doc/reference/slots.md) system is +designed for: allowing to write generic components. + +Let us modify the `Card` component to use slots: + +1. Remove the `content` prop. +2. Use the default slot to define the body. +3. Insert a few cards with arbitrary content, such as a `Counter` component. +4. (bonus) Add prop validation. + +![Generic card](_images/generic_card.png) + +**See also:** +[Bootstrap: documentation on cards](https://getbootstrap.com/docs/5.2/components/card/) + +## 15. Minimizing card content + +Finally, let’s add a feature to the `Card` component, to make it more +interesting: we want a button to toggle its content (show it or hide it). + +1. Add a state to the `Card` component to track if it is open (the default) or + not +2. Add a `t-if` in the template to conditionally render the content +3. Add a button in the header, and modify the code to flip the state when the + button is clicked + +![Toggling a card](_images/toggle_card.png) diff --git a/2_make_a_dashboard.md b/2_make_a_dashboard.md new file mode 100644 index 00000000000..b6650ba8655 --- /dev/null +++ b/2_make_a_dashboard.md @@ -0,0 +1,400 @@ +# Module 2: Make a Dashboard + +It is now time to learn about the Odoo JavaScript framework in its entirety, as +used by the web client. This document is a complete standalone project, in which +we will implement a dashboard client action. It is mostly an excuse to discover +and practice many features of the odoo web framework! + +![Overview](_images/overview_02.png) + +To get started, you need a running Odoo server and a development environment +setup. Before getting into the exercises, make sure you have a working setup. +Start your odoo server with this repository in the addons path, then install the +`awesome_dashboard` addon. + +For this project, we will start from the empty dashboard provided by the +`awesome_dashboard` addon. Then, we will progressively add features to it using +the Odoo JavaScript framework. + +Note that a lot of theory is covered in the slides for this event. Also, don't +hesitate to ask questions! + +## Content + +- [1. A Common Layout](#1-a-common-layout) +- [2. Add some buttons for quick navigation](#2-add-some-buttons-for-quick-navigation) +- [3. Add a dashboard item](#3-add-a-dashboard-item) +- [4. Call the server, add some statistics](#4-call-the-server-add-some-statistics) +- [5. Cache network calls, create a service](#5-cache-network-calls-create-a-service) +- [6. Display a pie chart](#6-display-a-pie-chart) +- [7. Periodic Updates](#7-periodic-updates) +- [8. Lazy loading the dashboard](#8-lazy-loading-the-dashboard) +- [9. Making our dashboard generic](#9-making-our-dashboard-generic) +- [10. Making our dashboard extensible](#10-making-our-dashboard-extensible) +- [11. Add and remove dashboard items](#11-add-and-remove-dashboard-items) +- [12. Going further](#12-going-further) + +## 1. A Common Layout + +Most screens in the Odoo web client uses a common layout: a control panel on +top, with some buttons, and a main content zone just below. This is done using +the `Layout` component, available in `@web/search/layout`. + +1. Update the `AwesomeDashboard` component located in + `awesome_dashboard/static/src/` to use the `Layout` component. You can use + `{controlPanel: {} }` for the display props of the `Layout` component. +2. Add a className prop to `Layout`: `className="'o_dashboard h-100'"` +3. Add a `dashboard.scss` file in which you set the `background-color` of + .`o_dashboard` to gray (or your favorite color) +4. Open http://localhost:8069/odoo, then open the Awesome Dashboard app, and see + the result. + +![Layout component](_images/new_layout.png) + +#### See also + +- Example: + [use of Layout in client action](https://github.com/odoo/odoo/blob/master/addons/web/static/src/webclient/actions/reports/report_action.js) + and + [template](https://github.com/odoo/odoo/blob/master/addons/web/static/src/webclient/actions/reports/report_action.xml) +- Example: + [use of Layout in kanban views](https://github.com/odoo/odoo/blob/master/addons/web/static/src/views/kanban/kanban_controller.xml) + +## 2. Add some buttons for quick navigation + +One important service provided by Odoo is the `action` service: it can execute +all kind of standard actions defined by Odoo. For example, here is how one +component could execute an action by its xml id: + +```js +import { useService } from "@web/core/utils/hooks"; +... +setup() { + this.action = useService("action"); +} +openSettings() { + this.action.doAction("base_setup.action_general_configuration"); +} +... +``` + +Let us now add two buttons to our control panel: + +1. A button `Customers`, which opens a kanban view with all customers (this + action already exists, so you should use its + [xml id](https://github.com/odoo/odoo/blob/1f4e583ba20a01f4c44b0a4ada42c4d3bb074273/odoo/addons/base/views/res_partner_views.xml#L510)). +2. A button `Leads`, which opens a dynamic action on the `crm.lead` model with a + list and a form view. Follow the example of + [this use of the action service](https://github.com/odoo/odoo/blob/ef424a9dc22a5abbe7b0a6eff61cf113826f04c0/addons/account/static/src/components/journal_dashboard_activity/journal_dashboard_activity.js#L28-L35). + +![Navigation buttons](_images/navigation_buttons.png) + +**See also:** +[code: action service](https://github.com/odoo/odoo/blob/master/addons/web/static/src/webclient/actions/action_service.js) + +## 3. Add a dashboard item + +Let us now improve the content of this dashboard. + +1. Create a generic `DashboardItem` component that display its default slot in a + nice card layout. It should take an optional `size` number props, that + default to 1. The width should be hardcoded to `(18*size)rem`. +2. Add two cards to the dashboard. One with no size, and the other with a size + of 2. + +![Dashboard items](_images/dashboard_item.png) + +**See also:** +[Owl slot system](https://github.com/odoo/owl/blob/master/doc/reference/slots.md) + +## 4. Call the server, add some statistics + +Let’s improve the dashboard by adding a few dashboard items to display real +business data. The `awesome_dashboard` addon provides a +`/awesome_dashboard/statistics` route that is meant to return some interesting +information. + +To call a specific controller, we need to use the `rpc` function. This function +`rpc(route, params, settings)` is the low level communication code that will +create a network request to the server in jsonrpc, then will return a promise +with the result. A basic request could look like this: + +```js +import { rpc } from "@web/core/network/rpc"; +... +setup() { + onWillStart(async () => { + const result = await rpc("/my/controller", {a: 1, b: 2}); + // ... + }); +} +... +``` + +1. Update Dashboard so that it uses the `rpc` function. +2. Call the statistics route `/awesome_dashboard/statistics` in the + `onWillStart` hook. +3. Display a few cards in the dashboard containing: + - Number of new orders this month + - Total amount of new orders this month + - Average amount of t-shirt by order this month + - Number of cancelled orders this month + - Average time for an order to go from ‘new’ to ‘sent’ or ‘cancelled’ + +![Statistics](_images/statistics1.png) + +## 5. Cache network calls, create a service + +If you open the Network tab of your browser’s dev tools, you will see that the +call to `/awesome_dashboard/statistics` is done every time the client action is +displayed. This is because the `onWillStart` hook is called each time the +`Dashboard` component is mounted. But in this case, we would prefer to do it +only the first time, so we actually need to maintain some state outside of the +`Dashboard` component. This is a nice use case for a service! + +1. Register and import a `new awesome_dashboard.statistics` service. +2. It should provide a function `loadStatistics` that, once called, performs the + actual rpc, and always return the same information. +3. Use the `memoize` utility function from `@web/core/utils/functions` that + allows caching the statistics. +4. Use this service in the `Dashboard` component. +5. Check that it works as expected. + +#### See also + +- [Example: simple service](https://github.com/odoo/odoo/blob/master/addons/web/static/src/core/network/http_service.js) +- [Example: service with a dependency](https://github.com/odoo/odoo/blob/master/addons/web/static/src/core/network/http_service.js) + +## 6. Display a pie chart + +Everyone likes charts (!), so let us add a pie chart in our dashboard. It will +display the proportions of t-shirts sold for each size: S/M/L/XL/XXL. + +For this exercise, we will use [Chart.js](https://www.chartjs.org/). It is the +chart library used by the graph view. However, it is not loaded by default, so +we will need to either add it to our assets bundle, or lazy load it. Lazy +loading is usually better since our users will not have to load the chartjs code +every time if they don’t need it. + +1. Create a `PieChart` component. +2. In its `onWillStart` method, load chartjs, you can use the + [loadJs function](https://github.com/odoo/odoo/blob/1f4e583ba20a01f4c44b0a4ada42c4d3bb074273/addons/web/static/src/core/assets.js#L23) + to load `/web/static/lib/Chart/Chart.js`. +3. Use the `PieChart` component in a `DashboardItem` to display a + [pie chart](https://www.chartjs.org/docs/2.8.0/charts/doughnut.html) that + shows the quantity for each sold t-shirts in each size (that information is + available in the `/statistics` route). Note that you can use the size + property to make it look larger. +4. The `PieChart` component will need to render a canvas, and draw on it using + chart.js. You can use this code to create the pie chart: + + ```js + import { getColor } from "@web/core/colors/colors"; + ... + renderChart() { + const labels = Object.keys(this.props.data); + const data = Object.values(this.props.data); + const color = labels.map((_, index) => getColor(index)); + this.chart = new Chart(this.canvasRef.el, { + type: "pie", + data: { + labels: labels, + datasets: [ + { + label: this.props.label, + data: data, + backgroundColor: color, + }, + ], + }, + }); + } + ``` + +5. Make it work! + +![Pie Chart](_images/pie_chart.png) + +#### See also + +- Example: + [lazy loading a js file](https://github.com/odoo/odoo/blob/1f4e583ba20a01f4c44b0a4ada42c4d3bb074273/addons/web/static/src/views/graph/graph_renderer.js#L57) +- Example: + [rendering a chart in a component](https://github.com/odoo/odoo/blob/1f4e583ba20a01f4c44b0a4ada42c4d3bb074273/addons/web/static/src/views/graph/graph_renderer.js#L618) + +## 7. Periodic Updates + +Since we moved the data loading in a cache, it never updates. But let us say +that we are looking at fast moving data, so we want to periodically (for +example, every 10min) reload fresh data. + +This is quite simple to implement, with a `setTimeout` or `setInterval` in the +statistics service. However, here is the tricky part: if the dashboard is +currently being displayed, it should be updated immediately. + +To do that, one can use a `reactive` object: it is just like the proxy returned +by `useState`, but not linked to any component. A component can then do a +`useState` on it to subscribe to its changes. + +1. Update the statistics service to reload data every 10 minutes (to test it, + use 10s instead!) +2. Modify it to return a + [reactive](https://github.com/odoo/owl/blob/master/doc/reference/reactivity.md#reactive) + object. Reloading data should update the reactive object in place. +3. Update the `Dashboard` component to wrap the reactive object in a `useState` + +#### See also + +- [Documentation on reactivity](https://github.com/odoo/owl/blob/master/doc/reference/reactivity.md) +- [Example: Use of reactive in a service](https://github.com/odoo/odoo/blob/1f4e583ba20a01f4c44b0a4ada42c4d3bb074273/addons/web/static/src/core/debug/profiling/profiling_service.js#L30) + +## 8. Lazy loading the dashboard + +Let us imagine that our dashboard is getting quite big, and is only of interest +to some of our users. In that case, it could make sense to lazy load our +dashboard, and all related assets, so we only pay the cost of loading the code +when we actually want to look at it. + +One way to do this is to use `LazyComponent` (from @web/core/assets) as an +intermediate that will load an asset bundle before displaying our component. + +For example, it can be used like this: + +```js +export class ExampleComponentLoader extends Component { + static components = { LazyComponent }; + static template = xml` + + `; +} + +registry + .category("actions") + .add("example_module.example_action", ExampleComponentLoader); +``` + +1. Move all dashboard assets into a sub folder `/dashboard` to make it easier to + add to a bundle. +2. Create a `awesome_dashboard.dashboard` assets bundle containing all content + of the `/dashboard` folder. +3. Modify `dashboard.js` to register itself to the `lazy_components` registry + instead of `actions`. +4. In `src/dashboard_action.js`, create an intermediate component that uses + `LazyComponent` and register it to the `actions` registry. + +## 9. Making our dashboard generic + +So far, we have a nice working dashboard. But it is currently hardcoded in the +dashboard template. What if we want to customize our dashboard? Maybe some users +have different needs and want to see other data. + +So, the next step is to make our dashboard generic: instead of hard-coding its +content in the template, it can just iterate over a list of dashboard items. But +then, many questions come up: how to represent a dashboard item, how to register +it, what data should it receive, and so on. There are many different ways to +design such a system, with different trade-offs. + +For this tutorial, we will say that a dashboard item is an object with the +following structure: + +```js +const item = { + id: "average_quantity", + description: "Average amount of t-shirt", + Component: StandardItem, + // size and props are optionals + size: 3, + props: (data) => ({ + title: "Average amount of t-shirt by order this month", + value: data.average_quantity, + }), +}; +``` + +The `description` value will be useful in a later exercise to show the name of +items that the user can add to their dashboard. The `size` number is optional, +and simply describes the size of the dashboard item that will be displayed. +Finally, the `props` function is optional. If not given, we will simply give the +`statistics` object as data. But if it is defined, it will be used to compute +specific props for the component. + +The goal is to replace the content of the dashboard with something that look +like the following snippet: + +```xml + + + + + + +``` + +Note that the above example features two advanced features of Owl: dynamic +components and dynamic props. + +We currently have two kinds of item components: number cards with a title and a +number, and pie cards with some label and a pie chart. + +1. Create and implement two components: `NumberCard` and `PieChartCard`, with + the corresponding props. +2. Create a file `dashboard_items.js` in which you define and export a list of + items, using `NumberCard` and `PieChartCard` to recreate our current + dashboard. +3. Import that list of items in our `Dashboard` component, add it to the + component, and update the template to use a `t-foreach` like shown above. + + ```js + setup() { + this.items = items; + } + ``` + +And now, our dashboard template is generic! + +## 10. Making our dashboard extensible + +However, the content of our item list is still hardcoded. Let us fix that by +using a registry: + +1. Instead of exporting a list, register all dashboard items in a + `awesome_dashboard` registry +2. Import all the items of the `awesome_dashboard` registry in the `Dashboard` + component + +The dashboard is now easily extensible. Any other Odoo addon that wants to +register a new item to the dashboard can just add it to the registry. + +## 11. Add and remove dashboard items + +Let us see how we can make our dashboard customizable. To make it simple, we +will save the user dashboard configuration in the local storage, so that we +don’t have to deal with the server for now. + +For this exercise, the dashboard configuration will be saved as a list of +removed item ids. + +1. Add a button in the control panel with a gear icon to indicate that it is a + settings button. +2. Clicking on that button should open a dialog. +3. In that dialog, we want to see a list of all existing dashboard items, each + with a checkbox. +4. There should be a Apply button in the footer. Clicking on it will build a + list of all item ids that are unchecked. +5. We want to store that value in the local storage. +6. And modify the Dashboard component to filter the current items by removing + the ids of items from the configuration. + +![Item configurations](_images/items_configuration.png) + +## 12. Going further + +Here is a list of some small (and not so small) improvements you could try to do +if you have the time: + +- Make sure your application can be translated (with `_t`). +- Clicking on a section of the pie chart should open a list view of all orders + that have the corresponding size. +- Save the content of the dashboard in a user setting on the server! +- Make it responsive: in mobile mode, each card should take 100% of the width. +- Update the dashboard in real time by using the bus (hard) diff --git a/3_intro_to_website.md b/3_intro_to_website.md new file mode 100644 index 00000000000..87dd35f69f2 --- /dev/null +++ b/3_intro_to_website.md @@ -0,0 +1,648 @@ +# Module 3: Learn the Website Framework + +In this chapter, we will learn all about the website-related framework(s). + +To understand the website codebase and how to hook yourself into it, you need to +take a few key factors into account: +- We make a website *builder*. That means that you should give the keys to the +user to design their site the way they intend: what can they drop on the page +and where? What can they change and how? What can they delete and how do they +re-add it? Not everything is customizable, but a lot is. That also means that +you cannot take a specific element, attribute or class as granted if there is +any way to move or get rid of them. +- The website module can be split into 2 scopes: there is the end-user-facing +frontend (let's call it the frontend), and there is the editor interface for the +website administrator or designer (let's call it the edit mode). + +On the frontend, we use `Interactions` to inject dynamic behaviors with +JavaScript. Interactions are mostly independent from the OWL framework, but they +use some similar syntax. + +In edit mode, we both supercharge the frontend interactions to add, remove or +alter some features, and we create *options* to give the keys to the designer. +Those options take full advantage of the HTML Builder framework, which relies on +OWL (for `OptionComponent`s) and on the HTML Editor's `Plugin`s. + +But enough introduction, let's stop reading and start doing! +To get started, you need a running Odoo server and a development environment +setup. Before getting into the exercises, make sure you have a working setup. +Start your Odoo server with this repository in the addons path, then install the +`awesome_website` addon. + +For this project, we will start with a very simple interaction, then create a +snippet (a block that is droppable on the page) and see how to work with it in +edit mode. Finally, we will create a more complex snippet with asynchronous +logic and services. + +💡 **Tip**: we will be talking about several features. What is their respective +scopes? +- Interaction / Colibri => `/web` (used on the frontend: portal / website) +- Builder options => `/html_builder` (used by website and mass mailing) +- Edit interactions => `/website` +- Preview interactions => `/website` + + +## Content + +- [Example of an interaction](#example-of-an-interaction) +- [1. Create an interaction](#1-create-an-interaction) +- [2. Create a cursor highlighter](#2-create-a-cursor-highlighter) +- [3. Create a before/after image snippet](#3-create-a-beforeafter-image-snippet) +- [4. Set up the before/after image interaction](#4-set-up-the-beforeafter-image-interaction) +- [5. Make an edit interaction](#5-make-an-edit-interaction) +- [6. Add builder options](#6-add-builder-options) +- [7. Use an OWL component](#7-use-an-owl-component) +- [8. Design a weather forecast snippet](#8-design-a-weather-forecast-snippet) +- [9. Consolidate your interaction with a service](#9-consolidate-your-interaction-with-a-service) +- [10. Add a test for your interaction](#10-add-a-test-for-your-interaction) +- [11. Add a test for your option](#11-add-a-test-for-your-option) + +## Example of an interaction + +What does an interaction look like? +It is a class that takes a `selector`, uses a life cycle (available methods are +`setup`, `willStart`, `start` and `destroy`), and attaches dynamic content +through the `dynamicContent` property, with available directives being `t-out` +(to output text or markup), `t-on-*` for events, `t-att-*` for attributes +(`t-att-class`, `t-att-style`, etc.) and `t-component` to attach a component. + +```js +import { registry } from "@web/core/registry"; +import { Interaction } from "@web/public/interaction"; + +export class MyCustomInteraction extends Interaction { + static selector = ".my-selector"; + dynamicContent = { + _root: { + "t-out": () => this.weather.temperature, + "t-att-style": () => ({ + "background-color": this.freezing ? "blue" : "yellow", + }), + }, + ".button": { + "t-on-click": this.doSomething, + } + }; + + setup() { + this.freezing = false; + } + + async willStart() { + this.weather = await rpc("/awesome_website/weather"); + this.freezing = this.weather.temperature < 0; + } + + doSomething() { + console.log("Hello world"); + } +} + +registry.category("public.interactions").add( + "awesome_website.my_custom_interaction", + MyCustomInteraction +); +``` + +Notice how the interaction is added to the `public.interactions` registry. + +The `dynamicContent` directives are attached after `willStart` has resolved, and +are then recomputed every time an event is triggered (or through other +Interaction helpers). + +💡 **Tips**: +- interactions can only be attached inside the `#wrapwrap` element (or within +the `body` if there is no `#wrapwrap`). +- Do you need a complex selector with `:has(*)` or `:not(:has(*))`? Use the +static properties `selectorHas` and `selectorNotHas` instead: as interactions +should work for end-users (as opposed to Odoo's customers), we cannot rely on +evergreen browsers and tend to code defensively. + +💡 **Tip**: Have a look at the [Interaction](https://github.com/odoo/odoo/blob/19.0/addons/web/static/src/public/interaction.js) +base class file to dive deeper into the implementation details. As it is +thoroughly commented, this is a good starting point to have a better grasp on +how it works and what the available methods are. + +## 1. Create an interaction + +Let's start gently with an interaction that updates the content of the `
` +element with the current time. + +![The date appears on the page](_images/website_date.png) + +1. Create the `Main` interaction inside +`awesome_website/static/src/interactions` and target the `
` tag. +2. Add a `dynamicContent` property. In our case, we can use the magic selector +`_root` to target the selector defined in the previous step, and output the +current date and time. +3. Add the file to the `web.assets_frontend` bundle in the manifest. +4. Open http://localhost:8069/ to see the result. + +## 2. Create a cursor highlighter + +Now that we have a basic understanding of the interactions, we can go a step +further. Let's enhance our awesome website with a cursor highlighter, a colored +dot that follows the user's cursor. + +![A yellow cursor highlighter](_images/website_cursor_highlighter.png) + +1. We are going to insert a new element on the page through the interaction. For +the selector, you can choose to focus the `#wrapwrap` directly. +2. In `setup()`, set up the variables that you will need: the x and y +coordinates and the new highlighter element (use the `.x_mouse_follower` class, +the styles are already ready). +3. In `start()`, append the highlighter. Look for the `insert()` method from the +`Interaction` class: it will automatically remove the element once the +interaction is destroyed. (This is also the reason why you need to append +elements in `start`: `destroy` is only called after an interaction has started. +If you added it in the `setup` and for some reason the interaction never +started, the element would not be removed.) +4. Now, for the magic to happen, we need to listen to the `pointermove` event +and track our cursor position. +5. You also need to adapt the highlighter's position. You could do that in the +handler of the `pointermove` event, but the position would not be cleaned on +destroy*. It is better to use the `dynamicContent` with the `t-att-style` +directive to achieve our goal! Everything that is done through the +`dynamicContent` will be reset upon destroy. + +🤓☝️ *Well actually, given that the element itself will be cleaned up, it +doesn't matter. But keep in mind that it is good practice to apply your +modifications through the `dynamicContent` property! + +💡 **Tip**: You want to debounce the `pointermove` event? Have a look at the +`debounced` method from the `Interaction` base class. + +## 3. Create a before/after image snippet + +Let's back up a little: we know how to attach interactions to existing elements, +and this is more than enough for plenty of things that you would want to do both +in the Website module and in modules with a dependency on the Portal module. +But you may have noticed that website pages work with building blocks (that we +call snippets): blocks that you can drag and drop in edit mode onto your page, +and that you can edit with options afterwards. + +How can you create a new snippet from scratch and make it available for website +designers? + +Snippet templates are defined in `/views/snippets` as XML files: those are QWeb +templates, processed by the server. + +We are not going to cover all the possibilities offered by QWeb templates, let's +use a standard structure. Open the file `s_image_comparison.xml` and insert your +HTML structure for the before/after image snippet. + +
+ Check your code against the example + + ```xml +
+
+
+ + + +
+
+
+
+ ``` + + 💡 **Tips**: Notice that we use a `
` tag: this is necessary if you want to add a + block snippet, and it will give you a few default options in edit mode. On the + contrary, if you only want to add an _inner snippet_ (a snippet that you can add + inside any other block), you don't necessarily need the `
`. + +
+ + +A stylesheet is defined in `/static/src/snippets/s_image_comparison/000.scss`, +but it does not work for now. You need to register the asset through the XML +view like so: + +```xml + + web.assets_frontend + awesome_website/static/src/snippets/s_image_comparison/000.scss + +``` + +💡 **Tips**: Why "000"? That is a versioning system so that we do not break +existing customers websites if we want to introduce breaking changes. A complete +revamp of the styles would then be called `001.scss`. +We also have some `000.xml` for templates called throughout the life of the +interaction. + +Finally, you must add your snippet XML in the manifest, among the `data` files. + +The new snippet is ready, but you still cannot use it: you have to add it to a +snippet category so that it appears when a user opens the snippet dialog. + +> *The snippets categories as they appear on the interface* +> +> ![The snippets groups in the interface](_images/builder_snippet_groups.png) + +All snippet categories are defined in `website/views/snippets/snippets.xml`. If +you are adding a snippet directly in the website module, you can edit that file. +If, like in our `awesome_website` case here, you need to add it from another +module, you can simple xpath your way into it. +Have a look at the original website file to understand the syntax. + +For our `s_image_comparison` snippet, I think the "Images" category would be a +perfect fit. Create a `snippets.xml` file and write your xpath. +For demo purposes, let's add the snippet preview in the 1st place, just before +`website.s_picture`. Feel free to add as many **keywords** as you wish: those +keywords are used to search for a snippet in the snippets dialog. + +> *Searching for the snippet* +> +> ![Searching for the snippet](_images/builder_add_snippet_search.png) + +
+ Unfold to see the full xpath + + ```xml + + ``` + +
+ +To confirm that it works, open your website homepage in edit mode, drag or click +on the "Images" category and select the image comparison block. + +## 4. Set up the before/after image interaction + +Now that we have the snippet, we can create its interaction. By convention, we +add it in the same folder as the `000.scss`. + +If you have a similar structure to what I suggested earlier, this is going to be +a simple interaction: you just need to listen for the slider `input` event and +update the `--slider-position` CSS custom property. + +Once your interaction is running, you will see that it doesn't work in edit +mode. That's normal: we have a separate registry category for edit interactions. +If you want exactly the same behavior for the interaction in edit mode as on the +frontend, just add it to the `public.interactions.edit` registry: + +```js +registry.category("public.interactions.edit").add("awesome_website.image_comparison", { + Interaction: ImageComparison, +}); +``` + +💡 **Tip**: It's not working? Did you add your file to the manifest? + +## 5. Make an edit interaction + +What if you want to tweak the interaction in edit mode? Maybe you want to +prevent an event from firing but still keep some parts of the interaction. Or +maybe you need to add some features on top of the public ones. We've got you +covered. + +Because in most cases an edit interaction derives from a public interaction, and +the public interaction could itself extend another interaction (which could have +its own edit interaction), edit interactions overrides are written as +**mixins**. That allows the builder to apply all the overrides. + +
+ ❓ It's not clear + + | Public | Edit | + | --------------- | ------------------ | + | Foo | FooEdit (override) | + | Bar extends Foo | BarEdit (override) | + + Where `Bar` applies on `.element`. + When you edit the page, if `.element` is on the page, it will apply both + `FooEdit` and `BarEdit` on `Bar`, in that order. + + **Why not extensions?** + + If we had used extends, it would not have been possible to apply both + overrides without copying code. + +
+ +Here, for the sake of this demo, we consider that we don't want the slider to be +actionable in edit*. Create `image_comparison.edit.js` and override your public +interaction! + +🤓☝️ *Well actually, this could be achieved by disabling the interaction +altogether. But for the sake of the exercise, let's imagine there are other +features that we want to keep. + +Don't forget to add your mixin to the edit interactions registry category, and +remove that same line from the base interaction file. + +```js +registry.category("public.interactions.edit").add("awesome_website.image_comparison", { + Interaction: ImageComparison, + mixin: ImageComparisonEdit, +}); +``` + +To completely block the slider, we should also set `pointer-events: none`, +otherwise you can still move the slider thumb. You can either add the Bootstrap +class `.pe-none` through the edit interaction, or add the rule to the file +`000.edit.scss`. + +💡 **Tip**: the file is already set to override the input's style. That allows +to click on the images to replace them and have access to all the standard image +options. Comment out the CSS rules to see the difference. + +You must add the edit files to the `website.assets_inside_builder_iframe` +bundle. + +```py +'website.assets_inside_builder_iframe': [ + 'awesome_website/static/src/snippets/**/*.edit.*' +], +``` + + +💡 **Tip**: overriding `dynamicContent` in edit interactions +- Unless that is exactly what you want, do not override the whole +`dynamicContent`! Always keep a copy of the original with +`dynamicContent = { ...this.dynamicContent, { ... } }`, or with +`patchDynamicContent` (in the setup). +Prefer `patchDynamicContent` if you add or override an entry of a selector whose +other entries you want to keep. + + +💡 **Tip**: When are interactions restarted in edition? +- Through `getConfigurationSnapshot`, the `websiteEditService`'s patch on +`Interaction` restarts an interaction if it detects a change in the `dataset` or +in the style of the interaction root element. +- If you have any other modification (class, child...), it won't restart the +interaction. This is to avoid stopping and restarting every interaction all the +time. +- Then `shouldStop` returns `true` if the snapshot returned was different from +what it used to be, or if `isImpactedBy` returns `true` (by default it always +returns `false`) + + If you do need to override that default behavior, you have 3 tools + available: + + - override `getConfigurationSnapshot` (you can check existing + implementations, e.g. on `DynamicSnippetCarouselEdit`) + - override `isImpactedBy`, typically if you should restart depending on a + child (present or not), or possibly depending on a class. Note that if you + check if a class is present in `isImpactedBy`, the interaction will always + restart if the class is present. If you need to restart only when the class + is toggled, you should use `getConfigurationSnapshot`. + - override `shouldStop`. The most common override on that one is to return + `true` all the time, if all your options should trigger a restart of the + interaction. Note that it does mean it will restart *all* the time, so try + the other solutions first if they're not too heavy. + +## 5bis. Make a preview interaction + +This part is completely website-specific: when you drop a section snippet on a +website page (or an HTML field), it opens the snippets preview dialog. + +There might be cases when you want the preview to look a very specific way or +have a limited behavior (compared to its actual, full behavior). Typically, if +the actual interaction implements a behavior when the user scrolls the page or +when they click on a button (e.g. carousels), you may want to show a similar +behavior on hover and on focus of the preview. + +💡 **Tip**: when you add `:hover` rules, always consider whether you should also +add `:focus-visible` to account for keyboard accessibility. + +By convention, we suffix those files (`.js`, `.scss`) with `*.preview.*`. + +SCSS preview files must be registered in the manifest, in the +`html_builder.iframe_add_dialog` bundle. + +JS preview interactions are, just like edit interactions, mixins and must be +registered through the registry, in the category `public.interactions.preview`. + +️📃 In the case of our comparing slider, we could for instance add an animation +on hover to move the slider to the left and to the right. + +## 6. Add builder options + +What we have been calling the "edit mode" since the beginning of this tutorial +actually depends on the html_builder module. What it does is embedding the +frontend in an iframe, and rendering options around it (or sometimes on top of +it, e.g. the editor toolbar when you select some text). + +You probably already saw that you have some default options available when you +click on your snippet. If you paid attention earlier, you remember that we used +a `
` to build our snippet: those default options are the ones that +appear when the current target is a section. + +While interactions are temporary and reset on page load, options apply permanent +changes that will be saved in the DOM, or sometimes in the database. + +Those options are defined as OWL templates with a set of Builder components +(`BuilderRow`, `BuilderButton`, `BuilderTextInput`, `BuilderColorPicker`, etc.). +Each individual option is a `BuilderRow` encapsulating one or several other +component(s). + +Those options take an `action` as prop, with optionally some `actionParams` or/ +and some `actionValue`. Actions are defined in JavaScript and extend +`BuilderAction`. + +💡 **Tip**: 4 shorthand actions are available: `classAction`, `styleAction`, +`attributeAction` and `dataAttributeAction`. Have a look in the codebase to see +how they're used! + +💡 **Tip**: some complex actions may require a deep dive into the way +`BuilderAction` works. Have a look at its implementation in +`addons/html_builder/static/src/core/builder_action.js`: it is heavily +commented / documented. + +A plain example would be a checkbox to turn a class on or off: + +```xml + + + + + +``` + +To register the option, you then have to xpath the option into the builder +options at the place you want it to appear. If you need to add custom logic, you +can create a `Plugin` and potentially a custom `BuilderAction`. + +```xml + + + + + +``` + +If you need a custom action, you should update the option template: +```xml + +``` + + +```js +class MyToggleOptionPlugin extends Plugin { + static id = "awesome_website.MyToggle"; + + resources = { + builder_actions: { MyToggleAction }, + }; +} + +class MyToggleAction extends BuilderAction { + static id = "myToggleAction"; + apply({ editingElement, params: { mainParam }}) { + ... + // Handle mainParam (=== "my-param") + } +} + +registry.category("website-plugins").add(MyToggleOptionPlugin.id, MyToggleOptionPlugin); +``` + +
+ More complex options that rely on a state can also extend + `BaseOptionComponent`, but this is not necessary here. + + ```js + export class MyToggleOption extends BaseOptionComponent { + static id = "my_toggle_option"; // Matches the XML tag name + static template = "awesome_website.MyToggleOption" + + setup() { + super.setup(); + this.state = useDomState((editingElement) => { + // handle state + }); + // custom logic + }; + } + + registry.category("website-options").add(MyToggleOption.id, MyToggleOption); + ``` +
+ + +️📃 Now it's your turn. Which options do we need for our snippet to shine? Choose +one or two and try to make them work. Here are some ideas: + +- Choose between vertical and horizontal slide +- Add before / after labels +- Change the appearance of the corners (rounded, squared, cut...) +- Set the default value of the slider on some other value (25%, 75%...) +- Change the color of the handle +- Adapt the handle size +- Set a different icon on the slider handle +- ... + +## 7. Use an OWL component + +If you need some reusable component and you do not care about it being editable, +you may want to use an OWL component. You can do that very easily in 2 ways: + +- through the Interaction's `t-component` directive. +- with an `` custom element. + +The syntax for `t-component` is: `"t-component": () => [Comp, { ...props }]`, +where the props are optional. + +To be able to use ``, you first have to register your component +in the `public_components` registry category. The element takes a `name` (the +one you gave in the registry) and a `props` attribute, which should be formatted +in JSON. + +⚠️ **Important**: these are valid approaches especially outside of the +boundaries of the HTML builder. But as soon as your component is available on +the builder, you should absolutely ask yourself whether an OWL component is +really the way to go! Your component will not be editable, you will not have +many ways to add website builder options on it, the end user will not have any +editing right on the text within it... There are always exceptions, but it is +generally a bad idea. + +️📃 Create a basic component with one or two props, and call it through an +interaction, then using ``. + +## 8. Design a weather forecast snippet + +## 9. Consolidate your interaction with a service + +## 10. Add a test for your interaction + +You should always test your new features and your fixes, to make sure that the +expected behavior keeps working in the future. To do that, we use the Odoo-built +unit test suite Hoot. + +To test an interaction, first create a file that ends in `.test.js` (by +convention). + +You then have to register the interaction you want to test in +`setupInteractionWhiteList()`, with the ID you gave it for the +`public.interactions` registry. + +Finally, call `await startInteractions("some HTML template")` to run the +interaction on some given HTML. +This function returns `{ core }`, which is the public interactions service. In +tests, we mainly use it in 2 ways: +- Control the number of interactions started (it will depend on the injected +HTML and the white-listed interactions): +`expect(core.interactions).toHaveLength(1);` +- If you call `startInteractions` with the option `{ waitForStart: false }`, you +can await `core.isReady` to wait for it to start. + +Note that if you need to test things before the interaction starts, in edit mode +or in translation, the function takes an optional object parameter with 3 +optional boolean keys: +`{ waitForStart: true, editMode: false, translateMode: false }`. + +Don't forget to add your file to the manifest. +- The `web.assets_unit_tests` bundle registers all the test files. +- You also need to register the minimum for your interactions to start in the +`web.assets_unit_tests_setup` bundle. Typically: at least the interaction JS +definition. + +️📃 Take some inspiration on existing modules and create a test for one of your +interactions. For instance, test the image comparison interaction to make sure +that the slider position is updated after an input change. + +## 11. Add a test for your option + +The process is very similar to test your builder options. +You should know a few helper functions we use to create our tests: + +- On website-related options, `defineWebsiteModels()` must be called at the +start of your file. +- `setupHTMLBuilder(content, options)`, as its name suggests, sets up a testing +builder environment. Both parameters are optional. The content is an HTML string +to start the test with some content already present. The options is an object +determining: + - `editableSelector`: `"#wrapwrap"` by default. + - `headerContent`: allows to add some header (before the main content). + Empty string by default. + - `snippets`: object that defines how test snippets should appear in the + sidebar. What group should the test add (`snippet_groups`), what template + should the test add as a group snippet and in which group to add it + (`snippet_structure`), and what template should it use as an inner snippet + (`snippet_content`). + - `snippetContent`: array of HTML strings, as a shorthand if you don't need + the other keys of `snippets`. + - `dropzoneSelectors`: object, or array of objects defining new dropzone + selectors (see the `dropzone_selectors` resource). + - `styleContent`: CSS style (as a string) to use for the test. By default, + the styles of the snippets are not used. + - `iframeLangDir`: language direction of the iframe content. `ltr` or `rtl`. + `ltr` by default. +- On website-related options, you should rather use `setupWebsiteBuilder` or +`setupWebsiteBuilderWithSnippet` if you want to setup a page with existing +snippets. + +️📃 Take some inspiration on existing website builder tests and create a test for +an option you implemented. diff --git a/4_customize_fields_views.md b/4_customize_fields_views.md new file mode 100644 index 00000000000..574c14d3905 --- /dev/null +++ b/4_customize_fields_views.md @@ -0,0 +1,114 @@ +# Module 4: Customize Fields and Views + +This project is a sequence of (mostly) independant exercises designed to teach +how to work with fields and views in Odoo. + +The setting for this project is an addon that manages a shelter and various kind +of animals that are adopted. All the code is located in the `awesome_shelter` +addon. + +To get started, you need a running Odoo server and a development environment +setup. Before getting into the exercises, make sure you have a working setup. +Start your odoo server with this repository in the addons path, then install the +`awesome_shelter` addon. + +The `awesome_shelter` addon introduces a few useful models: + +- `awesome_shelter.animal` represents a specific animal that has been rescued +- `awesome_shelter.animal_race` is a specific animal race, such as german + sheperd. +- `awesome_shelter.animal_type` is a small model that help us categorize + animals, such as "dogs" or "cats" + +## Content + +- [1. Add a ribbon](#1-add-a-ribbon) +- [2. Display a custom view banner](#2-display-a-custom-view-banner) +- [3. Subclass a char field](#3-subclass-a-char-field) +- [4. Customize a status bar widget](#4-customize-a-status-bar-widget) +- [5. Display pictogram and type in list view](#5-display-pictogram-and-type-in-list-view) +- [6. Extend a view](#6-extend-a-view) + +## 1. Add a ribbon + +In the kanban and form view for an animal, it would be cool to have a visual +feedback showing that the animal is adopted. Let us do that by adding a ribbon! + +This can be done by using an existing widget named `web_ribbon` + +1. modify the form view to add a `web_ribbon` widget, which should be visible + only when the state is `adopted` +2. do the same for the kanban view + +![Add a ribbon](_images/add_ribbon.png) + +## 2. Display a custom view banner + +The previous exercise showed how to use a widget. In this exercise, we will +implement a new widget from scratch. + +In the form view for the animal model, we want to display a banner with a +message when the animal has been adopted for more than 6 months. + +1. create a new component named `LongStayBanner` +2. register it in the `view_widgets` registry +3. Use it in the form view: + + ```xml + + ``` + +![Custom view banner](_images/banner.png) + +## 3. Subclass a char field + +Let us now see how to specialize a field. + +1. Subclass the charfield component from `@web/views/fields/char/char_field` +2. register it in the fields registry, and use the field in the form view +3. create a new template to add a button when the name is not set (this can be + done with `xpaths` or using `t-call`) +4. when the button is clicked, choose a name from a hardcoded list of pet names, + and set the value of the field + +![Charfield subclass](_images/subcharfield.png) + + +## 4. Customize a status bar widget + +Let us now customize the status bar widget to display a more festive effect when +an animal is adopted, which usually happens when the shelter staff clicks on the +`adopted` status. + +1. subclass the status bar field +2. register it in the fields registry, and use the field in the form view +3. override the `selectItem` to display a celebratory rainbow man when the + animal is adopted + +![Rainbowman](_images/rainbowadopted.png) + +## 5. Display pictogram and type in list view + +Animals have a type (cat, dog, etc). Those types can have a pictogram. We want +an extension of a one2many field widget to display the name and the pictogram a +little bit like the one2many_avatar, as a single element instead of two columns +in list view. + +1. subclass the many2one field +2. use it in the list view +3. modify your field to display a pictogram if it is defined + +![Rainbowman](_images/pictogram.png) + +## 6. Extend a view + +Finally, it sometimes happens that we need to work with views instead of fields. + +For this exercise, we want to modify the kanban view to reload its data every 10 +seconds or so. This is because the shelter staff want to display it on a small +external screen as a visual monitoring tool. + +1. Subclass the kanban view +2. register your kanban view in the `views` registry, and use it (with + `js_class` attribute) in the animal kanban view +3. Modify the code to reload every 10s diff --git a/5_build_a_clicker_game.md b/5_build_a_clicker_game.md new file mode 100644 index 00000000000..f5e163f84d1 --- /dev/null +++ b/5_build_a_clicker_game.md @@ -0,0 +1,449 @@ +# Module 5: Build a Clicker Game + +For this project, we will build together a +[clicker game](https://en.wikipedia.org/wiki/Incremental_game), completely +integrated with Odoo. In this game, the goal is to accumulate a large number of +clicks, and to automate the system. The interesting part is that we will use the +Odoo user interface as our playground. For example, we will hide bonuses in some +random parts of the web client. + +To get started, you need a running Odoo server and a development environment +setup. Before getting into the exercises, make sure you have a working setup. +Start your odoo server with this repository in the addons path, then install the +`awesome_clicker` addon. + +The code for this addon is currently empty, but we will progressively add +features to it. + +![Final Result](_images/final.png) + +## Content + +- [1. Create a systray item](#1-create-a-systray-item) +- [2. Count external clicks](#2-count-external-clicks) +- [3. Create a client action](#3-create-a-client-action) +- [4. Move the state to a service](#4-move-the-state-to-a-service) +- [5. Use a custom hook](#5-use-a-custom-hook) +- [6. Humanize the displayed value](#6-humanize-the-displayed-value) +- [7. Add a tooltip in ClickValue component](#7-add-a-tooltip-in-clickvalue-component) +- [8. Buy ClickBots](#8-buy-clickbots) +- [9. Refactor to a class model](#9-refactor-to-a-class-model) +- [10. Notify when a milestone is reached](#10-notify-when-a-milestone-is-reached) +- [11. Add BigBots](#11-add-bigbots) +- [12. Add a new type of resource: power](#12-add-a-new-type-of-resource-power) +- [13. Define some random rewards](#13-define-some-random-rewards) +- [14. Provide a reward when opening a form view](#14-provide-a-reward-when-opening-a-form-view) +- [15. Add commands in command palette](#15-add-commands-in-command-palette) +- [16. Add yet another resource: trees](#16-add-yet-another-resource-trees) +- [17. Use a dropdown menu for the systray item](#17-use-a-dropdown-menu-for-the-systray-item) +- [18. Use a Notebook component](#18-use-a-notebook-component) +- [19. Persist the game state](#19-persist-the-game-state) + +## 1. Create a systray item + +To get started, we want to display a counter in the systray. + +1. Create a `clicker_systray_item.js` (and `xml`) file with a hello world Owl + component. +2. Register it to the systray registry, and make sure it is visible. +3. Update the content of the item so that it displays the following string: + `Clicks: 0`, and add a button on the right to increment the value. + +![Systray](_images/systray.png) + +And voila, we have a completely working clicker game! + +#### See also + +- [Documentation on the systray registry](https://www.odoo.com/documentation/master/developer/reference/frontend/registries.html#frontend-registries-systray) +- [Example: adding a systray item to the registry](https://github.com/odoo/odoo/blob/c4fb9c92d7826ddbc183d38b867ca4446b2fb709/addons/web/static/src/webclient/user_menu/user_menu.js#L41-L42) + +## 2. Count external clicks + +Well, to be honest, it is not much fun yet. So let us add a new feature: we want +all clicks in the user interface to count, so the user is incentivized to use +Odoo as much as possible! But obviously, the intentional clicks on the main +counter should still count more. + +1. Use `useExternalListener` to listen on all clicks on `document.body`. +2. Each of these clicks should increase the counter value by 1. +3. Modify the code so that each click on the counter increased the value by 10 +4. Make sure that a click on the counter does not increase the value by 11! +5. Also additional challenge: make sure the external listener capture the + events, so we don’t miss any clicks. + +#### See also + +- [Owl documentation on useExternalListener](https://github.com/odoo/owl/blob/master/doc/reference/hooks.md#useexternallistener) +- [MDN page on event capture](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events#event_capture) + +## 3. Create a client action + +Currently, the current user interface is quite small: it is just a systray item. +We certainly need more room to display more of our game. To do that, let us +create a client action. A client action is a main action, managed by the web +client, that displays a component. + +1. Create a `client_action.js` (and `xml`) file, with a hello world component. +2. Register that client action in the action registry under the name + `awesome_clicker.client_action` +3. Add a button on the systray item with the text "Open". Clicking on it should + open the client action `awesome_clicker.client_action` (use the action + service to do that). +4. To avoid disrupting employees workflow, we prefer the client action to open + within a popover rather than in fullscreen mode. Modify the `doAction` call + to open it in a popover. + +You can use target: `"new"` in the doAction to open the action in a popover: + +```js +{ + type: "ir.actions.client", + tag: "awesome_clicker.client_action", + target: "new", + name: "Clicker" +} +``` + +![Client action](_images/client_action.png) + +**See also:** +[How to create a client action](https://www.odoo.com/documentation/master/developer/howtos/javascript_client_action.html#howtos-javascript-client-action) + +## 4. Move the state to a service + +For now, our client action is just a hello world component. We want it to +display our game state, but that state is currently only available in the +systray item. So it means that we need to change the location of our state to +make it available for all our components. This is a perfect use case for +services. + +1. Create a `clicker_service.js` file with the corresponding service. +2. This service should export a reactive value (the number of clicks) and a few + functions to update it: + ```js + const state = reactive({ clicks: 0 }); + ... + return { + state, + increment(inc) { + state.clicks += inc + } + }; + ``` +3. Access the state in both the systray item and the client action (don’t forget + to `useState` it). Modify the systray item to remove its own local state and + use it. Also, you can remove the +10 clicks button. +4. Display the state in the client action, and add a +10 clicks button in it. + +![Increment Button](_images/increment_button.png) + +**See also:** +[Short explanation on services](https://www.odoo.com/documentation/master/developer/tutorials/discover_js_framework/02_build_a_dashboard.html#tutorials-discover-js-framework-services) + +## 5. Use a custom hook + +Right now, every part of the code that will need to use our clicker service will +have to import `useService` and `useState`. Since it is quite common, let us use +a custom hook. It is also useful to put more emphasis on the `clicker` part, and +less emphasis on the `service` part. + +1. Create and export a `useClicker` hook that encapsulate the `useService` and + `useState` in a simple function. +2. Update all current uses of the clicker service to the new hook: + + ```js + this.clicker = useClicker(); + ``` + +**See also:** +[Documentation on hooks](https://github.com/odoo/owl/blob/master/doc/reference/hooks.md) + +## 6. Humanize the displayed value + +We will in the future display large numbers, so let us get ready for that. There +is a `humanNumber` function that format numbers in a easier to comprehend way: +for example, `1234` could be formatted as `1.2k` + +1. Use it to display our counters (both in the systray item and the client + action). +2. Create a `ClickValue` component that displays the value. + +Note that Owl allows component that contains just text nodes! + +![Humanized Number](_images/humanized_number.png) + +**See also:** +[definition of humanNumber function](https://github.com/odoo/odoo/blob/c638913df191dfcc5547f90b8b899e7738c386f1/addons/web/static/src/core/utils/numbers.js#L119) + +## 7. Add a tooltip in ClickValue component + +With the `humanNumber` function, we actually lost some precision on our +interface. Let us display the real number as a tooltip. + +1. The tooltip needs an html element. Change the `ClickValue` to wrap its value + in a `` element +2. Add a dynamic `data-tooltip` attribute to display the exact value. + +![Humanized Tooltip](_images/humanized_tooltip.png) + +**See also:** +[Documentation in the tooltip service](https://github.com/odoo/odoo/blob/c638913df191dfcc5547f90b8b899e7738c386f1/addons/web/static/src/core/tooltip/tooltip_service.js#L17) + +## 8. Buy ClickBots + +Let us make our game even more interesting: once a player get to 1000 clicks for +the first time, the game should unlock a new feature: the player can buy robots +for 1000 clicks. These robots will generate 10 clicks every 10 seconds. + +1. Add a `level` number to our state. This is a number that will be incremented + at some milestones, and unlock new features +2. Add a `clickBots` number to our state. It represents the number of robots + that have been purchased. +3. Modify the client action to display the number of click bots (only if + `level >= 1`), with a `Buy` button that is enabled if `clicks >= 1000`. The + `Buy` button should increment the number of clickbots by 1. +4. Set a 10s interval in the service that will increment the number of clicks by + `10*clickBots`. +5. Make sure the `Buy` button is disabled if the player does not have enough + clicks. + +![Clickbot](_images/clickbot.png) + +# 9. Refactor to a class model + +The current code is written in a somewhat functional style. But to do so, we +have to export the state and all its update functions in our clicker object. As +this project grows, this may become more and more complex. To make it simpler, +let us split our business logic out of our service and into a class. + +1. Create a `clicker_model` file that exports a reactive class. Move all the + state and update functions from the service into the model. +2. Rewrite the clicker service to instantiate and export the clicker model + class. + +To create the `ClickerModel`, you can extend the `Reactive` class from +`@web/core/utils/reactive`. The `Reactive` class wrap the model into a reactive +proxy. + +**See also:** +[Example of subclassing Reactive](https://github.com/odoo/odoo/blob/c638913df191dfcc5547f90b8b899e7738c386f1/addons/web/static/src/model/relational_model/datapoint.js#L32) + +# 10. Notify when a milestone is reached + +There is not much feedback that something changed when we reached 1k clicks. Let +us use the `effect` service to communicate that information clearly. The problem +is that our click model does not have access to services. Also, we want to keep +as much as possible the UI concern out of the model. So, we can explore a new +strategy for communication: event buses. + +1. Update the clicker model to instantiate a bus, and to trigger a + `MILESTONE_1k` event when we reach 1000 clicks for the first time. +2. Change the clicker service to listen to the same event on the model bus. +3. When that happens, use the effect service to display a rainbow man. 4 Add + some text to explain that the user can now buy clickbots. + +![Milestones](_images/milestone1.png) + +#### See also + +- [Owl documentation on event bus](https://github.com/odoo/owl/blob/master/doc/reference/utils.md#eventbus) +- [Documentation on effect service](https://www.odoo.com/documentation/master/developer/reference/frontend/services.html#frontend-services-effect) + +# 11. Add BigBots + +Clearly, we need a way to provide the player with more choices. Let us add a new +type of clickbot: `BigBots`, which are just more powerful: they provide with 100 +clicks each 10s, but they cost 5000 clicks. + +1. increment `level` when it gets to 5k (so it should be 2) +2. Update the state to keep track of `bigbots` +3. `bigbots` should be available at `level >= 2` +4. Display the corresponding information in the client action + +**Tip** If you need to use `<` or `>` in a template as a javascript expression, +be careful since it might clash with the xml parser. To solve that, you can use +one of the special aliases: `gt`, `gte`, `lt` or `lte`. See the +[Owl documentation page on template expressions](https://github.com/odoo/owl/blob/master/doc/reference/templates.md#expression-evaluation). + +![Bigbots](_images/bigbot.png) + +# 12. Add a new type of resource: power + +Now, to add another scaling point, let us add a new type of resource: a power +multiplier. This is a number that can be increased at level >= 3, and multiplies +the action of the bots (so, instead of providing `N` click, clickbots now +provide us with `power*N` clicks). + +1. increment level when the number of clicks gets to 100k (so it should be 3). +2. update the state to keep track of the power (initial value is 1). +3. change bots to use that number as a multiplier. +4. Update the user interface to display and let the user purchase a new power + level (costs: 50k). + +## 13. Define some random rewards + +We want the user to obtain sometimes bonuses, to reward using Odoo. A reward is +an object with: + +- a `description` string. +- an `apply` function that take the game state in argument and can modify it. +- a `minLevel` number (optional) that describes at which unlock level the bonus + is available. +- a `maxLevel` number (optional) that describes at which unlock level a bonus is + no longer available. + +1. Define a list of rewards in a new file `click_rewards.js`. For example: + + ```js + export const rewards = [ + { + description: "Get 1 click bot", + apply(clicker) { + clicker.increment(1); + }, + maxLevel: 3, + }, + { + description: "Get 10 click bot", + apply(clicker) { + clicker.increment(10); + }, + minLevel: 3, + maxLevel: 4, + }, + { + description: "Increase bot power!", + apply(clicker) { + clicker.multipler += 1; + }, + minLevel: 3, + }, + ]; + ``` + + You can add whatever you want to that list! + +2. Define a function `getReward` that will select a random reward from the list + of rewards that matches the current unlock level. +3. Extract the code that choose randomly in an array in a function `choose` that + you can move to another `utils.js` file. + +# 14. Provide a reward when opening a form view + +1. Patch the form controller. Each time a form controller is created, it should + randomly decides (1% chance) if a reward should be given. +2. If the answer is yes, call a method `getReward` on the model. +3. That method should choose a reward, send a sticky notification, with a button + `Collect` that will then apply the reward, and finally, it should open the + `clicker` client action. + +![Rewar](_images/reward.png) + +#### See also + +- [Documentation on patching a class](https://www.odoo.com/documentation/master/developer/reference/frontend/patching_code.html#frontend-patching-class) +- [Definition of patch function](https://github.com/odoo/odoo/blob/c638913df191dfcc5547f90b8b899e7738c386f1/addons/web/static/src/core/utils/patch.js#L71) +- [Example of patching a class](https://github.com/odoo/odoo/blob/c638913df191dfcc5547f90b8b899e7738c386f1/addons/web/static/src/core/utils/patch.js#L71) + +## 15. Add commands in command palette + +Let us now provide more interesting ways to interact with our game. + +1. Add a command `Open Clicker Game` to the command palette. +2. Add another command: `Buy 1 click bot`. + +![Command Palette](_images/command_palette.png) + +**See also:** +[Example of use of command provider registry](https://github.com/odoo/odoo/blob/c638913df191dfcc5547f90b8b899e7738c386f1/addons/web/static/src/core/debug/debug_providers.js#L10) + +## 16. Add yet another resource: trees + +It is now time to introduce a completely new type of resources. Here is one that +should not be too controversial: trees. We will now allow the user to plant +(collect?) fruit trees. A tree costs 1 million clicks, but it will provide us +with fruits (either pears or cherries). + +1. Update the state to keep track of various types of trees: pear/cherries, and + their fruits. +2. Add a function that computes the total number of trees and fruits. +3. Define a new unlock level at `clicks >= 1 000 000`. +4. Update the client user interface to display the number of trees and fruits, + and also, to buy trees. +5. Increment the fruit number by 1 for each tree every 30s. + +## 17. Use a dropdown menu for the systray item + +Our game starts to become interesting. But for now, the systray only displays +the total number of clicks. We want to see more information: the total number of +trees and fruits. Also, it would be useful to have a quick access to some +commands and some more information. Let us use a dropdown menu! + +1. Replace the systray item by a dropdown menu. +2. It should display the numbers of clicks, trees, and fruits, each with a nice + icon. +3. Clicking on it should open a dropdown menu that displays more detailed + information: each types of trees and fruits. +4. Also, a few dropdown items with some commands: open the clicker game, buy a + clickbot, ... + +![Dropdown](_images/dropdown.png) + +## 18. Use a Notebook component + +We now keep track of a lot more information. Let us improve our client interface +by organizing the information and features in various tabs, with the `Notebook` +component: + +1. Use the `Notebook` component. +2. All `click` content should be displayed in one tab. +3. All tree/fruits content should be displayed in another tab. + +![Notebook](_images/notebook.png) + +#### See also + +- [Odoo: Documentation on Notebook component](https://www.odoo.com/documentation/master/developer/reference/frontend/owl_components.html#frontend-owl-notebook) +- [Owl: Documentation on slots](https://github.com/odoo/owl/blob/master/doc/reference/slots.md) +- [Tests of Notebook component](https://github.com/odoo/odoo/blob/c638913df191dfcc5547f90b8b899e7738c386f1/addons/web/static/tests/core/notebook_tests.js#L27) + +## 19. Persist the game state + +You certainly noticed a big flaw in our game: it is transient. The game state is +lost each time the user closes the browser tab. Let us fix that. We will use the +local storage to persist the state. + +1. Import `browser` from `@web/core/browser/browser` to access the localstorage. +2. Serialize the state every 10s (in the same interval code) and store it on the + local storage. +3. When the clicker service is started, it should load the state from the local + storage (if any), or initialize itself otherwise. + +## 20. Introduce state migration system + +Once you persist state somewhere, a new problem arises: what happens when you +update your code, so the shape of the state changes, and the user opens its +browser with a state that was created with an old version? Welcome to the world +of migration issues! + +It is probably wise to tackle the problem early. What we will do here is add a +version number to the state, and introduce a system to automatically update the +states if it is not up to date. + +1. Add a version number to the state. +2. Define an (empty) list of migrations. A migration is an object with a + `fromVersion` number, a `toVersion` number, and a `apply` function. +3. Whenever the code loads the state from the local storage, it should check the + version number. If the state is not uptodate, it should apply all necessary + migrations. + +## 21. Add another type of trees + +To test our migration system, let us add a new type of trees: peaches. + +1. Add `peach` trees in the model and in the UI. +2. Increment the state version number. +3. Define a migration. + +![Peach tree](_images/peach_tree.png) diff --git a/6_testing_javascript.md b/6_testing_javascript.md new file mode 100644 index 00000000000..f27b113a6b0 --- /dev/null +++ b/6_testing_javascript.md @@ -0,0 +1,11 @@ +# Module 6: Test javascript code + +Short intro + +## Content + +- [1. Create an interaction](#1-create-an-interaction) + +## 1. Create an interaction + +todo \ No newline at end of file diff --git a/README.md b/README.md index a0158d919ee..833ed33a880 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,24 @@ -# Odoo tutorials +![Odoo Logo](_images/odoo_logo.png) -This repository hosts the code for the bases of the modules used in the -[official Odoo tutorials](https://www.odoo.com/documentation/latest/developer/tutorials.html). +# Odoo : Javascript Framework Training -It has 3 branches for each Odoo version: one for the bases, one for the -[Discover the JS framework](https://www.odoo.com/documentation/latest/developer/tutorials/discover_js_framework.html) -tutorial's solutions, and one for the -[Master the Odoo web framework](https://www.odoo.com/documentation/latest/developer/tutorials/master_odoo_web_framework.html) -tutorial's solutions. For example, `17.0`, `17.0-discover-js-framework-solutions` and -`17.0-master-odoo-web-framework-solutions`. +This branch contains the exercises and supporting code for the Odoo JavaScript Framework Training. +It provides four addons, each a standalone project that can be completed independently. + +To get started: + +1. Clone this repository. +2. Check out this branch. +2. Launch an Odoo server with this folder included in the addons path. + +Much of the theory is covered in the accompanying slides. +And of course—feel free to ask questions! + +| Title | Content | +| ------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| [Module 1: Learn Owl 🦉](1_learn_owl.md) | Owl, components, hooks, reactivity, state management, ... | +| [Module 2: Make a Dashboard](2_make_a_dashboard.md) | assets, basics of odoo framework, rpcs, systray, services, registries, ... | +| [Module 3: Introduction to the Website](3_intro_to_website.md.md) | interactions, snippets, pages, options, ... | +| [Module 4: Customize Fields and Views](4_customize_fields_views.md) | fields, views | +| [Module 5: Build a Clicker Game](5_build_a_clicker_game.md) | advanced framework, state management, ... | +| [Module 6: Testing Javascript](6_testing_javascript.md.md) | unit tests, integration tests, hoot, tours, ... | diff --git a/_images/add_ribbon.png b/_images/add_ribbon.png new file mode 100644 index 00000000000..533811369af Binary files /dev/null and b/_images/add_ribbon.png differ diff --git a/_images/autofocus.png b/_images/autofocus.png new file mode 100644 index 00000000000..eb13a35b67c Binary files /dev/null and b/_images/autofocus.png differ diff --git a/_images/banner.png b/_images/banner.png new file mode 100644 index 00000000000..e5f5246c981 Binary files /dev/null and b/_images/banner.png differ diff --git a/_images/bigbot.png b/_images/bigbot.png new file mode 100644 index 00000000000..77ae8dc00ef Binary files /dev/null and b/_images/bigbot.png differ diff --git a/_images/builder_add_snippet_search.png b/_images/builder_add_snippet_search.png new file mode 100644 index 00000000000..4586d1b0a56 Binary files /dev/null and b/_images/builder_add_snippet_search.png differ diff --git a/_images/builder_snippet_groups.png b/_images/builder_snippet_groups.png new file mode 100644 index 00000000000..f23bf0eabe6 Binary files /dev/null and b/_images/builder_snippet_groups.png differ diff --git a/_images/clickbot.png b/_images/clickbot.png new file mode 100644 index 00000000000..fc721748587 Binary files /dev/null and b/_images/clickbot.png differ diff --git a/_images/client_action.png b/_images/client_action.png new file mode 100644 index 00000000000..434a19213df Binary files /dev/null and b/_images/client_action.png differ diff --git a/_images/command_palette.png b/_images/command_palette.png new file mode 100644 index 00000000000..31c3a9ea37e Binary files /dev/null and b/_images/command_palette.png differ diff --git a/_images/counter.png b/_images/counter.png new file mode 100644 index 00000000000..5e2ac1a8f6d Binary files /dev/null and b/_images/counter.png differ diff --git a/_images/create_todo.png b/_images/create_todo.png new file mode 100644 index 00000000000..83ec5c4c532 Binary files /dev/null and b/_images/create_todo.png differ diff --git a/_images/dashboard_item.png b/_images/dashboard_item.png new file mode 100644 index 00000000000..1524ca5b0e2 Binary files /dev/null and b/_images/dashboard_item.png differ diff --git a/_images/delete_todo.png b/_images/delete_todo.png new file mode 100644 index 00000000000..cc9ff9a85ed Binary files /dev/null and b/_images/delete_todo.png differ diff --git a/_images/double_counter.png b/_images/double_counter.png new file mode 100644 index 00000000000..99e2b88d48e Binary files /dev/null and b/_images/double_counter.png differ diff --git a/_images/dropdown.png b/_images/dropdown.png new file mode 100644 index 00000000000..1cdeddc40cc Binary files /dev/null and b/_images/dropdown.png differ diff --git a/_images/final.png b/_images/final.png new file mode 100644 index 00000000000..f40107a7fc3 Binary files /dev/null and b/_images/final.png differ diff --git a/_images/generic_card.png b/_images/generic_card.png new file mode 100644 index 00000000000..ad079e95dde Binary files /dev/null and b/_images/generic_card.png differ diff --git a/_images/humanized_number.png b/_images/humanized_number.png new file mode 100644 index 00000000000..f51cf9c9938 Binary files /dev/null and b/_images/humanized_number.png differ diff --git a/_images/humanized_tooltip.png b/_images/humanized_tooltip.png new file mode 100644 index 00000000000..b11e5d52269 Binary files /dev/null and b/_images/humanized_tooltip.png differ diff --git a/_images/increment_button.png b/_images/increment_button.png new file mode 100644 index 00000000000..1d96e7aa0a3 Binary files /dev/null and b/_images/increment_button.png differ diff --git a/_images/items_configuration.png b/_images/items_configuration.png new file mode 100644 index 00000000000..a828a187b54 Binary files /dev/null and b/_images/items_configuration.png differ diff --git a/_images/markup.png b/_images/markup.png new file mode 100644 index 00000000000..bd8fea06f1a Binary files /dev/null and b/_images/markup.png differ diff --git a/_images/milestone1.png b/_images/milestone1.png new file mode 100644 index 00000000000..cc11eda3624 Binary files /dev/null and b/_images/milestone1.png differ diff --git a/_images/muted_todo.png b/_images/muted_todo.png new file mode 100644 index 00000000000..e732e4cfae2 Binary files /dev/null and b/_images/muted_todo.png differ diff --git a/_images/navigation_buttons.png b/_images/navigation_buttons.png new file mode 100644 index 00000000000..79d0221d217 Binary files /dev/null and b/_images/navigation_buttons.png differ diff --git a/_images/new_layout.png b/_images/new_layout.png new file mode 100644 index 00000000000..7bfd8128770 Binary files /dev/null and b/_images/new_layout.png differ diff --git a/_images/notebook.png b/_images/notebook.png new file mode 100644 index 00000000000..101be3bd3fd Binary files /dev/null and b/_images/notebook.png differ diff --git a/_images/odoo_logo.png b/_images/odoo_logo.png new file mode 100644 index 00000000000..474f9ff5d66 Binary files /dev/null and b/_images/odoo_logo.png differ diff --git a/_images/overview_02.png b/_images/overview_02.png new file mode 100644 index 00000000000..bd618027cee Binary files /dev/null and b/_images/overview_02.png differ diff --git a/_images/peach_tree.png b/_images/peach_tree.png new file mode 100644 index 00000000000..7ccbe51a7f5 Binary files /dev/null and b/_images/peach_tree.png differ diff --git a/_images/pictogram.png b/_images/pictogram.png new file mode 100644 index 00000000000..37f1a1b2fb9 Binary files /dev/null and b/_images/pictogram.png differ diff --git a/_images/pie_chart.png b/_images/pie_chart.png new file mode 100644 index 00000000000..ba56ded24b8 Binary files /dev/null and b/_images/pie_chart.png differ diff --git a/_images/rainbowadopted.png b/_images/rainbowadopted.png new file mode 100644 index 00000000000..52803dbf2b5 Binary files /dev/null and b/_images/rainbowadopted.png differ diff --git a/_images/reward.png b/_images/reward.png new file mode 100644 index 00000000000..bdd85e24175 Binary files /dev/null and b/_images/reward.png differ diff --git a/_images/simple_card.png b/_images/simple_card.png new file mode 100644 index 00000000000..8cff4e90d01 Binary files /dev/null and b/_images/simple_card.png differ diff --git a/_images/statistics1.png b/_images/statistics1.png new file mode 100644 index 00000000000..d05fa928f92 Binary files /dev/null and b/_images/statistics1.png differ diff --git a/_images/subcharfield.png b/_images/subcharfield.png new file mode 100644 index 00000000000..e4b321652be Binary files /dev/null and b/_images/subcharfield.png differ diff --git a/_images/sum_counter.png b/_images/sum_counter.png new file mode 100644 index 00000000000..9948016ca23 Binary files /dev/null and b/_images/sum_counter.png differ diff --git a/_images/systray.png b/_images/systray.png new file mode 100644 index 00000000000..06b40b440bd Binary files /dev/null and b/_images/systray.png differ diff --git a/_images/todo_list.png b/_images/todo_list.png new file mode 100644 index 00000000000..6d77ee395a5 Binary files /dev/null and b/_images/todo_list.png differ diff --git a/_images/toggle_card.png b/_images/toggle_card.png new file mode 100644 index 00000000000..6048b38c738 Binary files /dev/null and b/_images/toggle_card.png differ diff --git a/_images/toggle_todo.png b/_images/toggle_todo.png new file mode 100644 index 00000000000..594b6c8aef6 Binary files /dev/null and b/_images/toggle_todo.png differ diff --git a/_images/trees.png b/_images/trees.png new file mode 100644 index 00000000000..35b39ce1619 Binary files /dev/null and b/_images/trees.png differ diff --git a/_images/website_cursor_highlighter.png b/_images/website_cursor_highlighter.png new file mode 100644 index 00000000000..f62c1a01f95 Binary files /dev/null and b/_images/website_cursor_highlighter.png differ diff --git a/_images/website_date.png b/_images/website_date.png new file mode 100644 index 00000000000..d1e6ba097ec Binary files /dev/null and b/_images/website_date.png differ diff --git a/awesome_clicker/__manifest__.py b/awesome_clicker/__manifest__.py index 56dc2f779b9..f2a57dd1b46 100644 --- a/awesome_clicker/__manifest__.py +++ b/awesome_clicker/__manifest__.py @@ -3,11 +3,11 @@ 'name': "Awesome Clicker", 'summary': """ - Starting module for "Master the Odoo web framework, chapter 1: Build a Clicker game" + Companion addon for the Odoo JS Framework Training """, 'description': """ - Starting module for "Master the Odoo web framework, chapter 1: Build a Clicker game" + Companion addon for the Odoo JS Framework Training """, 'author': "Odoo", diff --git a/awesome_clicker/static/src/click_rewards.js b/awesome_clicker/static/src/click_rewards.js new file mode 100644 index 00000000000..6dcacae21f2 --- /dev/null +++ b/awesome_clicker/static/src/click_rewards.js @@ -0,0 +1,24 @@ +export const rewards = [ + { + description: "Get 1 click bot", + apply(clicker) { + clicker.increment(1); + }, + maxLevel: 3, + }, + { + description: "Get 10 click bot", + apply(clicker) { + clicker.increment(10); + }, + minLevel: 3, + maxLevel: 4, + }, + { + description: "Increase bot power!", + apply(clicker) { + clicker.multipler += 1; + }, + minLevel: 3, + }, +]; diff --git a/awesome_clicker/static/src/clicker_hook.js b/awesome_clicker/static/src/clicker_hook.js new file mode 100644 index 00000000000..bebbb02b119 --- /dev/null +++ b/awesome_clicker/static/src/clicker_hook.js @@ -0,0 +1,6 @@ +import { useService } from "@web/core/utils/hooks"; +import { useState } from "@odoo/owl"; + +export function useClicker() { + return useState(useService("awesome_clicker.clicker")); +} diff --git a/awesome_clicker/static/src/clicker_migration.js b/awesome_clicker/static/src/clicker_migration.js new file mode 100644 index 00000000000..bc9c0c825c7 --- /dev/null +++ b/awesome_clicker/static/src/clicker_migration.js @@ -0,0 +1,29 @@ +export const CURRENT_VERSION = 2.0; +export const migrations = [ + { + fromVersion: 1.0, + toVersion: 2.0, + apply: (state) => { + state.trees.peachTree = { + price: 1500000, + level: 4, + produce: "peach", + purchased: 0, + }; + state.fruits.peach = 0; + }, + }, +]; + +export function migrate(localState) { + if (localState?.version < CURRENT_VERSION) { + for (const migration of migrations) { + if (localState.version === migration.fromVersion) { + migration.apply(localState); + localState.version = migration.toVersion + } + } + localState.version = CURRENT_VERSION; + } + return localState; +} diff --git a/awesome_clicker/static/src/clicker_model.js b/awesome_clicker/static/src/clicker_model.js new file mode 100644 index 00000000000..329d3ca7c1f --- /dev/null +++ b/awesome_clicker/static/src/clicker_model.js @@ -0,0 +1,154 @@ +import { Reactive } from "@web/core/utils/reactive"; +import { EventBus } from "@odoo/owl"; +import { rewards } from "./click_rewards"; +import { choose } from "./utils"; +import { CURRENT_VERSION } from "./clicker_migration"; + +export class ClickerModel extends Reactive { + constructor() { + super(); + this.version = CURRENT_VERSION; + this.clicks = 0; + this.level = 0; + this.bus = new EventBus(); + this.bots = { + clickbot: { + price: 1000, + level: 1, + increment: 10, + purchased: 0, + }, + bigbot: { + price: 5000, + level: 2, + increment: 100, + purchased: 0, + } + }; + this.trees = { + pearTree: { + price: 1000000, + level: 4, + produce: "pear", + purchased: 0, + }, + cherryTree: { + price: 1000000, + level: 4, + produce: "cherry", + purchased: 0, + }, + peachTree: { + price: 1500000, + level: 4, + produce: "peach", + purchased: 0, + }, + } + this.fruits = { + pear: 0, + cherry: 0, + peach: 0, + }, + this.multiplier = 1 + this.ticks = 0; + } + + addClick() { + this.increment(1); + } + + /** + * This method is supposed to be periodically called by outside code, at some + * proper interval + */ + tick() { + this.ticks++; + for (const bot in this.bots) { + this.clicks += this.bots[bot].increment * this.bots[bot].purchased * this.multiplier; + } + if (this.ticks % 3 === 0) { + for (const tree in this.trees) { + this.fruits[this.trees[tree].produce] += this.trees[tree].purchased; + } + } + } + + buyMultiplier() { + if (this.clicks < 50000) { + return false; + } + this.clicks -= 50000; + this.multiplier++; + } + + increment(inc) { + this.clicks += inc; + if ( + this.milestones[this.level] && + this.clicks >= this.milestones[this.level].clicks + ) { + this.bus.trigger("MILESTONE", this.milestones[this.level]); + this.level += 1; + } + } + + buyBot(name) { + if (!Object.keys(this.bots).includes(name)) { + throw new Error(`Invalid bot name ${name}`); + } + if (this.clicks < this.bots[name].price) { + return false; + } + + this.clicks -= this.bots[name].price; + this.bots[name].purchased += 1; + } + + giveReward() { + const availableReward = []; + for (const reward of rewards) { + if (reward.minLevel <= this.level || !reward.minLevel) { + if (reward.maxLevel >= this.level || !reward.maxLevel) { + availableReward.push(reward); + } + } + } + const reward = choose(availableReward); + this.bus.trigger("REWARD", reward); + return choose(availableReward); + } + + buyTree(name) { + if (!Object.keys(this.trees).includes(name)) { + throw new Error(`Invalid tree name ${name}`); + } + if (this.clicks < this.trees[name].price) { + return false; + } + this.clicks -= this.trees[name].price; + this.trees[name].purchased += 1; + } + + toJSON() { + const json = Object.assign({}, this); + delete json["bus"]; + return json; + + } + + static fromJSON(json) { + const clicker = new ClickerModel(); + const clickerInstance = Object.assign(clicker, json); + return clickerInstance; + } + + get milestones() { + return [ + { clicks: 1000, unlock: "clickBot" }, + { clicks: 5000, unlock: "bigBot" }, + { clicks: 100000, unlock: "power multiplier" }, + { clicks: 1000000, unlock: "pear tree & cherry tree" }, + ]; + } +} diff --git a/awesome_clicker/static/src/clicker_provider.js b/awesome_clicker/static/src/clicker_provider.js new file mode 100644 index 00000000000..a59cc673b31 --- /dev/null +++ b/awesome_clicker/static/src/clicker_provider.js @@ -0,0 +1,27 @@ +import { registry } from "@web/core/registry"; + +const commandProviderRegistry = registry.category("command_provider"); + +commandProviderRegistry.add("clicker", { + provide: (env, options) => { + return [ + { + name: "Buy 1 click bot", + action() { + env.services["awesome_clicker.clicker"].buyBot("clickbot"); + }, + }, + { + name: "Open Clicker Game", + action() { + env.services.action.doAction({ + type: "ir.actions.client", + tag: "awesome_clicker.client_action", + target: "new", + name: "Clicker Game", + }); + }, + }, + ]; + }, +}); diff --git a/awesome_clicker/static/src/clicker_service.js b/awesome_clicker/static/src/clicker_service.js new file mode 100644 index 00000000000..e847b39e5f8 --- /dev/null +++ b/awesome_clicker/static/src/clicker_service.js @@ -0,0 +1,58 @@ +import { registry } from "@web/core/registry"; +import { ClickerModel } from "./clicker_model"; +import { browser } from "@web/core/browser/browser"; +import { migrate } from "./clicker_migration"; + +const clickerService = { + dependencies: ["action", "effect", "notification"], + start(env, services) { + const localState = migrate(JSON.parse(browser.localStorage.getItem("clickerState"))); + const model = localState ? ClickerModel.fromJSON(localState): new ClickerModel(); + + document.addEventListener("click", () => model.addClick(), true); + setInterval(() => { + model.tick(); + }, 10000); + + setInterval(() => { + browser.localStorage.setItem("clickerState", JSON.stringify(model)) + }, 10000); + + const bus = model.bus; + bus.addEventListener("MILESTONE", (ev) => { + services.effect.add({ + message: `Milestone reached! You can now buy ${ev.detail.unlock}`, + type: "rainbow_man", + }); + }); + + bus.addEventListener("REWARD", (ev) => { + const reward = ev.detail; + const closeNotification = services.notification.add( + `Congrats you won a reward: "${reward.description}"`, + { + type: "success", + sticky: true, + buttons: [ + { + name: "Collect", + onClick: () => { + reward.apply(model); + closeNotification(); + services.action.doAction({ + type: "ir.actions.client", + tag: "awesome_clicker.client_action", + target: "new", + name: "Clicker Game" + }); + }, + }, + ], + } + ); + }) + return model; + }, +}; + +registry.category("services").add("awesome_clicker.clicker", clickerService); diff --git a/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.js b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.js new file mode 100644 index 00000000000..58940423709 --- /dev/null +++ b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.js @@ -0,0 +1,49 @@ +import { Component } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { useClicker } from "../clicker_hook"; +import { ClickerValue } from "../clicker_value/clicker_value"; +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; + +export class ClickerSystray extends Component { + static template = "awesome_clicker.ClickerSystray"; + static props = {}; + static components = { ClickerValue, Dropdown, DropdownItem }; + + setup() { + this.action = useService("action"); + this.clicker = useClicker(); + } + + openClientAction() { + this.action.doAction({ + type: "ir.actions.client", + tag: "awesome_clicker.client_action", + target: "new", + name: "Clicker Game" + }); + } + + get numberTrees() { + let sum = 0; + for (const tree in this.clicker.trees) { + sum += this.clicker.trees[tree].purchased; + } + return sum; + } + + get numberFruits() { + let sum = 0; + for (const fruit in this.clicker.fruits) { + sum += this.clicker.fruits[fruit]; + } + return sum; + } +} + +export const systrayItem = { + Component: ClickerSystray, +}; + +registry.category("systray").add("awesome_clicker.ClickerSystray", systrayItem, { sequence: 1000 }); diff --git a/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml new file mode 100644 index 00000000000..ed4ede0d902 --- /dev/null +++ b/awesome_clicker/static/src/clicker_systray_item/clicker_systray_item.xml @@ -0,0 +1,30 @@ + + + +
+ + + + + + + + + + + x + + + + x + + + + +
+
+
diff --git a/awesome_clicker/static/src/clicker_value/clicker_value.js b/awesome_clicker/static/src/clicker_value/clicker_value.js new file mode 100644 index 00000000000..a5018bad8ac --- /dev/null +++ b/awesome_clicker/static/src/clicker_value/clicker_value.js @@ -0,0 +1,18 @@ +import { Component } from "@odoo/owl"; +import { humanNumber } from "@web/core/utils/numbers"; +import { useClicker } from "../clicker_hook"; + +export class ClickerValue extends Component { + static template = "awesome_clicker.ClickerValue"; + static props = {}; + + setup() { + this.clicker = useClicker(); + } + + get humanizedClicks() { + return humanNumber(this.clicker.clicks, { + decimals: 1, + }); + } +} diff --git a/awesome_clicker/static/src/clicker_value/clicker_value.xml b/awesome_clicker/static/src/clicker_value/clicker_value.xml new file mode 100644 index 00000000000..24fd29643f2 --- /dev/null +++ b/awesome_clicker/static/src/clicker_value/clicker_value.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/awesome_clicker/static/src/client_action/client_action.js b/awesome_clicker/static/src/client_action/client_action.js new file mode 100644 index 00000000000..245f5913cc5 --- /dev/null +++ b/awesome_clicker/static/src/client_action/client_action.js @@ -0,0 +1,16 @@ +import { Component } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useClicker } from "../clicker_hook"; +import { ClickerValue } from "../clicker_value/clicker_value"; +import { Notebook } from "@web/core/notebook/notebook"; +class ClickerClientAction extends Component { + static template = "awesome_clicker.ClickerClientAction"; + static props = ["*"]; + static components = { ClickerValue, Notebook }; + + setup() { + this.clicker = useClicker(); + } +} + +registry.category("actions").add("awesome_clicker.client_action", ClickerClientAction); diff --git a/awesome_clicker/static/src/client_action/client_action.xml b/awesome_clicker/static/src/client_action/client_action.xml new file mode 100644 index 00000000000..aac9e7b75e9 --- /dev/null +++ b/awesome_clicker/static/src/client_action/client_action.xml @@ -0,0 +1,83 @@ + + + +
+ Clicks: + +
+ + +
+

Bots

+
+ +
+
+ x ( clicks/10seconds) + +
+
+ +
+
+
+
+
+
+

Power multiplier

+
+
+
+ x + +
+
+ +
+
+
+
+
+ + +
+

Trees

+
+ +
+
+ x (1x /30seconds) + +
+
+ +
+
+
+
+ +

Fruits

+
+ +
+
+ x +
+
+
+
+
+
+
+ +
+ +
diff --git a/awesome_clicker/static/src/form_controller/form_controller_patch.js b/awesome_clicker/static/src/form_controller/form_controller_patch.js new file mode 100644 index 00000000000..dd8689cc4ce --- /dev/null +++ b/awesome_clicker/static/src/form_controller/form_controller_patch.js @@ -0,0 +1,15 @@ +import { FormController } from "@web/views/form/form_controller"; +import { patch } from "@web/core/utils/patch"; +import { useClicker } from "../clicker_hook"; + +const FormControllerPatch = { + setup() { + super.setup(...arguments); + if (Math.random() < 0.01) { + const clicker = useClicker(); + clicker.giveReward(); + } + }, +}; + +patch(FormController.prototype, FormControllerPatch); diff --git a/awesome_clicker/static/src/utils.js b/awesome_clicker/static/src/utils.js new file mode 100644 index 00000000000..f3f7c3977e9 --- /dev/null +++ b/awesome_clicker/static/src/utils.js @@ -0,0 +1,3 @@ +export function choose(list) { + return list[Math.floor(Math.random() * list.length)]; +} diff --git a/awesome_dashboard/__manifest__.py b/awesome_dashboard/__manifest__.py index a1cd72893d7..57f859c8f26 100644 --- a/awesome_dashboard/__manifest__.py +++ b/awesome_dashboard/__manifest__.py @@ -3,11 +3,19 @@ 'name': "Awesome Dashboard", 'summary': """ +<<<<<<< HEAD Starting module for "Discover the JS framework, chapter 2: Build a dashboard" """, 'description': """ Starting module for "Discover the JS framework, chapter 2: Build a dashboard" +======= + Companion addon for the Odoo JS Framework Training + """, + + 'description': """ + Companion addon for the Odoo JS Framework Training +>>>>>>> 7064949f3 ([ADD] Discover the JavaScript framework) """, 'author': "Odoo", @@ -24,7 +32,12 @@ 'assets': { 'web.assets_backend': [ 'awesome_dashboard/static/src/**/*', + ('remove', 'awesome_dashboard/static/src/dashboard/**/*'), ], + 'awesome_dashboard.dashboard': [ + 'awesome_dashboard/static/src/dashboard/**/*' + ] + }, 'license': 'AGPL-3' } diff --git a/awesome_dashboard/controllers/controllers.py b/awesome_dashboard/controllers/controllers.py index 05977d3bd7f..3facc2ae44b 100644 --- a/awesome_dashboard/controllers/controllers.py +++ b/awesome_dashboard/controllers/controllers.py @@ -33,4 +33,7 @@ def get_statistics(self): }, 'total_amount': random.randint(100, 1000) } +<<<<<<< HEAD +======= +>>>>>>> 7064949f3 ([ADD] Discover the JavaScript framework) diff --git a/awesome_dashboard/static/src/dashboard.js b/awesome_dashboard/static/src/dashboard.js deleted file mode 100644 index c4fb245621b..00000000000 --- a/awesome_dashboard/static/src/dashboard.js +++ /dev/null @@ -1,8 +0,0 @@ -import { Component } from "@odoo/owl"; -import { registry } from "@web/core/registry"; - -class AwesomeDashboard extends Component { - static template = "awesome_dashboard.AwesomeDashboard"; -} - -registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboard); diff --git a/awesome_dashboard/static/src/dashboard.xml b/awesome_dashboard/static/src/dashboard.xml deleted file mode 100644 index 1a2ac9a2fed..00000000000 --- a/awesome_dashboard/static/src/dashboard.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - hello dashboard - - - diff --git a/awesome_dashboard/static/src/dashboard/dashboard.js b/awesome_dashboard/static/src/dashboard/dashboard.js new file mode 100644 index 00000000000..da1b6ea4ca9 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.js @@ -0,0 +1,87 @@ +import { Component, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { Layout } from "@web/search/layout"; +import { useService } from "@web/core/utils/hooks"; +import { DashboardItem } from "./dashboard_item"; +import { Dialog } from "@web/core/dialog/dialog"; +import { CheckBox } from "@web/core/checkbox/checkbox"; +import { browser } from "@web/core/browser/browser"; + +class AwesomeDashboard extends Component { + static template = "awesome_dashboard.AwesomeDashboard"; + static components = { Layout, DashboardItem }; + + setup() { + this.action = useService("action"); + this.statistics = useState(useService("awesome_dashboard.statistics")); + this.dialog = useService("dialog"); + this.items = registry.category("awesome_dashboard").getAll(); + this.state = useState({ + disabledItems: browser.localStorage.getItem("disabledDashboardItems")?.split(",") || [] + }); + } + + openConfiguration() { + this.dialog.add(ConfigurationDialog, { + items: this.items, + disabledItems: this.state.disabledItems, + onUpdateConfiguration: this.updateConfiguration.bind(this), + }) + } + + updateConfiguration(newDisabledItems) { + this.state.disabledItems = newDisabledItems; + } + + openCustomerView() { + this.action.doAction("base.action_partner_form"); + } + + openLeads() { + this.action.doAction({ + type: "ir.actions.act_window", + name: "All leads", + res_model: "crm.lead", + views: [ + [false, "list"], + [false, "form"], + ], + }); + } +} + +class ConfigurationDialog extends Component { + static template = "awesome_dashboard.ConfigurationDialog"; + static components = { Dialog, CheckBox }; + static props = ["close", "items", "disabledItems", "onUpdateConfiguration"]; + + setup() { + this.items = useState(this.props.items.map((item) => { + return { + ...item, + enabled: !this.props.disabledItems.includes(item.id), + } + })); + } + + done() { + this.props.close(); + } + + onChange(checked, changedItem) { + changedItem.enabled = checked; + const newDisabledItems = Object.values(this.items).filter( + (item) => !item.enabled + ).map((item) => item.id) + + browser.localStorage.setItem( + "disabledDashboardItems", + newDisabledItems, + ); + + this.props.onUpdateConfiguration(newDisabledItems); + } + +} + +registry.category("lazy_components").add("AwesomeDashboard", AwesomeDashboard); diff --git a/awesome_dashboard/static/src/dashboard/dashboard.scss b/awesome_dashboard/static/src/dashboard/dashboard.scss new file mode 100644 index 00000000000..32862ec0d82 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.scss @@ -0,0 +1,3 @@ +.o_dashboard { + background-color: gray; +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard.xml b/awesome_dashboard/static/src/dashboard/dashboard.xml new file mode 100644 index 00000000000..d7aab6680a0 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + +
+ + + + + + +
+
+
+ + + + Which cards do you whish to see ? + + + + + + + + + + +
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_item.js b/awesome_dashboard/static/src/dashboard/dashboard_item.js new file mode 100644 index 00000000000..6dd67d6a4e2 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item.js @@ -0,0 +1,18 @@ +import { Component } from "@odoo/owl"; + +export class DashboardItem extends Component { + static template = "awesome_dashboard.DashboardItem" + static props = { + slots: { + type: Object, + shape: { + default: Object + }, + }, + size: { + type: Number, + default: 1, + optional: true, + }, + }; +} diff --git a/awesome_dashboard/static/src/dashboard/dashboard_item.xml b/awesome_dashboard/static/src/dashboard/dashboard_item.xml new file mode 100644 index 00000000000..a260e096b2b --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_item.xml @@ -0,0 +1,12 @@ + + + + +
+
+ +
+
+
+ +
diff --git a/awesome_dashboard/static/src/dashboard/dashboard_items.js b/awesome_dashboard/static/src/dashboard/dashboard_items.js new file mode 100644 index 00000000000..300e8bbd53e --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/dashboard_items.js @@ -0,0 +1,65 @@ +import { NumberCard } from "./number_card/number_card"; +import { PieChartCard } from "./pie_chart_card/pie_chart_card"; +import { registry } from "@web/core/registry"; + +const items = [ + { + id: "average_quantity", + description: "Average amount of t-shirt", + Component: NumberCard, + props: (data) => ({ + title: "Average amount of t-shirt by order this month", + value: data.average_quantity, + }) + }, + { + id: "average_time", + description: "Average time for an order", + Component: NumberCard, + props: (data) => ({ + title: "Average time for an order to go from 'new' to 'sent' or 'cancelled'", + value: data.average_time, + }) + }, + { + id: "number_new_orders", + description: "New orders this month", + Component: NumberCard, + props: (data) => ({ + title: "Number of new orders this month", + value: data.nb_new_orders, + }) + }, + { + id: "cancelled_orders", + description: "Cancelled orders this month", + Component: NumberCard, + props: (data) => ({ + title: "Number of cancelled orders this month", + value: data.nb_cancelled_orders, + }) + }, + { + id: "amount_new_orders", + description: "amount orders this month", + Component: NumberCard, + props: (data) => ({ + title: "Total amount of new orders this month", + value: data.total_amount, + }) + }, + { + id: "pie_chart", + description: "Shirt orders by size", + Component: PieChartCard, + size: 2, + props: (data) => ({ + title: "Shirt orders by size", + values: data.orders_by_size, + }) + } +] + +items.forEach(item => { + registry.category("awesome_dashboard").add(item.id, item); +}); diff --git a/awesome_dashboard/static/src/dashboard/number_card/number_card.js b/awesome_dashboard/static/src/dashboard/number_card/number_card.js new file mode 100644 index 00000000000..ca8b584a334 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/number_card/number_card.js @@ -0,0 +1,13 @@ +import { Component } from "@odoo/owl"; + +export class NumberCard extends Component { + static template = "awesome_dashboard.NumberCard"; + static props = { + title: { + type: String, + }, + value: { + type: Number, + } + } +} diff --git a/awesome_dashboard/static/src/dashboard/number_card/number_card.xml b/awesome_dashboard/static/src/dashboard/number_card/number_card.xml new file mode 100644 index 00000000000..3a0713623fa --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/number_card/number_card.xml @@ -0,0 +1,9 @@ + + + + +
+ +
+
+
diff --git a/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js new file mode 100644 index 00000000000..09c45b25cd8 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.js @@ -0,0 +1,43 @@ +import { loadJS } from "@web/core/assets"; +import { getColor } from "@web/core/colors/colors"; +import { Component, onWillStart, useRef, onMounted, onWillUnmount, onPatched } from "@odoo/owl"; + +export class PieChart extends Component { + static template = "awesome_dashboard.PieChart"; + static props = { + label: String, + data: Object, + }; + + setup() { + this.canvasRef = useRef("canvas"); + onWillStart(() => loadJS("/web/static/lib/Chart/Chart.js")); + onMounted(() => { + this.renderChart(); + }); + onPatched(() => { + this.chart.destroy(); + this.renderChart(); + }); + onWillUnmount(() => { + this.chart.destroy(); + }); + } + + renderChart() { + const labels = Object.keys(this.props.data); + const data = Object.values(this.props.data); + this.chart = new Chart(this.canvasRef.el, { + type: "pie", + data: { + labels: labels, + datasets: [ + { + label: this.props.label, + data: data, + }, + ], + }, + }); + } +} diff --git a/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml new file mode 100644 index 00000000000..18416e9a223 --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart/pie_chart.xml @@ -0,0 +1,10 @@ + + + +
+
+ +
+
+
+
\ No newline at end of file diff --git a/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js new file mode 100644 index 00000000000..3faac175fed --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.js @@ -0,0 +1,15 @@ +import { Component } from "@odoo/owl"; +import { PieChart } from "../pie_chart/pie_chart"; + +export class PieChartCard extends Component { + static template = "awesome_dashboard.PieChartCard"; + static components = { PieChart } + static props = { + title: { + type: String, + }, + values: { + type: Object, + }, + } +} diff --git a/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml new file mode 100644 index 00000000000..58a6811c83a --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/pie_chart_card/pie_chart_card.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/awesome_dashboard/static/src/dashboard/statistics_service.js b/awesome_dashboard/static/src/dashboard/statistics_service.js new file mode 100644 index 00000000000..f55c09b59bd --- /dev/null +++ b/awesome_dashboard/static/src/dashboard/statistics_service.js @@ -0,0 +1,21 @@ +import { registry } from "@web/core/registry"; +import { reactive } from "@odoo/owl"; +import { rpc } from "@web/core/network/rpc"; + +const statisticsService = { + start() { + const statistics = reactive({ isReady: false }); + + async function loadData() { + const updates = await rpc("/awesome_dashboard/statistics"); + Object.assign(statistics, updates, { isReady: true }); + } + + setInterval(loadData, 5*1000); + loadData(); + + return statistics; + }, +}; + +registry.category("services").add("awesome_dashboard.statistics", statisticsService); diff --git a/awesome_dashboard/static/src/dashboard_loader.js b/awesome_dashboard/static/src/dashboard_loader.js new file mode 100644 index 00000000000..35296129c1f --- /dev/null +++ b/awesome_dashboard/static/src/dashboard_loader.js @@ -0,0 +1,15 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { LazyComponent } from "@web/core/assets"; +import { Component, xml } from "@odoo/owl"; + +class AwesomeDashboardLoader extends Component { + static components = { LazyComponent }; + static template = xml` + + `; + +} + +registry.category("actions").add("awesome_dashboard.dashboard", AwesomeDashboardLoader); diff --git a/awesome_owl/static/src/card/card.js b/awesome_owl/static/src/card/card.js new file mode 100644 index 00000000000..b5bad5a5c86 --- /dev/null +++ b/awesome_owl/static/src/card/card.js @@ -0,0 +1,22 @@ +import { Component, useState } from "@odoo/owl"; + +export class Card extends Component { + static template = "awesome_owl.Card"; + static props = { + title: String, + slots: { + type: Object, + shape: { + default: true + }, + } + }; + + setup() { + this.state = useState({ isOpen: true }); + } + + toggleContent() { + this.state.isOpen = !this.state.isOpen; + } +} diff --git a/awesome_owl/static/src/card/card.xml b/awesome_owl/static/src/card/card.xml new file mode 100644 index 00000000000..3cf5c65b704 --- /dev/null +++ b/awesome_owl/static/src/card/card.xml @@ -0,0 +1,16 @@ + + + +
+
+
+ + +
+

+ +

+
+
+
+
diff --git a/awesome_owl/static/src/counter/counter.js b/awesome_owl/static/src/counter/counter.js new file mode 100644 index 00000000000..0690ee1ec9d --- /dev/null +++ b/awesome_owl/static/src/counter/counter.js @@ -0,0 +1,15 @@ +import { Component, useState } from "@odoo/owl"; + +export class Counter extends Component { + static template = "awesome_owl.Counter"; + static props = { + value: Number, + onChange: { type: Function, optional: true } + }; + + increment() { + if (this.props.onChange) { + this.props.onChange(); + } + } +} diff --git a/awesome_owl/static/src/counter/counter.xml b/awesome_owl/static/src/counter/counter.xml new file mode 100644 index 00000000000..80bb3f611fe --- /dev/null +++ b/awesome_owl/static/src/counter/counter.xml @@ -0,0 +1,9 @@ + + + +
+ Counter: + +
+
+
diff --git a/awesome_owl/static/src/playground.js b/awesome_owl/static/src/playground.js index 4ac769b0aa5..b2eb620b043 100644 --- a/awesome_owl/static/src/playground.js +++ b/awesome_owl/static/src/playground.js @@ -1,5 +1,27 @@ -import { Component } from "@odoo/owl"; +import { Component, markup, useState } from "@odoo/owl"; +import { Counter } from "./counter/counter"; +import { Card } from "./card/card"; +import { TodoList } from "./todo_list/todo_list"; export class Playground extends Component { static template = "awesome_owl.playground"; + static components = { Counter, Card, TodoList }; + + setup() { + this.str1 = "
some content
"; + this.str2 = markup("
some content
"); + this.counters = useState([ + { value: 1}, + { value: 10 }, + { value: 100 } + ]); + } + + get sum() { + return this.counters[0].value + this.counters[1].value + this.counters[2].value; + } + + increment(index) { + this.counters[index].value++; + } } diff --git a/awesome_owl/static/src/playground.xml b/awesome_owl/static/src/playground.xml index 4fb905d59f9..d787d3b00a9 100644 --- a/awesome_owl/static/src/playground.xml +++ b/awesome_owl/static/src/playground.xml @@ -4,7 +4,21 @@
hello world + + + + +
The sum is:
+
+ + content of card 1 + + + + +
+
diff --git a/awesome_owl/static/src/todo_list/todo_item.js b/awesome_owl/static/src/todo_list/todo_item.js new file mode 100644 index 00000000000..7478160e957 --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_item.js @@ -0,0 +1,9 @@ +import { Component } from "@odoo/owl"; +import { Todo } from "./todo_model"; + +export class TodoItem extends Component { + static template = "awesome_owl.TodoItem"; + static props = { + todo: Todo, + }; +} diff --git a/awesome_owl/static/src/todo_list/todo_item.xml b/awesome_owl/static/src/todo_list/todo_item.xml new file mode 100644 index 00000000000..4bc11398e8d --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_item.xml @@ -0,0 +1,14 @@ + + + +
+ + + + +
+
+
diff --git a/awesome_owl/static/src/todo_list/todo_list.js b/awesome_owl/static/src/todo_list/todo_list.js new file mode 100644 index 00000000000..44f98af8a76 --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_list.js @@ -0,0 +1,21 @@ +import { Component, useState } from "@odoo/owl"; +import { TodoItem } from "./todo_item"; +import { useAutofocus } from "../utils"; +import { TodoModel } from "./todo_model"; + +export class TodoList extends Component { + static template = "awesome_owl.TodoList"; + static components = { TodoItem }; + + setup() { + this.model = useState(new TodoModel()); + useAutofocus("input") + } + + addTodo(ev) { + if (ev.keyCode === 13 && ev.target.value != "") { + this.model.add(ev.target.value); + ev.target.value = ""; + } + } +} diff --git a/awesome_owl/static/src/todo_list/todo_list.xml b/awesome_owl/static/src/todo_list/todo_list.xml new file mode 100644 index 00000000000..b7359da9274 --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_list.xml @@ -0,0 +1,11 @@ + + + +
+ + + + +
+
+
diff --git a/awesome_owl/static/src/todo_list/todo_model.js b/awesome_owl/static/src/todo_list/todo_model.js new file mode 100644 index 00000000000..6da9a1a6a8e --- /dev/null +++ b/awesome_owl/static/src/todo_list/todo_model.js @@ -0,0 +1,37 @@ + +export class Todo { + static nextId = 1; + + constructor(model, description) { + this._model = model; + this.id = Todo.nextId++; + this.description = description; + this.isCompleted = false; + } + + toggle() { + this.isCompleted = !this.isCompleted; + } + + remove() { + this._model.remove(this.id); + } +} + +export class TodoModel { + constructor() { + this.todos = []; + } + + add(description) { + const todo = new Todo(this, description); + this.todos.push(todo); + } + + remove(id) { + const todoIndex = this.todos.findIndex((todo) => todo.id === id); + if (todoIndex >= 0) { + this.todos.splice(todoIndex, 1); + } + } +} \ No newline at end of file diff --git a/awesome_owl/static/src/utils.js b/awesome_owl/static/src/utils.js new file mode 100644 index 00000000000..f452f103aa0 --- /dev/null +++ b/awesome_owl/static/src/utils.js @@ -0,0 +1,8 @@ +import { useRef, onMounted } from "@odoo/owl"; + +export function useAutofocus(refName) { + const ref = useRef(refName); + onMounted(() => { + ref.el.focus(); + }); +} diff --git a/awesome_shelter/__init__.py b/awesome_shelter/__init__.py new file mode 100644 index 00000000000..a0fdc10fe11 --- /dev/null +++ b/awesome_shelter/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import models diff --git a/awesome_shelter/__manifest__.py b/awesome_shelter/__manifest__.py new file mode 100644 index 00000000000..5a7af98f925 --- /dev/null +++ b/awesome_shelter/__manifest__.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +{ + "name": "Awesome Shelter", + "summary": """ + Companion addon for the Odoo JS Framework Training + """, + "description": """ + Companion addon for the Odoo JS Framework Training + """, + "author": "Odoo", + "website": "https://www.odoo.com/", + "category": "Tutorials", + "version": "0.1", + "application": True, + "installable": True, + "depends": ["base", "web", "contacts"], + "data": [ + "views/views.xml", + "security/ir.model.access.csv", + "data/shelter_data.xml", + ], + "assets": { + "web.assets_backend": [ + "awesome_shelter/static/src/**/*", + ], + }, + "license": "AGPL-3", +} diff --git a/awesome_shelter/data/shelter_data.xml b/awesome_shelter/data/shelter_data.xml new file mode 100644 index 00000000000..1da524ce1a6 --- /dev/null +++ b/awesome_shelter/data/shelter_data.xml @@ -0,0 +1,20 @@ + + + + + Cat + + + + + Dog + + + + + Stray + + + + + \ No newline at end of file diff --git a/awesome_shelter/models/__init__.py b/awesome_shelter/models/__init__.py new file mode 100644 index 00000000000..b90bab0a42a --- /dev/null +++ b/awesome_shelter/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import animal +from . import animal_type +from . import animal_race diff --git a/awesome_shelter/models/animal.py b/awesome_shelter/models/animal.py new file mode 100644 index 00000000000..3223927282d --- /dev/null +++ b/awesome_shelter/models/animal.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, api +from dateutil.relativedelta import relativedelta + + +class Animal(models.Model): + _name = "awesome_shelter.animal" + _description = "Animal" + _inherit = ["mail.thread", "mail.activity.mixin"] + + name = fields.Char(required=True) + picture = fields.Image("Picture") + type_id = fields.Many2one("awesome_shelter.animal_type", "Type", required=True) + pictogram = fields.Image(related="type_id.pictogram") + state = fields.Selection( + selection=[ + ("on_site", "On site"), + ("adopted", "Adopted"), + ], + default="on_site", + required=True, + string="State", + tracking=True, + ) + race_id = fields.Many2one("awesome_shelter.animal_race", "Race") + notes = fields.Html("Notes") + dropper_id = fields.Many2one("res.partner", "Dropper") + drop_date = fields.Date("Drop date", default=fields.Date.today(), required=True) + owner_id = fields.Many2one("res.partner", "Owner", tracking=True) + birth_date = fields.Date("Birth date") + + is_present_for_six_month = fields.Boolean( + compute="_compute_is_present_for_six_month" + ) + + @api.depends("drop_date") + def _compute_is_present_for_six_month(self): + for record in self: + record.is_present_for_six_month = ( + fields.Date.today() + relativedelta(months=-6) > record.drop_date + ) diff --git a/awesome_shelter/models/animal_race.py b/awesome_shelter/models/animal_race.py new file mode 100644 index 00000000000..205906af281 --- /dev/null +++ b/awesome_shelter/models/animal_race.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class AnimalRace(models.Model): + _name = "awesome_shelter.animal_race" + _description = "Race" + + name = fields.Char(required=True) diff --git a/awesome_shelter/models/animal_type.py b/awesome_shelter/models/animal_type.py new file mode 100644 index 00000000000..0decb8b77aa --- /dev/null +++ b/awesome_shelter/models/animal_type.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class AnimalType(models.Model): + _name = "awesome_shelter.animal_type" + _description = "Type" + + name = fields.Char(required=True) + pictogram = fields.Image("Pictogram") diff --git a/awesome_shelter/security/ir.model.access.csv b/awesome_shelter/security/ir.model.access.csv new file mode 100644 index 00000000000..e796e844b32 --- /dev/null +++ b/awesome_shelter/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +awesome_shelter.access_shelter_animal,access_shelter_animal,awesome_shelter.model_awesome_shelter_animal,base.group_user,1,1,1,1 +awesome_shelter.access_shelter_animal_type,access_shelter_animal_type,awesome_shelter.model_awesome_shelter_animal_type,base.group_user,1,1,1,1 +awesome_shelter.access_shelter_animal_race,access_shelter_animal_race,awesome_shelter.model_awesome_shelter_animal_race,base.group_user,1,1,1,1 diff --git a/awesome_shelter/static/img/cat_pictogram.png b/awesome_shelter/static/img/cat_pictogram.png new file mode 100644 index 00000000000..70ba929f084 Binary files /dev/null and b/awesome_shelter/static/img/cat_pictogram.png differ diff --git a/awesome_shelter/static/img/dog_pictogram.png b/awesome_shelter/static/img/dog_pictogram.png new file mode 100644 index 00000000000..7b304821371 Binary files /dev/null and b/awesome_shelter/static/img/dog_pictogram.png differ diff --git a/awesome_shelter/static/src/fields/animal_type_many_2_one.js b/awesome_shelter/static/src/fields/animal_type_many_2_one.js new file mode 100644 index 00000000000..557a9cfa05a --- /dev/null +++ b/awesome_shelter/static/src/fields/animal_type_many_2_one.js @@ -0,0 +1,33 @@ +import { registry } from "@web/core/registry"; +import { Many2OneField, buildM2OFieldDescription } from "@web/views/fields/many2one/many2one_field"; +import { imageUrl } from "@web/core/utils/urls"; + +const field = buildM2OFieldDescription(Many2OneField); + +class AnimalTypeManyToOne extends Many2OneField { + + static template = "shelter.AnimalTypeManyToOne"; + static props = { ...Many2OneField.props, imageField: { type: String } }; + + get pictogramUrl() { + return imageUrl(this.props.record.resModel, this.props.record.resId, this.props.imageField); + } + + get hasImage() { + return Boolean(this.props.record.data[this.props.imageField]); + } + +} + +const animalTypeManyToOne = { + ...field, + component: AnimalTypeManyToOne, + fieldDependencies: [...field.fieldDependencies || [], { name: "pictogram", type: "image" }], + extractProps({options}) { + const props = field.extractProps(...arguments); + props.imageField = options.image_field; + return props; + } +} + +registry.category("fields").add("animal_type_many2one", animalTypeManyToOne); diff --git a/awesome_shelter/static/src/fields/animal_type_many_2_one.xml b/awesome_shelter/static/src/fields/animal_type_many_2_one.xml new file mode 100644 index 00000000000..0c47931e30c --- /dev/null +++ b/awesome_shelter/static/src/fields/animal_type_many_2_one.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/awesome_shelter/static/src/fields/name_generator_field.js b/awesome_shelter/static/src/fields/name_generator_field.js new file mode 100644 index 00000000000..6ee9d600e84 --- /dev/null +++ b/awesome_shelter/static/src/fields/name_generator_field.js @@ -0,0 +1,47 @@ +import { standardFieldProps } from "@web/views/fields/standard_field_props" +import { registry } from "@web/core/registry" +import { charField } from "@web/views/fields/char/char_field"; + +class NameGeneratorField extends charField.component { + static template = "shelter.NameGeneratorField"; + static props = { ...charField.component.props }; + static names = [ + "Charlie", + "Daisy", + "Bella", + "Lola", + "Luna", + "Milo", + "Teddy", + "Cooper", + "Max", + "Bailey", + "Buddy", + "Coco", + "Leo", + "Loki", + "Lucy", + "Chloé", + "Oscar", + "Rocky", + "Sadie", + "Bonnie", + "Poppy", + ]; + + generate() { + const name = NameGeneratorField.names[Math.floor(Math.random() * NameGeneratorField.names.length)]; + this.props.record.update({ [this.props.name]: name }); + } + + get shouldShowButton() { + return !this.props.record.data[this.props.name]; + } +} + +const nameGeneratorField = { + ...charField, + component: NameGeneratorField, +} + +registry.category("fields").add("name_generator", nameGeneratorField); diff --git a/awesome_shelter/static/src/fields/name_generator_field.xml b/awesome_shelter/static/src/fields/name_generator_field.xml new file mode 100644 index 00000000000..fe08d77f858 --- /dev/null +++ b/awesome_shelter/static/src/fields/name_generator_field.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/awesome_shelter/static/src/fields/statusbar_rainbowman_field.js b/awesome_shelter/static/src/fields/statusbar_rainbowman_field.js new file mode 100644 index 00000000000..31e59ff557b --- /dev/null +++ b/awesome_shelter/static/src/fields/statusbar_rainbowman_field.js @@ -0,0 +1,34 @@ +import { registry } from "@web/core/registry" +import { useService } from "@web/core/utils/hooks"; +import { statusBarField } from "@web/views/fields/statusbar/statusbar_field"; + +class StatusbarRainbowManField extends statusBarField.component { + static props = { ...statusBarField.component.props, rainbowStages: { type: Array } }; + static template = statusBarField.component.template; + + setup() { + super.setup(); + this.effect = useService("effect"); + } + + async selectItem(item) { + super.selectItem(item); + if (this.props.rainbowStages.includes(item.value)) { + this.effect.add({ message: "A new happy life on the making" }); + } + } + +} + +const statusbarRainbowManField = { + ...statusBarField, + additionalClasses: ["o_field_statusbar"], + component: StatusbarRainbowManField, + extractProps({ options }) { + const props = statusBarField.extractProps(...arguments); + props.rainbowStages = options.rainbow_stages; + return props; + } +} + +registry.category("fields").add("statusbar_rainbowman", statusbarRainbowManField); diff --git a/awesome_shelter/static/src/utils/useInterval.js b/awesome_shelter/static/src/utils/useInterval.js new file mode 100644 index 00000000000..f61a26e2eae --- /dev/null +++ b/awesome_shelter/static/src/utils/useInterval.js @@ -0,0 +1,14 @@ +import { browser } from "@web/core/browser/browser"; +import { onMounted, onWillUnmount } from "@odoo/owl"; + +/** + * Creates an interval that will call the given callback every + * `duration` ms. + * @param {Function} callback + * @param {Number} duration + */ +export function useInterval(callback, duration) { + let interval; + onMounted(() => (interval = browser.setInterval(callback, duration))); + onWillUnmount(() => browser.clearInterval(interval)); +} diff --git a/awesome_shelter/static/src/views/kanban_view.js b/awesome_shelter/static/src/views/kanban_view.js new file mode 100644 index 00000000000..5785a696e62 --- /dev/null +++ b/awesome_shelter/static/src/views/kanban_view.js @@ -0,0 +1,23 @@ +import { kanbanView } from "@web/views/kanban/kanban_view"; +import { registry } from "@web/core/registry"; +import { useInterval } from "../utils/useInterval"; + +class ShelterKanbanController extends kanbanView.Controller { + + setup() { + super.setup(); + useInterval(this.reload.bind(this), 10000); + } + + reload() { + this.model.load(); + } + +} + +const shelterKanbanView = { + ...kanbanView, + Controller: ShelterKanbanController, +}; + +registry.category("views").add("shelter_kanban", shelterKanbanView); diff --git a/awesome_shelter/static/src/widgets/long_stay_banner.js b/awesome_shelter/static/src/widgets/long_stay_banner.js new file mode 100644 index 00000000000..2602ef8f231 --- /dev/null +++ b/awesome_shelter/static/src/widgets/long_stay_banner.js @@ -0,0 +1,21 @@ +import { Component } from "@odoo/owl" +import { standardWidgetProps } from "@web/views/widgets/standard_widget_props"; +import { registry } from "@web/core/registry"; + + +class LongStayBannerWidget extends Component { + + static template = "shelter.LongStayBannerWidget"; + static props = { ...standardWidgetProps } + + get animalName () { + return this.props.record.data["display_name"]; + } + +} + +const longStayBannerWidget = { + component: LongStayBannerWidget, +} + +registry.category("view_widgets").add("long_stay_banner", longStayBannerWidget); diff --git a/awesome_shelter/static/src/widgets/long_stay_banner.xml b/awesome_shelter/static/src/widgets/long_stay_banner.xml new file mode 100644 index 00000000000..ce1a7dce005 --- /dev/null +++ b/awesome_shelter/static/src/widgets/long_stay_banner.xml @@ -0,0 +1,10 @@ + + + + +
+ Oh no, has been here for a long time... +
+
+ +
diff --git a/awesome_shelter/views/views.xml b/awesome_shelter/views/views.xml new file mode 100644 index 00000000000..6eca01e6e04 --- /dev/null +++ b/awesome_shelter/views/views.xml @@ -0,0 +1,87 @@ + + + + + Animals + awesome_shelter.animal + list,kanban,form + + + + awesome_shelter.animal.list + awesome_shelter.animal + + + + + + + + + + + + awesome_shelter.animal.kanban + awesome_shelter.animal + + + + + + +
+ + +
+
+ +
+
+
+ +
+ + + awesome_shelter.animal.form + awesome_shelter.animal + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + +
+
diff --git a/awesome_tests/__init__.py b/awesome_tests/__init__.py new file mode 100644 index 00000000000..40a96afc6ff --- /dev/null +++ b/awesome_tests/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/awesome_tests/__manifest__.py b/awesome_tests/__manifest__.py new file mode 100644 index 00000000000..0b4ae2880e9 --- /dev/null +++ b/awesome_tests/__manifest__.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +{ + 'name': "Awesome Tests", + + 'summary': """ + Companion addon for the Odoo JS Framework Training + """, + + 'description': """ + Companion addon for the Odoo JS Framework Training + """, + + 'author': "Odoo", + 'website': "https://www.odoo.com", + + # Categories can be used to filter modules in modules listing + # Check https://github.com/odoo/odoo/blob/15.0/odoo/addons/base/data/ir_module_category_data.xml + # for the full list + 'category': 'Tutorials', + 'version': '0.2', + + # any module necessary for this one to work correctly + 'depends': ['base', 'web'], + 'application': True, + 'installable': True, + 'data': [ + ], + 'assets': { + }, + 'license': 'AGPL-3' +} diff --git a/awesome_website/__init__.py b/awesome_website/__init__.py new file mode 100644 index 00000000000..b0f26a9a602 --- /dev/null +++ b/awesome_website/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import controllers diff --git a/awesome_website/__manifest__.py b/awesome_website/__manifest__.py new file mode 100644 index 00000000000..b6836422ca5 --- /dev/null +++ b/awesome_website/__manifest__.py @@ -0,0 +1,49 @@ +{ + "name": "Awesome Website", + "summary": """ + Companion addon for the Odoo JS Framework Training + """, + "description": """ + Companion addon for the Odoo JS Framework Training + """, + "author": "Odoo", + "website": "https://www.odoo.com", + # Categories can be used to filter modules in modules listing + # Check https://github.com/odoo/odoo/blob/19.0/odoo/addons/base/data/ir_module_category_data.xml + # for the full list + "category": "Tutorials", + "version": "0.2", + # any module necessary for this one to work correctly + "depends": ["website"], + "application": True, + "installable": True, + "data": [ + "data/images.xml", + "views/snippets/s_image_comparison.xml", + "views/snippets/s_timer.xml", + "views/snippets/snippets.xml", + ], + "assets": { + "web.assets_frontend": [ + "awesome_website/static/src/snippets/**/*.scss", + "awesome_website/static/src/interactions/**/*.js", + "awesome_website/static/src/snippets/s_image_comparison/image_comparison.js", + ], + "website.assets_inside_builder_iframe": [ + "awesome_website/static/src/snippets/**/*.edit.scss", + ], + "html_builder.iframe_add_dialog": [ + "awesome_website/static/src/snippets/**/*.preview.scss", + ], + "website.website_builder_assets": [ + "awesome_website/static/src/website_builder/image_comparison_snippet_option.*", + ], + "web.assets_unit_tests": [ + "awesome_website/static/tests/**/*", + ], + "web.assets_unit_tests_setup": [ + "awesome_website/static/src/**/*", + ], + }, + "license": "AGPL-3", +} diff --git a/awesome_website/controllers/__init__.py b/awesome_website/controllers/__init__.py new file mode 100644 index 00000000000..457bae27e11 --- /dev/null +++ b/awesome_website/controllers/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import controllers \ No newline at end of file diff --git a/awesome_website/controllers/controllers.py b/awesome_website/controllers/controllers.py new file mode 100644 index 00000000000..e6bfda796c9 --- /dev/null +++ b/awesome_website/controllers/controllers.py @@ -0,0 +1,10 @@ +from odoo import http +from odoo.http import request, route + +class OwlPlayground(http.Controller): + @http.route(['/awesome_website'], type='http', auth='public') + def show_playground(self): + """ + Renders the owl playground page + """ + return request.render('awesome_owl.playground') diff --git a/awesome_website/data/images.xml b/awesome_website/data/images.xml new file mode 100644 index 00000000000..27d5129e488 --- /dev/null +++ b/awesome_website/data/images.xml @@ -0,0 +1,17 @@ + + + + + + s_image_comparison_01.jpg + url + /awesome_website/static/src/img/s_image_comparison_01.jpg + + + + s_image_comparison_02.jpg + url + /awesome_website/static/src/img/s_image_comparison_02.jpg + + + diff --git a/awesome_website/static/src/img/s_image_comparison_01.jpg b/awesome_website/static/src/img/s_image_comparison_01.jpg new file mode 100644 index 00000000000..2a2585a55cb Binary files /dev/null and b/awesome_website/static/src/img/s_image_comparison_01.jpg differ diff --git a/awesome_website/static/src/img/s_image_comparison_02.jpg b/awesome_website/static/src/img/s_image_comparison_02.jpg new file mode 100644 index 00000000000..feb293e5888 Binary files /dev/null and b/awesome_website/static/src/img/s_image_comparison_02.jpg differ diff --git a/awesome_website/static/src/interactions/main.js b/awesome_website/static/src/interactions/main.js new file mode 100644 index 00000000000..9a42beda554 --- /dev/null +++ b/awesome_website/static/src/interactions/main.js @@ -0,0 +1,15 @@ +import { registry } from "@web/core/registry"; +import { Interaction } from "@web/public/interaction"; + +class Main extends Interaction { + static selector = "main"; + + dynamicContent = { + _root: { + "t-out": () => (new Date()).toLocaleString(), + }, + }; + +} + +// registry.category("public.interactions").add("awesome_website.main", Main); diff --git a/awesome_website/static/src/interactions/mouse_follower.js b/awesome_website/static/src/interactions/mouse_follower.js new file mode 100644 index 00000000000..9ca70054674 --- /dev/null +++ b/awesome_website/static/src/interactions/mouse_follower.js @@ -0,0 +1,38 @@ +import { registry } from "@web/core/registry"; +import { Interaction } from "@web/public/interaction"; + +class CursorFollow extends Interaction { + static selector = "#wrapwrap"; + dynamicContent = { + _root: { + "t-on-mousemove": (event) => { + this.x_mouse_follower = event.clientX; + this.y_mouse_follower = event.clientY; + }, + }, + ".x_mouse_follower": { + "t-att-style": () => ({ + position: "absolute", + "z-index": "auto", + top: `${this.y_mouse_follower}px`, + left: `${this.x_mouse_follower}px`, + }), + }, + }; + + setup() { + this.x_mouse_follower = 0; + this.y_mouse_follower = 0; + + this.cursorEl = document.createElement("span"); + this.cursorEl.classList.add("x_mouse_follower"); + } + + start() { + this.insert(this.cursorEl, this.el); + } +} + +// registry +// .category("public.interactions") +// .add("awesome_website.cursor_follow", CursorFollow); diff --git a/awesome_website/static/src/interactions/mouse_follower.scss b/awesome_website/static/src/interactions/mouse_follower.scss new file mode 100644 index 00000000000..1cd7fd54e15 --- /dev/null +++ b/awesome_website/static/src/interactions/mouse_follower.scss @@ -0,0 +1,13 @@ +#wrapwrap .x_mouse_follower { + width: 72px; + height: 72px; + filter: blur(5px); + position: fixed; + background-color: var(--o-color-1); + opacity: 0.5; + border-radius: 50%; + pointer-events: none; + z-index:-1; + /*rtl:ignore*/ + transform: translate(-50%, -50%); +} diff --git a/awesome_website/static/src/interactions/owl_component.js b/awesome_website/static/src/interactions/owl_component.js new file mode 100644 index 00000000000..d24cbf74435 --- /dev/null +++ b/awesome_website/static/src/interactions/owl_component.js @@ -0,0 +1,43 @@ +import { Component, xml, useState, onWillStart, onWillDestroy } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { Interaction } from "@web/public/interaction"; + +class Timer extends Component { + static template = xml`
+
+
+ +
+
+
`; + static props = { + locale: { + type: String, + }, + }; + + setup() { + this.state = useState({ time: new Date() }); + onWillStart(() => { + this.interval = setInterval(() => (this.state.time = new Date()), 100); + }); + onWillDestroy(() => { + if (this.interval) { + clearInterval(this.interval); + } + }); + } +} + +class OwlInteraction extends Interaction { + static selector = "main"; + dynamicContent = { + _root: { + "t-component": () => [Timer, { locale: "fr" }], + }, + }; +} + +registry.category("public_components").add("OwlTimer", Timer); + +registry.category("public.interactions").add("OwlTimer", OwlInteraction); diff --git a/awesome_website/static/src/snippets/s_image_comparison/000.edit.scss b/awesome_website/static/src/snippets/s_image_comparison/000.edit.scss new file mode 100644 index 00000000000..d01a0fe645b --- /dev/null +++ b/awesome_website/static/src/snippets/s_image_comparison/000.edit.scss @@ -0,0 +1,9 @@ +.o_image_comparison_container { + .o_image_comparison_slider { + clip-path: rect(0 calc(var(--slider-position) + 1%) 100% var(--slider-position)); + + .o_rtl & { + clip-path: rect(0 calc(100% - var(--slider-position)) 100% calc(100% - var(--slider-position) - 1%)); + } + } +} diff --git a/awesome_website/static/src/snippets/s_image_comparison/000.preview.scss b/awesome_website/static/src/snippets/s_image_comparison/000.preview.scss new file mode 100644 index 00000000000..0627b5a5bfe --- /dev/null +++ b/awesome_website/static/src/snippets/s_image_comparison/000.preview.scss @@ -0,0 +1,29 @@ +@property --slider-position { + syntax: ""; + inherits: true; + initial-value: 50%; +} + +@keyframes slide { +0% { + --slider-position: 50%; +} +25% { + --slider-position: 100%; +} +50% { + --slider-position: 50%; +} +75% { + --slider-position: 0%; +} +100% { + --slider-position: 50%; +} +} + +.o_snippet_preview_wrap:hover{ + .o_image_comparison_container{ + animation: 5s linear infinite slide; + } +} diff --git a/awesome_website/static/src/snippets/s_image_comparison/000.scss b/awesome_website/static/src/snippets/s_image_comparison/000.scss new file mode 100644 index 00000000000..f2ed1f38e4b --- /dev/null +++ b/awesome_website/static/src/snippets/s_image_comparison/000.scss @@ -0,0 +1,70 @@ +.o_image_comparison_s_rounded { + --rounding: 1rem; +} +.o_image_comparison_m_rounded { + --rounding: 2rem; +} +.o_image_comparison_xl_rounded { + --rounding: 4rem; +} + +.o_image_comparison_container { + --slider-position: 50%; + + img { + grid-area: 1 / 1; + width: 100%; + height: 100%; + border-radius: var(--rounding, 1rem); + + &.o_image_after { + clip-path: rect(0 100% 100% var(--slider-position)); + + .o_rtl & { + clip-path: rect(0 calc(100% - var(--slider-position)) 100% 0); + } + } + } + + .o_image_comparison_slider { + inset: 0; + margin-inline: -2px; + appearance: none; + background-color: transparent; + cursor: ew-resize; + z-index: 1; + + &::-webkit-slider-thumb { + appearance: none; + height: 100vh; + width: 4px; + border: 0; + background-color: #fff; + } + &::-moz-range-thumb { + appearance: none; + height: 100vh; + width: 4px; + border: 0; + background-color: #fff; + } + + &:focus-visible + .o_image_comparison_handle { + box-shadow: 0 0 0 3px var(--o-color-1), 0 0 0 6px #fff; + } + } + + .o_image_comparison_handle { + left: var(--slider-position); + width: 2rem; + aspect-ratio: 1; + background-color: #fff; + } +} + +.o_image_comparison_bw { + img { + filter: grayscale(1); + } +} + diff --git a/awesome_website/static/src/snippets/s_image_comparison/image_comparison.edit.js b/awesome_website/static/src/snippets/s_image_comparison/image_comparison.edit.js new file mode 100644 index 00000000000..a73fccc208d --- /dev/null +++ b/awesome_website/static/src/snippets/s_image_comparison/image_comparison.edit.js @@ -0,0 +1,23 @@ +import { registry } from "@web/core/registry"; +import { ImageComparison } from "./image_comparison"; + +const ImageComparisonEdit = (I) => + class extends I { + dynamicContent = { + _root: { + "t-att-class": () => ({ + "pe-none": true, + }), + }, + }; + updateSliderPosition() { + return + } + }; + +registry + .category("public.interactions.edit") + .add("awesome_website.image_comparison", { + Interaction: ImageComparison, + mixin: ImageComparisonEdit, + }); diff --git a/awesome_website/static/src/snippets/s_image_comparison/image_comparison.js b/awesome_website/static/src/snippets/s_image_comparison/image_comparison.js new file mode 100644 index 00000000000..6ce3bfb6a0d --- /dev/null +++ b/awesome_website/static/src/snippets/s_image_comparison/image_comparison.js @@ -0,0 +1,18 @@ +import { registry } from "@web/core/registry"; +import { Interaction } from "@web/public/interaction"; + +export class ImageComparison extends Interaction { + static selector = ".o_image_comparison_container"; + + dynamicContent = { + ".o_image_comparison_slider": { + "t-on-input": this.updateSliderPosition, + }, + }; + + updateSliderPosition(event) { + this.el.style.setProperty("--slider-position", `${event.target.value}%`); + } +} + +registry.category("public.interactions").add("awesome_website.image_comparison", ImageComparison); diff --git a/awesome_website/static/src/snippets/s_image_comparison/image_comparison.preview.js b/awesome_website/static/src/snippets/s_image_comparison/image_comparison.preview.js new file mode 100644 index 00000000000..cd464376d7c --- /dev/null +++ b/awesome_website/static/src/snippets/s_image_comparison/image_comparison.preview.js @@ -0,0 +1,16 @@ +import { registry } from "@web/core/registry"; +import { ImageComparison } from "./image_comparison"; + +const ImageComparisonEdit = (I) => + class extends I { + updateSliderPosition() { + return + } + }; + +registry + .category("public.interactions.preview") + .add("awesome_website.image_comparison", { + Interaction: ImageComparison, + mixin: ImageComparisonEdit, + }); diff --git a/awesome_website/static/src/website_builder/image_comparison_snippet_option.js b/awesome_website/static/src/website_builder/image_comparison_snippet_option.js new file mode 100644 index 00000000000..54307aedb25 --- /dev/null +++ b/awesome_website/static/src/website_builder/image_comparison_snippet_option.js @@ -0,0 +1,19 @@ +import { BaseOptionComponent } from "@html_builder/core/base_option_component"; +import { Plugin } from "@html_editor/plugin"; +import { registry } from "@web/core/registry"; + +export class ImageComparisonSnippetOption extends BaseOptionComponent { + static template = "awesome_website.ImageComparisonSnippetOption"; + static selector = ".s_image_comparison"; +} + +export class ImageComparisonSnippetOptionPlugin extends Plugin { + static id = "imageComparisonSnippetOption"; + resources = { + builder_options: [ImageComparisonSnippetOption], + }; +} + +registry + .category("website-plugins") + .add(ImageComparisonSnippetOptionPlugin.id, ImageComparisonSnippetOptionPlugin); diff --git a/awesome_website/static/src/website_builder/image_comparison_snippet_option.xml b/awesome_website/static/src/website_builder/image_comparison_snippet_option.xml new file mode 100644 index 00000000000..c802f8cfc6f --- /dev/null +++ b/awesome_website/static/src/website_builder/image_comparison_snippet_option.xml @@ -0,0 +1,22 @@ + + + + + + + + S + M + XL + + + + + + + + + + diff --git a/awesome_website/static/tests/snippets/s_image_comparison/image_comparison.test.js b/awesome_website/static/tests/snippets/s_image_comparison/image_comparison.test.js new file mode 100644 index 00000000000..91a5ad3bffc --- /dev/null +++ b/awesome_website/static/tests/snippets/s_image_comparison/image_comparison.test.js @@ -0,0 +1,39 @@ +import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers"; +import { defineStyle } from "@web/../tests/web_test_helpers"; + +import { describe, expect, test, manuallyDispatchProgrammaticEvent } from "@odoo/hoot"; +import { queryOne } from "@odoo/hoot-dom"; + +setupInteractionWhiteList("awesome_website.image_comparison"); + +describe("interaction_dev", () => { + test("image_comparison exist", async () => { + defineStyle(/* css */ `* { transition: none !important; }`); + const { core } = await startInteractions(` +
+
+
+ + + +
+
+
+
+ `); + const input = queryOne("input"); + const snippet = queryOne(".o_image_comparison_container"); + + expect(core.interactions).toHaveLength(1); + + expect(input).toHaveValue(50); + expect(snippet).toHaveStyle({ "--slider-position": "50%" }); + + input.value = "99"; + await manuallyDispatchProgrammaticEvent(input, "input"); + + expect(queryOne(".o_image_comparison_container")).toHaveStyle({ + "--slider-position": "99%", + }); + }); +}); diff --git a/awesome_website/static/tests/website_builder/image_comparison_snippet_option.test.js b/awesome_website/static/tests/website_builder/image_comparison_snippet_option.test.js new file mode 100644 index 00000000000..70c6acefe19 --- /dev/null +++ b/awesome_website/static/tests/website_builder/image_comparison_snippet_option.test.js @@ -0,0 +1,29 @@ +import { describe, expect, test, queryOne, click } from "@odoo/hoot"; +import { animationFrame } from "@odoo/hoot-dom"; +import { + defineWebsiteModels, + setupWebsiteBuilderWithSnippet, +} from "@website/../tests/builder/website_helpers"; + +describe("awesome_website.builder", () => { + defineWebsiteModels(); + + test("rounding in image_comparison", async () => { + const { waitSidebarUpdated } = await setupWebsiteBuilderWithSnippet("s_image_comparison"); + + const snippetEl = queryOne(":iframe .s_image_comparison"); + + await click(snippetEl); + await waitSidebarUpdated(); + + expect("[data-container-title='Image Comparison']").toBeVisible(); + const optionBtn = queryOne("[data-class-action='o_image_comparison_m_rounded']"); + expect(optionBtn).toBeVisible(); + + await click(optionBtn); + + await animationFrame(); + + expect(snippetEl).toHaveClass("o_image_comparison_m_rounded"); + }); +}); diff --git a/awesome_website/views/snippets/s_image_comparison.xml b/awesome_website/views/snippets/s_image_comparison.xml new file mode 100644 index 00000000000..dca8ca70ee3 --- /dev/null +++ b/awesome_website/views/snippets/s_image_comparison.xml @@ -0,0 +1,21 @@ + + + + + + + web.assets_frontend + awesome_website/static/src/snippets/s_image_comparison/000.scss + + diff --git a/awesome_website/views/snippets/s_timer.xml b/awesome_website/views/snippets/s_timer.xml new file mode 100644 index 00000000000..0e3d83c3114 --- /dev/null +++ b/awesome_website/views/snippets/s_timer.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/awesome_website/views/snippets/snippets.xml b/awesome_website/views/snippets/snippets.xml new file mode 100644 index 00000000000..44166e2dfb2 --- /dev/null +++ b/awesome_website/views/snippets/snippets.xml @@ -0,0 +1,15 @@ + + + +