diff --git a/setup/web_help/odoo/addons/web_help b/setup/web_help/odoo/addons/web_help new file mode 120000 index 000000000..ab9e4cced --- /dev/null +++ b/setup/web_help/odoo/addons/web_help @@ -0,0 +1 @@ +../../../../web_help \ No newline at end of file diff --git a/setup/web_help/setup.py b/setup/web_help/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/web_help/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/web_help/README.rst b/web_help/README.rst new file mode 100644 index 000000000..38929e877 --- /dev/null +++ b/web_help/README.rst @@ -0,0 +1,35 @@ +**This file is going to be generated by oca-gen-addon-readme.** + +*Manual changes will be overwritten.* + +Please provide content in the ``readme`` directory: + +* **DESCRIPTION.rst** (required) +* INSTALL.rst (optional) +* CONFIGURE.rst (optional) +* **USAGE.rst** (optional, highly recommended) +* DEVELOP.rst (optional) +* ROADMAP.rst (optional) +* HISTORY.rst (optional, recommended) +* **CONTRIBUTORS.rst** (optional, highly recommended) +* CREDITS.rst (optional) + +Content of this README will also be drawn from the addon manifest, +from keys such as name, authors, maintainers, development_status, +and license. + +A good, one sentence summary in the manifest is also highly recommended. + + +Automatic changelog generation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +`HISTORY.rst` can be auto generated using `towncrier `_. + +Just put towncrier compatible changelog fragments into `readme/newsfragments` +and the changelog file will be automatically generated and updated when a new fragment is added. + +Please refer to `towncrier` documentation to know more. + +NOTE: the changelog will be automatically generated when using `/ocabot merge $option`. +If you need to run it manually, refer to `OCA/maintainer-tools README `_. diff --git a/web_help/__init__.py b/web_help/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/web_help/__manifest__.py b/web_help/__manifest__.py new file mode 100644 index 000000000..3d4ffdd50 --- /dev/null +++ b/web_help/__manifest__.py @@ -0,0 +1,26 @@ +{ + "name": "Help Framework", + "sumamry": "This module introduces a new way to guide users", + "author": "Onestein,Odoo Community Association (OCA)", + "category": "Technical", + "license": "AGPL-3", + "version": "16.0.1.0.0", + "website": "https://github.com/OCA/web", + "depends": [ + "web", + ], + "assets": { + "web.assets_backend": [ + "web_help/static/src/components/highlighter/highlighter.esm.js", + "web_help/static/src/components/highlighter/highlighter.scss", + "web_help/static/src/components/highlighter/highlighter.xml", + "web_help/static/src/helpers.esm.js", + "web_help/static/src/trip.esm.js", + "web_help/static/src/user_trip.esm.js", + "web_help/static/src/change_password_trip.esm.js", + "web_help/static/src/components/help_button/help_button.esm.js", + "web_help/static/src/components/help_button/help_button.xml", + "web_help/static/src/trip.xml", + ] + }, +} diff --git a/web_help/readme/CONTRIBUTORS.rst b/web_help/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..47b6403d0 --- /dev/null +++ b/web_help/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Dennis Sluijk diff --git a/web_help/readme/DESCRIPTION.rst b/web_help/readme/DESCRIPTION.rst new file mode 100644 index 000000000..0f2c87499 --- /dev/null +++ b/web_help/readme/DESCRIPTION.rst @@ -0,0 +1,7 @@ +This module introduces a new way to guide the user through Odoo. +This module is created because **the standard Odoo tours:** + +#. forces the user to create records; +#. cannot be reopened; +#. is only available for the admin user; +#. the bubbles are not obvious. diff --git a/web_help/readme/USAGE.rst b/web_help/readme/USAGE.rst new file mode 100644 index 000000000..8ee6f9d70 --- /dev/null +++ b/web_help/readme/USAGE.rst @@ -0,0 +1,15 @@ +**Demo:** + + .. image:: ../static/description/demo.gif + +You can see the demo live by going to the users list view (Settings > Users & Companies > Users) +and clicking the little '?' next to the view switcher. + + .. image:: ../static/description/viewswitcher.png + +Also there's a demo for the change password wizard: + + .. image:: ../static/description/changepassword.png + +It's easy to create your own guides, please refer to ``static/src/user_trip.esm.js`` and +``static/src/change_password_trip.esm.js`` diff --git a/web_help/static/description/changepassword.png b/web_help/static/description/changepassword.png new file mode 100644 index 000000000..e3ccdc84a Binary files /dev/null and b/web_help/static/description/changepassword.png differ diff --git a/web_help/static/description/demo.gif b/web_help/static/description/demo.gif new file mode 100644 index 000000000..8428f9635 Binary files /dev/null and b/web_help/static/description/demo.gif differ diff --git a/web_help/static/description/viewswitcher.png b/web_help/static/description/viewswitcher.png new file mode 100644 index 000000000..b4a8cb435 Binary files /dev/null and b/web_help/static/description/viewswitcher.png differ diff --git a/web_help/static/src/change_password_trip.esm.js b/web_help/static/src/change_password_trip.esm.js new file mode 100644 index 000000000..0135d9aed --- /dev/null +++ b/web_help/static/src/change_password_trip.esm.js @@ -0,0 +1,23 @@ +/** @odoo-module **/ +import {Trip} from "@web_help/trip.esm"; +import {registry} from "@web/core/registry"; + +export class ChangePasswordTrip extends Trip { + setup() { + this.addStep({ + selector: "th[data-name='new_passwd'], td[name='new_passwd']", + content: this.env._t("Change the password here, make sure it's secure."), + }); + + this.addStep({ + selector: "button[name='change_password_button']", + content: this.env._t("Click here to confirm it."), + }); + } +} + +registry.category("trips").add("change_password_trip", { + Trip: ChangePasswordTrip, + selector: (model, viewType) => + model === "change.password.wizard" && viewType === "form", +}); diff --git a/web_help/static/src/components/help_button/help_button.esm.js b/web_help/static/src/components/help_button/help_button.esm.js new file mode 100644 index 000000000..24b3a7bed --- /dev/null +++ b/web_help/static/src/components/help_button/help_button.esm.js @@ -0,0 +1,28 @@ +/** @odoo-module **/ +import LegacyControlPanel from "web.ControlPanel"; +import {ControlPanel} from "@web/search/control_panel/control_panel"; +import {findTrip} from "@web_help/helpers.esm"; +import {Component, useState} from "@odoo/owl"; +import {ActionDialog} from "@web/webclient/actions/action_dialog"; + +export class HelpButton extends Component { + setup() { + const foundTrip = findTrip(this.props.resModel, this.props.viewType); + this.state = useState({ + TripClass: foundTrip, + }); + } + + onClick() { + const TripClass = this.state.TripClass; + const trip = new TripClass(this.env); + trip.setup(); + trip.start(); + } +} + +HelpButton.template = "web_help.HelpButton"; + +Object.assign(ControlPanel.components, {HelpButton}); +Object.assign(LegacyControlPanel.components, {HelpButton}); +Object.assign(ActionDialog.components, {HelpButton}); diff --git a/web_help/static/src/components/help_button/help_button.xml b/web_help/static/src/components/help_button/help_button.xml new file mode 100644 index 000000000..ac6034547 --- /dev/null +++ b/web_help/static/src/components/help_button/help_button.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/web_help/static/src/components/highlighter/highlighter.esm.js b/web_help/static/src/components/highlighter/highlighter.esm.js new file mode 100644 index 000000000..a3caf7bb6 --- /dev/null +++ b/web_help/static/src/components/highlighter/highlighter.esm.js @@ -0,0 +1,131 @@ +/** @odoo-module **/ +import {Component, EventBus, useRef, useState} from "@odoo/owl"; +import {registry} from "@web/core/registry"; + +export class Highlighter extends Component { + setup() { + this.state = useState({visible: false}); + this.bus = this.props.bus; + this.bus.on("hide", this, () => this.hide()); + this.bus.on("highlight", this, (options) => this.highlight(options)); + this.highlightRef = useRef("highlightRef"); + this.overlayRef = useRef("overlay"); + } + + hide() { + this.state.visible = false; + this.resetAnimation(); + } + + _getBoundsOfElement($el) { + const bounds = { + x: Number.MAX_SAFE_INTEGER, + y: Number.MAX_SAFE_INTEGER, + }; + + let xEnd = 0, + yEnd = 0; + + $el.filter(":visible").each(function () { + const elementBounds = this.getBoundingClientRect(); + if (elementBounds.x < bounds.x) { + bounds.x = elementBounds.x; + } + if (elementBounds.y < bounds.y) { + bounds.y = elementBounds.y; + } + if (xEnd < elementBounds.x + elementBounds.width) { + xEnd = elementBounds.x + elementBounds.width; + } + if (yEnd < elementBounds.y + elementBounds.height) { + yEnd = elementBounds.y + elementBounds.height; + } + }); + + bounds.width = xEnd - bounds.x; + bounds.height = yEnd - bounds.y; + return bounds; + } + + highlight({selector, content, animate = 250, padding = 10}) { + const selection = $(selector); + + if (!selection.length) { + return console.error("Element not found.", selector); + } + const bounds = this._getBoundsOfElement(selection); + this.state.visible = true; + this.animate(content, bounds, animate, padding); + } + + animate(content, bounds, animate = 250, padding = 10) { + const $el = $(this.highlightRef.el); + + $el.popover("dispose"); + $el.animate( + { + top: _.str.sprintf("%spx", Math.floor(bounds.y) - padding), + left: _.str.sprintf("%spx", Math.floor(bounds.x) - padding), + width: _.str.sprintf("%spx", Math.floor(bounds.width) + padding * 2), + height: _.str.sprintf("%spx", Math.floor(bounds.height) + padding * 2), + }, + animate ? animate : 0, + function () { + $el.popover( + _.extend(this._getPopoverOptions(), { + content: content, + }) + ).popover("show"); + }.bind(this) + ); + } + + _getPopoverOptions() { + return { + container: $(this.overlayRef.el), + placement: "auto", + html: true, + trigger: "manual", + boundary: "viewport", + sanitize: false, + template: + '', + }; + } + + resetAnimation() { + const $el = $(this.highlightRef.el); + $el.popover("dispose"); + $el.css({ + top: 0, + left: 0, + width: 0, + height: 0, + }); + } +} +Highlighter.template = "web_help.Highlighter"; + +export const highlighterService = { + start() { + const bus = new EventBus(); + + registry.category("main_components").add("Highlighter", { + Component: Highlighter, + props: {bus}, + }); + + return { + hide: () => bus.trigger("hide"), + highlight: (selector, content, animate = 250, padding = 10) => + bus.trigger("highlight", { + selector: selector, + content: content, + animate: animate, + padding: padding, + }), + }; + }, +}; + +registry.category("services").add("highlighter", highlighterService); diff --git a/web_help/static/src/components/highlighter/highlighter.scss b/web_help/static/src/components/highlighter/highlighter.scss new file mode 100644 index 000000000..d2182ada0 --- /dev/null +++ b/web_help/static/src/components/highlighter/highlighter.scss @@ -0,0 +1,23 @@ +.web_help_overlay { + top: 0px; + left: 0px; + width: 100%; + height: 100%; + position: fixed; + z-index: 1151; + + .web_help_highlight { + position: absolute; + outline: 1000vw solid rgba(0, 0, 0, 0.7); + } + + .popover { + background-color: transparent; + border: none; + + .popover-body { + color: #fff; + font-size: 1.2rem; + } + } +} diff --git a/web_help/static/src/components/highlighter/highlighter.xml b/web_help/static/src/components/highlighter/highlighter.xml new file mode 100644 index 000000000..9a3e6c8b4 --- /dev/null +++ b/web_help/static/src/components/highlighter/highlighter.xml @@ -0,0 +1,15 @@ + + + + +
+
+
+
+
+ + diff --git a/web_help/static/src/helpers.esm.js b/web_help/static/src/helpers.esm.js new file mode 100644 index 000000000..dc93f1342 --- /dev/null +++ b/web_help/static/src/helpers.esm.js @@ -0,0 +1,16 @@ +/** @odoo-module **/ +import {registry} from "@web/core/registry"; + +export function findTrip(model, viewType) { + const trips = registry.category("trips").getAll(); + const matchedTrips = _.filter(trips, function (trip) { + return trip.selector(model, viewType); + }); + if (matchedTrips.length >= 1) { + if (matchedTrips.length != 1) { + console.warning("More than one trip found", model, viewType); + } + return matchedTrips[0].Trip; + } + return null; +} diff --git a/web_help/static/src/trip.esm.js b/web_help/static/src/trip.esm.js new file mode 100644 index 000000000..21467e284 --- /dev/null +++ b/web_help/static/src/trip.esm.js @@ -0,0 +1,99 @@ +/** @odoo-module **/ +import {renderToString} from "@web/core/utils/render"; + +export class Trip { + constructor(env) { + this.steps = []; + this.env = env; + this.index = -1; + this.highlighterService = this.env.services.highlighter; + } + + get count() { + return this.steps.length; + } + + get isAtLastStep() { + return this.index === this.count - 1; + } + + _getStepTemplate() { + const step = this.steps[this.index]; + if (step.template) { + return step.template; + } + return this.isAtLastStep ? "web_help.TripStepLast" : "web_help.TripStep"; + } + + setup() { + return; + } + + addStep({ + selector, + content, + beforeHighlight = async () => { + return; + }, + animate = 250, + padding = 10, + template = null, + renderContext = {}, + }) { + this.steps.push({ + selector: selector, + content: content, + beforeHighlight: beforeHighlight, + animate: animate, + padding: padding, + template: template, + renderContext: renderContext, + }); + } + + start() { + this.nextStep(); + } + + stop() { + this.index = -1; + this.highlighterService.hide(); + } + + _getStepRenderContext() { + const step = this.steps[this.index]; + + return Object.assign( + { + content: step.content, + cbBtnText: this.isAtLastStep + ? this.env._t("Finish") + : this.env._t("Got it"), + closeBtnText: this.env._t("Close"), + }, + step.renderContext + ); + } + + async nextStep() { + this.index++; + let cb = this.nextStep; + if (this.isAtLastStep) { + cb = this.stop; + } + const step = this.steps[this.index]; + + const $stepRender = $( + renderToString(this._getStepTemplate(), this._getStepRenderContext()) + ); + $stepRender.find(".web_help_cb_button").click(cb.bind(this)); + $stepRender.find(".web_help_close").click(this.stop.bind(this)); + await step.beforeHighlight(); + this.highlighterService.highlight( + step.selector, + $stepRender, + step.animate, + step.padding + ); + } +} diff --git a/web_help/static/src/trip.xml b/web_help/static/src/trip.xml new file mode 100644 index 000000000..cc2de88b9 --- /dev/null +++ b/web_help/static/src/trip.xml @@ -0,0 +1,29 @@ + + + +
+
+ +
+ + + +
+
+ + +
+
+ +
+ + +
+
+
diff --git a/web_help/static/src/user_trip.esm.js b/web_help/static/src/user_trip.esm.js new file mode 100644 index 000000000..4c9afb7b9 --- /dev/null +++ b/web_help/static/src/user_trip.esm.js @@ -0,0 +1,31 @@ +/** @odoo-module **/ +import {Trip} from "@web_help/trip.esm"; +import {registry} from "@web/core/registry"; + +export class UserTrip extends Trip { + setup() { + this.addStep({ + selector: ".o_list_button_add", + content: this.env._t("To create a new user click here."), + }); + + this.addStep({ + selector: ".o_cp_searchview", + content: this.env._t("Use the searchbar to find specific users."), + renderContext: { + cbBtnText: this.env._t("Next"), + closeBtnText: this.env._t("Cancel"), + }, + }); + + this.addStep({ + selector: ".o_cp_switch_buttons", + content: this.env._t("You can switch to different views here."), + }); + } +} + +registry.category("trips").add("user_trip", { + Trip: UserTrip, + selector: (model, viewType) => model === "res.users" && viewType === "list", +});