[REF] *: convert legacy dialogs

The goal of this commit is to convert a bunch of legacy dialogs into owl dialogs.

closes odoo/odoo#131278

Task-id: 3453920
Related: odoo/enterprise#45484
Signed-off-by: Samuel Degueldre (sad) <sad@odoo.com>
This commit is contained in:
FrancoisGe
2023-08-16 16:05:12 +02:00
parent a2b41824df
commit e3da5f1b7d
41 changed files with 820 additions and 936 deletions
@@ -1,9 +1,10 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { markup } from "@odoo/owl";
import { InputConfirmationDialog } from "@portal/js/components/input_confirmation_dialog/input_confirmation_dialog";
import { handleCheckIdentity } from "@portal/js/portal_security";
import publicWidget from "@web/legacy/js/public/public_widget";
import Dialog from "@web/legacy/js/core/dialog";
import {handleCheckIdentity} from "@portal/js/portal_security";
/**
* Replaces specific <field> elements by normal HTML, strip out the rest entirely
@@ -131,67 +132,6 @@ function fixupViewBody(oldNode, record) {
return [node, qrcode, code]
}
/**
* Converts a backend <button> element and a bunch of metadata into a structure
* which can kinda be of use to Dialog.
*/
class Button {
constructor(parent, model, record_id, input_node, button_node) {
this._parent = parent;
this.model = model;
this.record_id = record_id;
this.input = input_node;
this.text = button_node.getAttribute('string');
this.classes = button_node.getAttribute('class') || null;
this.action = button_node.getAttribute('name');
if (button_node.getAttribute('special') === 'cancel') {
this.close = true;
this.click = null;
} else {
this.close = false;
// because Dialog doesnt' call() click on the descriptor object
this.click = this._click.bind(this);
}
}
async _click() {
if (!this.input.reportValidity()) {
this.input.classList.add('is-invalid');
return;
}
try {
await this.callAction(this.record_id, {code: this.input.value});
} catch (e) {
this.input.classList.add('is-invalid');
// show custom validity error message
this.input.setCustomValidity(e.message);
this.input.reportValidity();
return;
}
this.input.classList.remove('is-invalid');
// reloads page, avoid window.location.reload() because it re-posts forms
window.location = window.location;
}
async callAction(id, update) {
try {
await this._parent._rpc({model: this.model, method: 'write', args: [id, update]});
await handleCheckIdentity(
this._parent.proxy('_rpc'),
this._parent._rpc({model: this.model, method: this.action, args: [id]})
);
} catch(e) {
// avoid error toast (crashmanager)
e.event.preventDefault();
// try to unwrap mess of an error object to a usable error message
throw new Error(
!e.message ? e.toString()
: !e.message.data ? e.message.message
: e.message.data.message || _t("Operation failed for unknown reason.")
);
}
}
}
publicWidget.registry.TOTPButton = publicWidget.Widget.extend({
selector: '#auth_totp_portal_enable',
events: {
@@ -205,7 +145,7 @@ publicWidget.registry.TOTPButton = publicWidget.Widget.extend({
model: 'res.users',
method: 'action_totp_enable_wizard',
args: [this.getSession().user_id]
}));
}), (...args) => this.call("dialog", "add", ...args));
if (!w) {
// TOTP probably already enabled, just reload page
@@ -225,27 +165,49 @@ publicWidget.registry.TOTPButton = publicWidget.Widget.extend({
);
const xmlBody = doc.querySelector('sheet *');
const [body, , codeInput] = fixupViewBody(xmlBody, record);
// remove custom validity error message any time the field changes
// otherwise it sticks and browsers suppress submit
codeInput.addEventListener('input', () => codeInput.setCustomValidity(''));
const [body, ,] = fixupViewBody(xmlBody, record);
const buttons = [];
for(const button of doc.querySelectorAll('footer button')) {
buttons.push(new Button(this, model, record.id, codeInput, button));
}
this.call("dialog", "add", InputConfirmationDialog, {
body: markup(body.outerHTML),
onInput: ({ inputEl }) => {
inputEl.setCustomValidity("");
},
confirmLabel: _t("Activate"),
confirm: async ({ inputEl }) => {
if (!inputEl.reportValidity()) {
inputEl.classList.add("is-invalid");
return false;
}
// wrap in a root host of .modal-body otherwise it breaks our neat flex layout
const $content = document.createElement('form');
$content.appendChild(body);
// implicit submission by pressing [return] from within input
$content.addEventListener('submit', (e) => {
e.preventDefault();
// sadness: footer not available as normal element
dialog.$footer.find('.btn-primary').click();
try {
await this._rpc({
model,
method: "write",
args: [record.id, { code: inputEl.value }],
});
await handleCheckIdentity(
this.proxy("_rpc"),
this._rpc({ model, method: "enable", args: [record.id] }),
(...args) => this.call("dialog", "add", ...args)
);
} catch (e) {
const errorMessage = (
!e.message ? e.toString()
: !e.message.data ? e.message.message
: e.message.data.message || _t("Operation failed for unknown reason.")
);
inputEl.classList.add("is-invalid");
// show custom validity error message
inputEl.setCustomValidity(errorMessage);
inputEl.reportValidity();
return false;
}
// reloads page, avoid window.location.reload() because it re-posts forms
window.location = window.location;
},
cancel: () => {},
});
var dialog = new Dialog(this, {$content, buttons}).open();
}
},
});
publicWidget.registry.DisableTOTPButton = publicWidget.Widget.extend({
selector: '#auth_totp_portal_disable',
@@ -257,7 +219,8 @@ publicWidget.registry.DisableTOTPButton = publicWidget.Widget.extend({
e.preventDefault();
await handleCheckIdentity(
this.proxy('_rpc'),
this._rpc({model: 'res.users', method: 'action_totp_disable', args: [this.getSession().user_id]})
this._rpc({model: 'res.users', method: 'action_totp_disable', args: [this.getSession().user_id]}),
(...args) => this.call("dialog", "add", ...args)
)
window.location = window.location;
}
@@ -276,7 +239,8 @@ publicWidget.registry.RevokeTrustedDeviceButton = publicWidget.Widget.extend({
model: 'auth_totp.device',
method: 'remove',
args: [parseInt(this.el.id)]
})
}),
(...args) => this.call("dialog", "add", ...args)
);
window.location = window.location;
}
@@ -295,7 +259,8 @@ publicWidget.registry.RevokeAllTrustedDevicesButton = publicWidget.Widget.extend
model: 'res.users',
method: 'revoke_all_devices',
args: [this.getSession().user_id]
})
}),
(...args) => this.call("dialog", "add", ...args)
);
window.location = window.location;
}
@@ -1,8 +1,8 @@
/** @odoo-module **/
import { formatDuration } from "@web/core/l10n/dates";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { formatDuration } from "@web/views/fields/formatters";
import {
preloadStatusBar,
statusBarField,
+1 -1
View File
@@ -39,7 +39,7 @@
'payment/static/src/js/manage_form.js',
'payment/static/src/js/payment_form_mixin.js',
'payment/static/src/js/post_processing.js',
'payment/static/src/xml/payment_post_processing.xml',
'payment/static/src/xml/*.xml',
],
'web.assets_backend': [
'payment/static/src/scss/payment_provider.scss',
+13 -37
View File
@@ -1,8 +1,9 @@
/** @odoo-module **/
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { renderToMarkup } from "@web/core/utils/render";
import { _t } from "@web/core/l10n/translation";
import publicWidget from "@web/legacy/js/public/public_widget";
import Dialog from "@web/legacy/js/core/dialog";
import paymentFormMixin from "@payment/js/payment_form_mixin";
import { debounce } from "@web/core/utils/timing";
@@ -68,49 +69,24 @@ import { debounce } from "@web/core/utils/timing";
},
/**
* Build the confirmation dialog based on the linked records' information.
* Build and open the confirmation dialog based on the linked records' information.
*
* @private
* @param {Array} linkedRecordsInfo - The list of information about linked records.
* @param confirmCallback - The callback method called when the user clicks on the
* confirmation button.
* @return {object}
*/
_buildConfirmationDialog: function (linkedRecordsInfo, confirmCallback) {
const $dialogContentMessage = $(
'<span>', {text: _t("Are you sure you want to delete this payment method?")}
_openConfirmationDialog: function (linkedRecordsInfo, confirmCallback) {
const body = renderToMarkup(
"payment.ManageFormConfirmationDialog",
{ linkedRecordsInfo }
);
if (linkedRecordsInfo.length > 0) { // List the documents linked to the token.
$dialogContentMessage.append($('<br>'));
$dialogContentMessage.append($(
'<span>', {text: _t("It is currently linked to the following documents:")}
));
const $documentInfoList = $('<ul>');
linkedRecordsInfo.forEach(documentInfo => {
$documentInfoList.append($('<li>').append($(
'<a>', {
href: documentInfo.url,
target: '_blank',
title: documentInfo.description,
text: documentInfo.name
}
)));
});
$dialogContentMessage.append($documentInfoList);
}
return new Dialog(this, {
this.call("dialog", "add", ConfirmationDialog, {
title: _t("Warning!"),
size: 'medium',
$content: $('<div>').append($dialogContentMessage),
buttons: [
{
text: _t("Confirm Deletion"), classes: 'btn-primary', close: true,
click: confirmCallback,
},
{
text: _t("Cancel"), close: true
},
],
body,
confirmLabel: _t("Confirm Deletion"),
confirm: confirmCallback,
cancel: () => {},
});
},
@@ -154,7 +130,7 @@ import { debounce } from "@web/core/utils/timing";
method: 'get_linked_records_info',
args: [tokenId],
}).then(linkedRecordsInfo => {
this._buildConfirmationDialog(linkedRecordsInfo, execute).open();
this._openConfirmationDialog(linkedRecordsInfo, execute);
}).guardedCatch(error => {
this._displayError(
_t("Server Error"),
@@ -1,8 +1,7 @@
/** @odoo-module **/
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { escape } from "@web/core/utils/strings";
import core from "@web/legacy/js/services/core";
import Dialog from "@web/legacy/js/core/dialog";
import { _t } from "@web/core/l10n/translation";
export default {
@@ -70,18 +69,14 @@
* @param {string} title - The title of the error
* @param {string} description - The description of the error
* @param {string} error - The raw error message
* @return {(Dialog|undefined)} A dialog showing the error if no payment option is selected,
* undefined otherwise.
*/
_displayError: function (title, description = '', error = '') {
const $checkedRadios = this.$('input[name="o_payment_radio"]:checked');
if ($checkedRadios.length !== 1) { // Cannot find selected payment option, show dialog
return new Dialog(null, {
this.call("dialog", "add", ConfirmationDialog, {
title: _t("Error: %s", title),
size: 'medium',
$content: `<p>${escape(description) || ''}</p>`,
buttons: [{text: _t("Ok"), close: true}]
}).open();
body: description || '',
});
} else { // Show error in inline form
this._hideError(); // Remove any previous error
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="payment.ManageFormConfirmationDialog" owl="1">
<div>
<span>Are you sure you want to delete this payment method?</span>
<t t-if="linkedRecordsInfo.length > 0">
<br/>
<span>It is currently linked to the following documents:</span>
<ul>
<t t-foreach="linkedRecordsInfo" t-as="documentInfo" t-key="documentInfo_index">
<li t-att-title="documentInfo.description" t-att-href="documentInfo.url" t-esc="documentInfo.name"/>
</t>
</ul>
</t>
</div>
</t>
</templates>
+1
View File
@@ -47,6 +47,7 @@ a dependency towards website editing and customization capabilities.""",
'portal/static/src/xml/portal_signature.xml',
'portal/static/src/js/portal_sidebar.js',
'portal/static/src/xml/portal_security.xml',
'portal/static/src/js/components/**/*',
],
'web.assets_tests': [
'portal/static/tests/**/*',
@@ -0,0 +1,47 @@
/** @odoo-module */
import { useEffect } from "@odoo/owl";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
export class InputConfirmationDialog extends ConfirmationDialog {
static props = {
...ConfirmationDialog.props,
onInput: Function,
};
static template = "portal.InputConfirmationDialog";
setup() {
super.setup();
const onInput = () => {
if (this.props.onInput) {
this.props.onInput({ inputEl: this.inputEl });
}
};
const onKeydown = (ev) => {
if (ev.key && ev.key.toLowerCase() === "enter") {
ev.preventDefault();
this._confirm();
}
};
useEffect(
(inputEl) => {
this.inputEl = inputEl;
if (this.inputEl) {
this.inputEl.focus();
this.inputEl.addEventListener("keydown", onKeydown);
this.inputEl.addEventListener("input", onInput);
return () => {
this.inputEl.removeEventListener("keydown", onKeydown);
this.inputEl.removeEventListener("input", onInput);
};
}
},
() => [this.modalRef.el?.querySelector("input")]
);
}
_confirm() {
this.execButton(() => this.props.confirm({ inputEl: this.inputEl }));
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="portal.InputConfirmationDialog" t-inherit="web.ConfirmationDialog" owl="1">
<xpath expr="//p[hasclass('text-prewrap')]" position="attributes">
<attribute name="class"></attribute>
</xpath>
</t>
</templates>
+105 -145
View File
@@ -1,10 +1,11 @@
/** @odoo-module **/
import { ConfirmationDialog } from '@web/core/confirmation_dialog/confirmation_dialog';
import { renderToMarkup } from "@web/core/utils/render";
import publicWidget from '@web/legacy/js/public/public_widget';
import Dialog from "@web/legacy/js/core/dialog";
import { _t } from "@web/legacy/js/services/core";
import { renderToString } from "@web/core/utils/render";
import session from "web.session";
import { InputConfirmationDialog } from './components/input_confirmation_dialog/input_confirmation_dialog';
publicWidget.registry.NewAPIKeyButton = publicWidget.Widget.extend({
selector: '.o_portal_new_api_key',
@@ -23,46 +24,40 @@ publicWidget.registry.NewAPIKeyButton = publicWidget.Widget.extend({
model: 'res.users',
method: 'api_key_wizard',
args: [session.user_id],
}));
const self = this;
const d_description = new Dialog(self, {
title: _t('New API Key'),
$content: renderToString('portal.keydescription'),
buttons: [{text: _t('Confirm'), classes: 'btn-primary', close: true, click: async () => {
var description = d_description.el.querySelector('[name="description"]').value;
var wizard_id = await this._rpc({
model: 'res.users.apikeys.description',
method: 'create',
args: [{name: description}],
}), (...args) => this.call("dialog", "add", ...args));
this.call("dialog", "add", InputConfirmationDialog, {
title: _t("New API Key"),
body: renderToMarkup("portal.keydescription"),
confirmLabel: _t("Confirm"),
confirm: async ({ inputEl }) => {
const description = inputEl.value;
const wizard_id = await this._rpc({
model: "res.users.apikeys.description",
method: "create",
args: [{ name: description }],
});
var res = await handleCheckIdentity(
const res = await handleCheckIdentity(
this.proxy('_rpc'),
this._rpc({
model: 'res.users.apikeys.description',
method: 'make_key',
args: [wizard_id],
})
}),
(...args) => this.call("dialog", "add", ...args)
);
const d_show = new Dialog(self, {
title: _t('API Key Ready'),
$content: renderToString('portal.keyshow', {key: res.context.default_key}),
buttons: [{text: _t('Close'), clases: 'btn-primary', close: true}],
});
d_show.on('closed', this, () => {
window.location = window.location;
});
d_show.open();
}}, {text: _t('Discard'), close: true}],
this.call("dialog", "add", ConfirmationDialog, {
title: _t("API Key Ready"),
body: renderToMarkup("portal.keyshow", { key: res.context.default_key }),
confirmLabel: _t("Close"),
}, {
onClose: () => {
window.location = window.location;
},
})
}
});
d_description.opened(() => {
const input = d_description.el.querySelector('[name="description"]');
input.focus();
d_description.el.addEventListener('submit', (e) => {
e.preventDefault();
d_description.$footer.find('.btn-primary').click();
});
});
d_description.open();
}
});
@@ -80,7 +75,8 @@ publicWidget.registry.RemoveAPIKeyButton = publicWidget.Widget.extend({
model: 'res.users.apikeys',
method: 'remove',
args: [parseInt(this.el.id)]
})
}),
(...args) => this.call("dialog", "add", ...args)
);
window.location = window.location;
}
@@ -121,72 +117,49 @@ publicWidget.registry.RevokeSessionsButton = publicWidget.Widget.extend({
},
async _onClick() {
const rpc = this.proxy('_rpc');
const wrapped = this._rpc({
const { res_id: checkId } = await this._rpc({
model: 'res.users',
method: 'api_key_wizard',
args: [session.user_id],
});
await handleRevokeSessions(rpc, wrapped);
window.location.href = "/web/session/logout?redirect=/";
this.call("dialog", "add", InputConfirmationDialog, {
title: _t("Log out from all devices?"),
body: renderToMarkup("portal.revoke_all_devices_popup_template"),
confirmLabel: _t("Log out from all devices"),
confirm: async ({ inputEl }) => {
if (!inputEl.reportValidity()) {
inputEl.classList.add("is-invalid");
return false;
}
function handleRevokeSessions(rpc, wrapped) {
return wrapped.then((inst) => {
const check_id = inst.res_id
var $content = $(renderToString("portal.revoke_all_devices_popup_template"));
return new Promise((resolve) => {
var dialog = new Dialog(this, {
title: _t("Log out from all devices?"),
$content,
buttons: [{
text: _t("Log out from all devices"), classes: 'btn btn-primary',
// nb: if click & close, waits for click to resolve before closing
async click() {
const password_input = this.el.querySelector('[name=password]');
if (!password_input.reportValidity()) {
password_input.classList.add('is-invalid');
return;
};
await rpc({
model: 'res.users.identitycheck',
method: 'write',
args: [check_id, {password: password_input.value}]
});
await rpc({
model: 'res.users.identitycheck',
method: 'revoke_all_devices',
args: [check_id]
}).then((inst) => {
this.close();
resolve(inst);
}, (err) => {
err.event.preventDefault(); // suppress crashmanager
password_input.classList.add('is-invalid');
password_input.setCustomValidity(_t("Check failed"));
password_input.reportValidity();
});
}
}, {
text: _t('Cancel'), close: true
}]
await this._rpc({
model: "res.users.identitycheck",
method: "write",
args: [checkId, { password: inputEl.value }],
});
try {
await this._rpc({
model: "res.users.identitycheck",
method: "revoke_all_devices",
args: [checkId],
});
dialog.opened(() => {
const password_input = dialog.el.querySelector('[name="password"]');
password_input.focus();
password_input.addEventListener('input', () => {
password_input.classList.remove('is-invalid');
password_input.setCustomValidity('');
});
dialog.el.addEventListener('submit', (e) => {
e.preventDefault();
dialog.$footer.find('.btn-primary').click();
});
});
dialog.open();
})
})
}
}
} catch {
inputEl.classList.add("is-invalid");
inputEl.setCustomValidity(_t("Check failed"));
inputEl.reportValidity();
return false;
}
window.location.href = "/web/session/logout?redirect=/";
return true;
},
cancel: () => {},
onInput: ({ inputEl }) => {
inputEl.classList.remove("is-invalid");
inputEl.setCustomValidity("");
},
});
},
});
/**
@@ -200,65 +173,52 @@ publicWidget.registry.RevokeSessionsButton = publicWidget.Widget.extend({
*
* @param {Function} rpc Widget#_rpc bound do the widget
* @param {Promise} wrapped promise to check for an identity check request
* @param {Function} addDialog add a dialog to the dialog service
* @returns {Promise} result of the original call
*/
export function handleCheckIdentity(rpc, wrapped) {
export async function handleCheckIdentity(rpc, wrapped, addDialog) {
return wrapped.then((r) => {
if (!(r.type === "ir.actions.act_window" && r.res_model === "res.users.identitycheck")) {
return r;
}
const check_id = r.res_id;
return new Promise((resolve, reject) => {
const d = new Dialog(null, {
const checkId = r.res_id;
return new Promise((resolve) => {
addDialog(InputConfirmationDialog, {
title: _t("Security Control"),
$content: renderToString('portal.identitycheck'),
buttons: [{
text: _t("Confirm Password"), classes: 'btn btn-primary',
// nb: if click & close, waits for click to resolve before closing
click() {
const password_input = this.el.querySelector('[name=password]');
if (!password_input.reportValidity()) {
password_input.classList.add('is-invalid');
return;
}
return rpc({
model: 'res.users.identitycheck',
method: 'write',
args: [check_id, {password: password_input.value}]
}).then(() => rpc({
model: 'res.users.identitycheck',
method: 'run_check',
args: [check_id]
})).then((r) => {
this.close();
resolve(r);
}, (err) => {
err.event.preventDefault(); // suppress crashmanager
password_input.classList.add('is-invalid');
password_input.setCustomValidity(_t("Check failed"));
password_input.reportValidity();
});
body: renderToMarkup("portal.identitycheck"),
confirmLabel: _t("Confirm Password"),
confirm: async ({ inputEl }) => {
if (!inputEl.reportValidity()) {
inputEl.classList.add("is-invalid");
return false;
}
}, {
text: _t('Cancel'), close: true
}]
}).on('close', null, () => {
// unlink wizard object?
reject();
let result;
await rpc({
model: "res.users.identitycheck",
method: "write",
args: [checkId, { password: inputEl.value }],
});
try {
result = await rpc({
model: "res.users.identitycheck",
method: "run_check",
args: [checkId],
});
} catch {
inputEl.classList.add("is-invalid");
inputEl.setCustomValidity(_t("Check failed"));
inputEl.reportValidity();
return false;
}
resolve(result);
return true;
},
cancel: () => {},
onInput: ({ inputEl }) => {
inputEl.classList.remove("is-invalid");
inputEl.setCustomValidity("");
},
});
d.opened(() => {
const pw = d.el.querySelector('[name="password"]');
pw.focus();
pw.addEventListener('input', () => {
pw.classList.remove('is-invalid');
pw.setCustomValidity('');
});
d.el.addEventListener('submit', (e) => {
e.preventDefault();
d.$footer.find('.btn-primary').click();
});
});
d.open();
});
});
}
@@ -1,5 +1,5 @@
<templates xml:space="preserve">
<t t-name="portal.identitycheck">
<t t-name="portal.identitycheck" owl="1">
<form string="Security Control">
<h3><strong>Please enter your password to confirm you own this account</strong></h3>
<br/>
@@ -10,7 +10,7 @@
<a href="/web/reset_password/" class="btn btn-link" role="button">Forgot password?</a>
</form>
</t>
<t t-name="portal.keydescription">
<t t-name="portal.keydescription" owl="1">
<form string="Key Description">
<h3><strong>Name your key</strong></h3>
<p>Enter a description of and purpose for the key.</p>
@@ -23,7 +23,7 @@
</p>
</form>
</t>
<t t-name="portal.keyshow">
<t t-name="portal.keyshow" owl="1">
<div>
<h3><strong>Write down your key</strong></h3>
<p>
@@ -39,23 +39,19 @@
</div>
</t>
<!-- Popup's view !-->
<t t-name="portal.revoke_all_devices_popup_template">
<t t-name="portal.revoke_all_devices_popup_template" owl="1">
<section>
<div>
You are about to log out from all devices that currently have access to this user's account.
</div><br/>
<t t-set="path">password</t>
<form action="/my/security" method="post" class="oe_reset_password_form" >
<input type="hidden" name="op" value="revoke"/>
<div class="mb-3">
<label for="current">Type in your password to confirm :</label>
<input type="password" t-attf-class="form-control form-control-sm"
id="current" name="password"
autocomplete="current-password" required="required"/>
<div class="invalid-feedback">
<t t-esc="error"/>
</div>
</div>
<input type="hidden" name="op" value="revoke"/>
</form>
</section>
</t>
+12 -4
View File
@@ -1,11 +1,11 @@
/** @odoo-module **/
import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import publicWidget from "@web/legacy/js/public/public_widget";
import { formatDateTime, parseDate, parseDateTime} from "@web/legacy/js/core/dates";
import time from "@web/legacy/js/core/time";
import config from "@web/legacy/js/services/config";
import { _t } from "@web/core/l10n/translation";
import Dialog from "@web/legacy/js/core/dialog";
import dom from "@web/legacy/js/core/dom";
import {getCookie, setCookie, deleteCookie} from "@web/legacy/js/core/cookie_utils";
@@ -1055,14 +1055,22 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa
locale: moment.locale(),
allowInputToggle: true,
});
$dateGroup.on('error.datetimepicker', function (err) {
$dateGroup.on('error.datetimepicker', (err) => {
if (err.date) {
if (err.date < minDate) {
Dialog.alert(this, _t('The date you selected is lower than the minimum date: ') + minDate.format(datetimepickerFormat));
this.call("dialog", "add", AlertDialog, {
body:
_t("The date you selected is lower than the minimum date: ") +
minDate.format(datetimepickerFormat),
});
}
if (err.date > maxDate) {
Dialog.alert(this, _t('The date you selected is greater than the maximum date: ') + maxDate.format(datetimepickerFormat));
this.call("dialog", "add", AlertDialog, {
body:
_t("The date you selected is greater than the maximum date: ") +
maxDate.format(datetimepickerFormat),
});
}
}
return false;
@@ -10,48 +10,47 @@ export class ConfirmationDialog extends Component {
setup() {
this.env.dialogData.close = () => this._cancel();
this.modalRef = useChildRef();
this.isConfirmedOrCancelled = false; // ensures we do not confirm and/or cancel twice
this.isProcess = false;
}
async _cancel() {
if (this.isConfirmedOrCancelled) {
return;
}
this.isConfirmedOrCancelled = true;
this.disableButtons();
if (this.props.cancel) {
try {
await this.props.cancel();
} catch (e) {
this.props.close();
throw e;
}
}
this.props.close();
this.execButton(this.props.cancel);
}
async _confirm() {
if (this.isConfirmedOrCancelled) {
return;
}
this.isConfirmedOrCancelled = true;
this.disableButtons();
if (this.props.confirm) {
try {
await this.props.confirm();
} catch (e) {
this.props.close();
throw e;
}
}
this.props.close();
this.execButton(this.props.confirm);
}
disableButtons() {
setButtonsDisabled(disabled) {
this.isProcess = disabled;
if (!this.modalRef.el) {
return; // safety belt for stable versions
}
for (const button of [...this.modalRef.el.querySelectorAll(".modal-footer button")]) {
button.disabled = true;
button.disabled = disabled;
}
}
async execButton(callback) {
if (this.isProcess) {
return;
}
this.setButtonsDisabled(true);
if (callback) {
let shouldClose;
try {
shouldClose = await callback();
} catch (e) {
this.props.close();
throw e;
}
if (shouldClose === false) {
this.setButtonsDisabled(false);
return;
}
}
this.props.close();
}
}
ConfirmationDialog.template = "web.ConfirmationDialog";
ConfirmationDialog.components = { Dialog };
@@ -21,4 +21,4 @@
</Dialog>
</t>
</templates>
</templates>
+47 -2
View File
@@ -293,10 +293,55 @@ export function formatDateTime(value, options = {}) {
return value.setZone("default").toFormat(format);
}
/**
* Converts a given duration in seconds into a human-readable format.
*
* The function takes a duration in seconds and converts it into a human-readable form,
* such as "1h" or "1 hour, 30 minutes", depending on the value of the `showFullDuration` parameter.
* If the `showFullDuration` is set to true, the function will display up to two non-zero duration
* components in long form (e.g: hours, minutes).
* Otherwise, it will show just the largest non-zero duration component in narrow form (e.g: y or h).
* Luxon takes care of translations given the current locale.
*
* @param {number} seconds - The duration in seconds to be converted.
* @param {boolean} showFullDuration - If true, the output will have two components in long form.
* Otherwise, just one component will be displayed in narrow form.
*
* @returns {string} A human-readable string representation of the duration.
*
* @example
* // Sample usage
* const durationInSeconds = 7320; // 2 hours and 2 minutes (2 * 3600 + 2 * 60)
* const fullDuration = humanizeDuration(durationInSeconds, true);
* console.log(fullDuration); // Output: "2 hours, 2 minutes"
*
* const shortDuration = humanizeDuration(durationInSeconds, false);
* console.log(shortDuration); // Output: "2h"
*/
export function formatDuration(seconds, showFullDuration) {
const displayStyle = showFullDuration ? "long" : "narrow";
const numberOfValuesToDisplay = showFullDuration ? 2 : 1;
const durationKeys = ["years", "months", "days", "hours", "minutes"];
if (seconds < 60) {
seconds = 60;
}
seconds -= seconds % 60;
let duration = luxon.Duration.fromObject({ seconds: seconds }).shiftTo(...durationKeys);
duration = duration.shiftTo(...durationKeys.filter((key) => duration.get(key)));
const durationSplit = duration.toHuman({ unitDisplay: displayStyle }).split(",");
if (!showFullDuration && duration.loc.locale.includes("en") && duration.months > 0) {
durationSplit[0] = durationSplit[0].replace("m", "M");
}
return durationSplit.slice(0, numberOfValuesToDisplay).join(",");
}
/**
* Formats the given DateTime to the server date format.
* @param {DateTime} value
* @returns {string}
* @returns {string}
*/
export function serializeDate(value) {
if (!dateCache.has(value)) {
@@ -308,7 +353,7 @@ export function serializeDate(value) {
/**
* Formats the given DateTime to the server datetime format.
* @param {DateTime} value
* @returns {string}
* @returns {string}
*/
export function serializeDateTime(value) {
if (!dateTimeCache.has(value)) {
@@ -23,13 +23,13 @@ import { setLoadXmlDefaultApp, loadJS, templates } from '@web/core/assets';
import { MainComponentsContainer } from "@web/core/main_components_container";
import { browser } from '@web/core/browser/browser';
import { jsonrpc } from '@web/core/network/rpc_service';
import { renderToString } from "@web/core/utils/render";
import { _t } from "@web/core/l10n/translation";
import { omit } from "@web/core/utils/objects";
import { Component, App, whenReady } from "@odoo/owl";
const serviceRegistry = registry.category("services");
import { Component, App, whenReady } from "@odoo/owl";
import { omit } from '@web/core/utils/objects';
import { renderToString } from '@web/core/utils/render';
// Load localizations outside the PublicRoot to not wait for DOM ready (but
// wait for them in PublicRoot)
@@ -77,51 +77,6 @@ export function formatChar(value, options) {
return value;
}
/**
* Converts a given duration in seconds into a human-readable format.
*
* The function takes a duration in seconds and converts it into a human-readable form,
* such as "1h" or "1 hour, 30 minutes", depending on the value of the `showFullDuration` parameter.
* If the `showFullDuration` is set to true, the function will display up to two non-zero duration
* components in long form (e.g: hours, minutes).
* Otherwise, it will show just the largest non-zero duration component in narrow form (e.g: y or h).
* Luxon takes care of translations given the current locale.
*
* @param {number} seconds - The duration in seconds to be converted.
* @param {boolean} showFullDuration - If true, the output will have two components in long form.
* Otherwise, just one component will be displayed in narrow form.
*
* @returns {string} A human-readable string representation of the duration.
*
* @example
* // Sample usage
* const durationInSeconds = 7320; // 2 hours and 2 minutes (2 * 3600 + 2 * 60)
* const fullDuration = humanizeDuration(durationInSeconds, true);
* console.log(fullDuration); // Output: "2 hours, 2 minutes"
*
* const shortDuration = humanizeDuration(durationInSeconds, false);
* console.log(shortDuration); // Output: "2h"
*/
export function formatDuration(seconds, showFullDuration) {
const displayStyle = showFullDuration ? "long" : "narrow";
const numberOfValuesToDisplay = showFullDuration ? 2 : 1;
const durationKeys = ["years", "months", "days", "hours", "minutes"];
if (seconds < 60) {
seconds = 60;
}
seconds -= seconds % 60;
let duration = luxon.Duration.fromObject({ seconds: seconds }).shiftTo(...durationKeys);
duration = duration.shiftTo(...durationKeys.filter((key) => duration.get(key)));
const durationSplit = duration.toHuman({ unitDisplay: displayStyle }).split(",");
if (!showFullDuration && duration.loc.locale.includes("en") && duration.months > 0) {
durationSplit[0] = durationSplit[0].replace("m", "M");
}
return durationSplit.slice(0, numberOfValuesToDisplay).join(",");
}
/**
* Returns a string representing a float. The result takes into account the
* user settings (to display the correct decimal separator).
+18 -13
View File
@@ -4,12 +4,13 @@ import config from "@web/legacy/js/services/config";
import concurrency from "@web/legacy/js/core/concurrency";
import { _t } from "@web/core/l10n/translation";
import dom from "@web/legacy/js/core/dom";
import Dialog from "@web/legacy/js/core/dialog";
import Widget from "@web/legacy/js/core/widget";
import localStorage from "@web/legacy/js/core/local_storage";
import { debounce } from "@web/core/utils/timing";
import { sortBy } from "@web/core/utils/arrays";
import { pick } from "@web/core/utils/objects";
import { AlertDialog, ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { markup } from "@odoo/owl";
/**
* Formats a content-check result (@see checkXML, checkSCSS).
@@ -683,20 +684,23 @@ var ViewEditor = Widget.extend({
}).bind(this)
);
var self = this;
return Promise.all(defs).guardedCatch(function (results) {
// some overrides handle errors themselves
if (results === undefined) {
return;
}
var error = results[1];
Dialog.alert(self, '', {
const content = _t(
"A server error occured. Please check you correctly signed in and that the file you are saving is correctly formatted."
);
const body = markup(`
<div>${content}</div>
<br/>
${error}
`);
this.call("dialog", "add", AlertDialog, {
title: _t("Server error"),
$content: $('<div/>').html(
_t("A server error occured. Please check you correctly signed in and that the file you are saving is correctly formatted.")
+ '<br/>'
+ error
)
body,
});
});
},
@@ -1004,12 +1008,13 @@ var ViewEditor = Widget.extend({
* @private
*/
_onResetClick: function () {
var self = this;
Dialog.confirm(this, _t("If you reset this file, all your customizations will be lost as it will be reverted to the default file."), {
title: _t("Careful!"),
confirm_callback: function () {
self._resetResource(self._getSelectedResource());
this.call("dialog", "add", ConfirmationDialog, {
title: _t("Careful"),
body: _t("If you reset this file, all your customizations will be lost as it will be reverted to the default file."),
confirm: () => {
this._resetResource(this._getSelectedResource());
},
cancel: () => {},
});
},
/**
@@ -38,6 +38,7 @@
'website_event_exhibitor/static/src/js/event_exhibitor_connect.js',
'website_event_exhibitor/static/src/js/event_sponsor_search.js',
'website_event_exhibitor/static/src/xml/event_exhibitor_connect.xml',
'website_event_exhibitor/static/src/components/exhibitor_connect_closed_dialog/**/*',
],
'web.report_assets_common': [
'/website_event_exhibitor/static/src/scss/event_full_page_ticket_report.scss',
@@ -0,0 +1,32 @@
/** @odoo-module */
import { Component, onWillStart, markup } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { useService } from "@web/core/utils/hooks";
import { formatDuration } from "@web/core/l10n/dates";
export class ExhibitorConnectClosedDialog extends Component {
static template = "website_event_exhibitor.ExhibitorConnectClosedDialog";
static components = { Dialog };
static props = {
sponsorId: Number,
};
setup() {
this.rpc = useService("rpc");
onWillStart(() => this.fetchSponsor());
}
/**
* @private
*/
async fetchSponsor() {
const sponsorData = await this.rpc(
`/event_sponsor/${encodeURIComponent(this.props.sponsorId)}/read`
);
sponsorData.website_description = markup(sponsorData.website_description);
this.formatEventStartRemaining = formatDuration(sponsorData.event_start_remaining, true);
this.sponsorData = sponsorData;
}
}
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="website_event_exhibitor.ExhibitorConnectClosedDialog" owl="1">
<Dialog size="'md'" header="false" footer="false" >
<div class="o_wesponsor_js_connect_modal_main container">
<div class="row mt-2">
<t t-if="! sponsorData.event_is_ongoing">
<div class="col-12 alert alert-warning text-center" role="alert"
t-if="sponsorData.event_is_done">
Event <span t-out="sponsorData.event_name" class="fw-bold"/> is over.
<br/>
<span>Join us next time to meet <b t-out="sponsorData.name"/>!</span>
</div>
<div class="col-12 alert alert-warning text-center" role="alert"
t-else="">
<span t-out="sponsorData.name" class="fw-bold"/> is not available right now.<br />
Event <span t-out="sponsorData.event_name" class="fw-bold"/>
<span t-if="sponsorData.event_start_today">
starts in
<span t-if="sponsorData.event_start_remaining &gt;= 1" t-out="formatEventStartRemaining"/>
<t t-else="">
a few seconds
</t>.
</span>
<span class="my-0" t-else="">
starts on <span t-out="sponsorData.event_date_begin_located"/>
</span>
</div>
</t>
<div class="col-12 alert alert-warning text-center" role="alert"
t-else="">
<span t-out="sponsorData.name" class="fw-bold"/> is not available right now.<br />
Come back between
<strong>
<t t-out="sponsorData.hour_from_str"/>
-
<t t-out="sponsorData.hour_to_str"/>
</strong> (<span t-out="sponsorData.event_date_tz"/>)
to meet them!
</div>
<div class="col-2">
<img class="img" style="max-width: 100%;"
t-att-src="sponsorData.website_image_url"
t-att-alt="sponsorData.name"/>
</div>
<div class="col-10">
<div class="d-flex align-items-top mb-3">
<div class="d-flex flex-column">
<div class="d-flex align-items-center">
<h4 t-out="sponsorData.name" class="mb4"/>
<span class="badge text-bg-primary ms-2"
t-out="sponsorData.sponsor_type_name"/>
</div>
<span class="text-muted" t-if="sponsorData.subtitle" t-out="sponsorData.subtitle"/>
<span t-if="sponsorData.url">
<i class="fa fa-home me-2"/><a t-att-href="sponsorData.url"><span t-out="sponsorData.url"/></a>
</span>
<span t-if="sponsorData.email">
<i class="fa fa-envelope me-2"/><a t-att-mailto="sponsorData.email"><span t-out="sponsorData.email"/></a>
</span>
<span t-if="sponsorData.phone">
<i class="fa fa-phone me-2"/><span t-out="sponsorData.phone"/>
</span>
</div>
<img t-if="sponsorData.country_flag_url"
class="img ms-auto"
style="max-height: 36px;"
t-att-src="sponsorData.country_flag_url"
t-att-alt="sponsorData.country_name"/>
</div>
</div>
<div class="col-12" t-if="sponsorData.website_description" t-out="sponsorData.website_description"/>
</div>
</div>
</Dialog>
</t>
</templates>
@@ -1,60 +1,8 @@
/** @odoo-module **/
import { debounce } from "@web/core/utils/timing";
import Dialog from "@web/legacy/js/core/dialog";
import publicWidget from "@web/legacy/js/public/public_widget";
import { Markup } from "@web/legacy/js/core/utils";
var ExhibitorConnectClosedDialog = Dialog.extend({
events: Object.assign({}, Dialog.prototype.events, {
'click .o_wesponsor_js_connect_modal_contry': '_onClickCountryFlag',
}),
template: 'exhibitor.connect.closed.modal',
/**
* @override
* @param {Object} parent;
* @param {Object} options holding a sponsorData obj with required values to
* display (see .xml for details);
*/
init: function (parent, options) {
options = Object.assign({
size: 'medium',
renderHeader: false,
renderFooter: false,
backdrop: true,
}, options || {});
this.sponsorId = options.sponsorId;
this._super(parent, options);
},
/**
* @override
* Wait for fetching sponsor data;
*/
willStart: function () {
return Promise.all([
this._super(...arguments),
this._fetchSponsor()
]);
},
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
/**
* @private
*/
async _fetchSponsor() {
const sponsorData = await this._rpc({
route: `/event_sponsor/${encodeURIComponent(this.sponsorId)}/read`
});
sponsorData.website_description = Markup(sponsorData.website_description);
this.sponsorData = sponsorData;
},
});
import { ExhibitorConnectClosedDialog } from "../components/exhibitor_connect_closed_dialog/exhibitor_connect_closed_dialog";
publicWidget.registry.eventExhibitorConnect = publicWidget.Widget.extend({
selector: '.o_wesponsor_connect_button',
@@ -113,17 +61,12 @@ publicWidget.registry.eventExhibitorConnect = publicWidget.Widget.extend({
_openClosedDialog: function ($element) {
const sponsorId = this.$el.data('sponsorId');
return new ExhibitorConnectClosedDialog(
this, {
sponsorId: sponsorId,
}
).open();
this.call("dialog", "add", ExhibitorConnectClosedDialog, { sponsorId });
},
});
export default {
ExhibitorConnectClosedDialog: ExhibitorConnectClosedDialog,
eventExhibitorConnect: publicWidget.registry.eventExhibitorConnect,
};
@@ -1,79 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="exhibitor.connect.closed.modal">
<div class="o_wesponsor_js_connect_modal_main container">
<div class="row mt-2">
<t t-if="! widget.sponsorData.event_is_ongoing">
<div class="col-12 alert alert-warning text-center" role="alert"
t-if="widget.sponsorData.event_is_done">
Event <span t-out="widget.sponsorData.event_name" class="fw-bold"/> is over.
<br/>
<span>Join us next time to meet <b t-out="widget.sponsorData.name"/>!</span>
</div>
<div class="col-12 alert alert-warning text-center" role="alert"
t-else="">
<span t-out="widget.sponsorData.name" class="fw-bold"/> is not available right now.<br />
Event <span t-out="widget.sponsorData.event_name" class="fw-bold"/>
<span t-if="widget.sponsorData.event_start_today">
starts in
<span t-if="widget.sponsorData.event_start_remaining &gt;= 1" t-out="widget.sponsorData.event_start_remaining"
t-options="{'widget': 'duration', 'digital': False, 'unit': 'minute', 'round': 'minute'}"/>
<t t-else="">
a few seconds
</t>.
</span>
<span class="my-0" t-else="">
starts on <span t-out="widget.sponsorData.event_date_begin_located"/>
</span>
</div>
</t>
<div class="col-12 alert alert-warning text-center" role="alert"
t-else="">
<span t-out="widget.sponsorData.name" class="fw-bold"/> is not available right now.<br />
Come back between
<strong>
<t t-out="widget.sponsorData.hour_from_str"/>
-
<t t-out="widget.sponsorData.hour_to_str"/>
</strong> (<span t-out="widget.sponsorData.event_date_tz"/>)
to meet them!
</div>
<div class="col-2">
<img class="img" style="max-width: 100%;"
t-att-src="widget.sponsorData.website_image_url"
t-att-alt="widget.sponsorData.name"/>
</div>
<div class="col-10">
<div class="d-flex align-items-top mb-3">
<div class="d-flex flex-column">
<div class="d-flex align-items-center">
<h4 t-out="widget.sponsorData.name" class="mb4"/>
<span class="badge text-bg-primary ms-2"
t-out="widget.sponsorData.sponsor_type_name"/>
</div>
<span class="text-muted" t-if="widget.sponsorData.subtitle" t-out="widget.sponsorData.subtitle"/>
<span t-if="widget.sponsorData.url">
<i class="fa fa-home me-2"/><a t-att-href="widget.sponsorData.url"><span t-out="widget.sponsorData.url"/></a>
</span>
<span t-if="widget.sponsorData.email">
<i class="fa fa-envelope me-2"/><a t-att-mailto="widget.sponsorData.email"><span t-out="widget.sponsorData.email"/></a>
</span>
<span t-if="widget.sponsorData.phone">
<i class="fa fa-phone me-2"/><span t-out="widget.sponsorData.phone"/>
</span>
</div>
<img t-if="widget.sponsorData.country_flag_url"
class="img ms-auto"
style="max-height: 36px;"
t-att-src="widget.sponsorData.country_flag_url"
t-att-alt="widget.sponsorData.country_name"/>
</div>
</div>
<div class="col-12" t-if="widget.sponsorData.website_description" t-out="widget.sponsorData.website_description"/>
</div>
</div>
</t>
</templates>
@@ -1,8 +1,8 @@
/** @odoo-module **/
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import publicWidget from "@web/legacy/js/public/public_widget";
import { _t } from "@web/core/l10n/translation";
import Dialog from "@web/legacy/js/core/dialog";
publicWidget.registry.websiteEventMeetingRoom = publicWidget.Widget.extend({
selector: '.o_wevent_meeting_room_card',
@@ -31,23 +31,20 @@ publicWidget.registry.websiteEventMeetingRoom = publicWidget.Widget.extend({
event.preventDefault();
event.stopPropagation();
Dialog.confirm(
this,
_t("Are you sure you want to close this room?"),
{
confirm_callback: async () => {
await this._rpc({
model: 'event.meeting.room',
method: 'write',
args: [this.meetingRoomId, {is_published: false}],
context: this.context,
});
this.call("dialog", "add", ConfirmationDialog, {
body: _t("Are you sure you want to close this room?"),
confirm: async () => {
await this._rpc({
model: "event.meeting.room",
method: "write",
args: [this.meetingRoomId, { is_published: false }],
context: this.context,
});
// remove the element so we do not need to refresh the page
this.$el.remove();
}
// remove the element so we do not need to refresh the page
this.$el.remove();
},
);
});
},
/**
@@ -58,22 +55,19 @@ publicWidget.registry.websiteEventMeetingRoom = publicWidget.Widget.extend({
_onDuplicateClick: function (event) {
event.preventDefault();
event.stopPropagation();
Dialog.confirm(
this,
_t("Are you sure you want to duplicate this room?"),
{
confirm_callback: async () => {
await this._rpc({
model: 'event.meeting.room',
method: 'copy',
args: [this.meetingRoomId],
context: this.context,
});
this.call("dialog", "add", ConfirmationDialog, {
body: _t("Are you sure you want to duplicate this room?"),
confirm: async () => {
await this._rpc({
model: "event.meeting.room",
method: "copy",
args: [this.meetingRoomId],
context: this.context,
});
window.location.reload();
}
window.location.reload();
},
);
});
},
/**
+1
View File
@@ -75,6 +75,7 @@ Ask questions, get answers, no distractions
'website_forum/static/src/js/website_forum.js',
'website_forum/static/src/js/website_forum.share.js',
'website_forum/static/src/xml/public_templates.xml',
'website_forum/static/src/components/flag_mark_as_offensive/**/*',
],
},
'license': 'LGPL-3',
@@ -0,0 +1,31 @@
/** @odoo-module **/
import { Component, useEffect } from "@odoo/owl";
import { useChildRef } from "@web/core/utils/hooks";
import { Dialog } from "@web/core/dialog/dialog";
export class FlagMarkAsOffensiveDialog extends Component {
static template = "website_forum.FlagMarkAsOffensiveDialog";
static components = { Dialog };
setup() {
this.modalRef = useChildRef();
const onClickDiscard = (ev) => {
ev.preventDefault();
this.props.close();
};
useEffect(
(discardButton) => {
if (discardButton) {
discardButton.addEventListener("click", onClickDiscard);
return () => {
discardButton.removeEventListener("click", onClickDiscard);
};
}
},
() => [this.modalRef.el?.querySelector(".btn-link")]
);
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="website_forum.FlagMarkAsOffensiveDialog" owl="1">
<Dialog size="'md'" title="props.title" footer="false" modalRef="modalRef" >
<t t-out="props.body"/>
</Dialog>
</t>
</templates>
@@ -1,8 +1,9 @@
/** @odoo-module **/
import { markup } from "@odoo/owl";
import { FlagMarkAsOffensiveDialog } from "../components/flag_mark_as_offensive/flag_mark_as_offensive";
import dom from "@web/legacy/js/core/dom";
import {setCookie} from "@web/legacy/js/core/cookie_utils";
import Dialog from "@web/legacy/js/core/dialog";
import { loadWysiwygFromTextarea } from "@web_editor/js/frontend/loadWysiwygFromTextarea";
import publicWidget from "@web/legacy/js/public/public_widget";
import { Markup } from '@web/legacy/js/core/utils';
@@ -572,17 +573,9 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({
const template = await this._rpc({
route: $(ev.currentTarget).data('action'),
});
const dialog = new Dialog(this, {
size: 'medium',
this.call("dialog", "add", FlagMarkAsOffensiveDialog, {
title: _t("Offensive Post"),
$content: template,
renderFooter: false,
}).open();
dialog.opened().then(() => {
dialog.$(".btn-link:contains('Discard')").click((ev) => {
ev.preventDefault();
dialog.close();
});
body: markup(template),
});
},
_displayAccessDeniedNotification(message) {
@@ -1,9 +1,9 @@
/** @odoo-module **/
import Dialog from "@web/legacy/js/core/dialog";
import options from "@web_editor/js/editor/snippets.options";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { _t } from "@web/core/l10n/translation";
import { renderToElement } from "@web/core/utils/render";
import options from "@web_editor/js/editor/snippets.options";
options.registry.mailing_list_subscribe = options.Class.extend({
/**
@@ -14,17 +14,19 @@ options.registry.mailing_list_subscribe = options.Class.extend({
if (this.mailingLists.length) {
this.$target.attr("data-list-id", this.mailingLists[0][0]);
} else {
Dialog.confirm(this, _t("No mailing list found, do you want to create a new one? This will save all your changes, are you sure you want to proceed?"), {
confirm_callback: () => {
this.trigger_up('request_save', {
this.call("dialog", "add", ConfirmationDialog, {
body: _t("No mailing list found, do you want to create a new one? This will save all your changes, are you sure you want to proceed?"),
confirm: () => {
this.trigger_up("request_save", {
reload: false,
onSuccess: () => {
window.location.href = '/web#action=mass_mailing.action_view_mass_mailing_lists';
window.location.href =
"/web#action=mass_mailing.action_view_mass_mailing_lists";
},
});
},
cancel_callback: () => {
this.trigger_up('remove_snippet', {
cancel: () => {
this.trigger_up("remove_snippet", {
$snippet: this.$target,
});
},
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="website_slides.slides.course.unsubscribe.modal.leave" t-inherit-mode="extension">
<xpath expr="//p[last()]" position="after">
<t t-if="widget.enroll === 'payment'">
<t t-inherit="website_slides.SlideUnsubscribeDialog" t-inherit-mode="extension">
<xpath expr="//t[@t-if=&quot;state.mode === 'leave'&quot;] //p[last()]" position="after">
<t t-if="props.enroll === 'payment'">
<p class="alert alert-warning">
<i class="fa fa-exclamation-triangle fa-3x float-start me-3"></i>
This course is paid.<br/>
+1 -1
View File
@@ -109,12 +109,12 @@ Featuring
'website_slides/static/src/xml/website_slides_fullscreen.xml',
'website_slides/static/src/xml/website_slides_share.xml',
'website_slides/static/src/xml/website_slides_channel_tag.xml',
'website_slides/static/src/xml/website_slides_unsubscribe.xml',
'website_slides/static/src/xml/slide_management.xml',
'website_slides/static/src/xml/slide_course_join.xml',
'website_slides/static/src/xml/slide_course_prerequisite.xml',
'website_slides/static/src/xml/slide_quiz_create.xml',
'website_slides/static/src/xml/slide_quiz.xml',
'website_slides/static/src/js/public/**/*',
],
'website.assets_editor': [
'website_slides/static/src/js/systray_items/*.js',
@@ -0,0 +1,29 @@
/** @odoo-module **/
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { useAutofocus } from "@web/core/utils/hooks";
export class CategoryAddDialog extends ConfirmationDialog {
static template = "website_slides.CategoryAddDialog";
static props = {
...ConfirmationDialog.props,
channelId: Number,
};
setup() {
super.setup();
this.inputRef = useAutofocus();
this.csrf_token = odoo.csrf_token;
this.lastInputValue;
}
_confirm() {
this.execButton(() => {
if (this.inputRef.el.value === this.lastInputValue) {
return;
}
this.lastInputValue = this.inputRef.el.value;
return this.props.confirm({ formEl: this.modalRef.el.querySelector("form") });
});
}
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="website_slides.CategoryAddDialog" t-inherit="web.ConfirmationDialog" owl="1">
<xpath expr="//p[hasclass('text-prewrap')]" position="replace">
<div>
<form t-on-submit.prevent="_confirm" action="/slides/category/add" method="POST" id="slide_category_add_form">
<input type="hidden" name="csrf_token" t-att-value="csrf_token"/>
<input type="hidden" name="channel_id" t-att-value="props.channelId"/>
<div class="mb-3 row">
<label for="section_name" class="col-sm-3 col-form-label">Section name</label>
<div class="col-sm-9">
<input t-ref="autofocus" type="text" autocomplete="off" class="form-control" name="name" id="section_name" required="required" placeholder='e.g. "Introduction"'/>
</div>
</div>
</form>
</div>
</xpath>
</t>
</templates>
@@ -0,0 +1,82 @@
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
import { CheckBox } from "@web/core/checkbox/checkbox";
import { Dialog } from "@web/core/dialog/dialog";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
export class SlideUnsubscribeDialog extends Component {
static template = "website_slides.SlideUnsubscribeDialog";
static components = { CheckBox, Dialog };
static props = {
channelId: Number,
isFollower: String,
visibility: String,
enroll: { type: String, optional: true },
};
setup() {
this.rpc = useService("rpc");
this.state = useState({
buttonDisabled: false,
});
this.channelID = parseInt(this.props.channelId, 10);
this.isFollower = this.props.isFollower === "True";
this.updateState("subscription");
this.isChecked = this.isFollower;
}
updateState(mode) {
if (mode === "subscription") {
this.state.title = this.isFollower ? _t("Subscribe") : _t("Notifications");
this.state.mode = "subscription";
} else if (mode === "leave") {
this.state.title = _t("Leave the course");
this.state.mode = "leave";
}
}
onChangeCheckbox(isChecked) {
this.isChecked = isChecked;
}
onClickLeaveCourse() {
this.updateState("leave");
}
onClickLeaveCourseCancel() {
this.updateState("subscription");
}
async onClickLeaveCourseSubmit() {
if (this.state.buttonDisabled) {
return;
}
this.state.buttonDisabled = true;
await this.rpc("/slides/channel/leave", { channel_id: this.channelID });
if (this.props.visibility === "public" || this.props.visibility === "connected") {
window.location.reload();
} else {
window.location.href = "/slides";
}
}
async onClickSubscriptionSubmit() {
if (this.state.buttonDisabled) {
return;
}
this.state.buttonDisabled = true;
if (this.isFollower === this.isChecked) {
this.props.close();
} else {
await this.rpc(`/slides/channel/${this.isChecked ? "subscribe" : "unsubscribe"}`, {
channel_id: this.channelID,
});
window.location.reload();
}
}
}
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="website_slides.SlideUnsubscribeDialog" owl="1">
<Dialog size="'md'" title="state.title">
<div>
<t t-if="state.mode === 'subscription'">
<form class="clearfix">
<div class="controls mt8">
<CheckBox value="isChecked" name="subscribed" onChange="(isChecked) => this.onChangeCheckbox(isChecked)">
Be notified when a new content is added.
</CheckBox>
</div>
</form>
</t>
<t t-if="state.mode === 'leave'">
<p>Do you really want to leave the course?</p>
<p>All progress will be lost until you rejoin this course.</p>
</t>
</div>
<t t-set-slot="footer" owl="1">
<t t-if="state.mode === 'subscription'">
<button class="btn btn-primary" t-att-disabled="state.buttonDisabled" t-on-click="() => this.onClickSubscriptionSubmit()">Save</button>
<button class="btn" t-att-disabled="state.buttonDisabled" t-on-click="() => this.props.close()">Discard</button>
<button class="btn btn-danger ms-auto" t-att-disabled="state.buttonDisabled" t-on-click="() => this.onClickLeaveCourse()">or Leave the course</button>
</t>
<t t-if="state.mode === 'leave'">
<button class="btn btn-danger" t-att-disabled="state.buttonDisabled" t-on-click="() => this.onClickLeaveCourseSubmit()">Leave the course</button>
<button class="btn" t-att-disabled="state.buttonDisabled" t-on-click="() => this.onClickLeaveCourseCancel()">Discard</button>
</t>
</t>
</Dialog>
</t>
</templates>
@@ -1,68 +1,8 @@
/** @odoo-module **/
import publicWidget from '@web/legacy/js/public/public_widget';
import Dialog from '@web/legacy/js/core/dialog';
import { _t } from "@web/core/l10n/translation";
var CategoryAddDialog = Dialog.extend({
template: 'slides.category.add',
events: Object.assign(
{ "submit form#slide_category_add_form": "_onClickFormSubmit" },
Dialog.events
),
/**
* @override
*/
init: function (parent, options) {
options = Object.assign({
title: _t('Add a section'),
size: 'medium',
buttons: [{
text: _t('Save'),
classes: 'btn-primary o_js_website_slides_add_category_btn',
click: this._onClickFormSubmit.bind(this),
}, {
text: _t('Back'),
close: true
}]
}, options || {});
this.csrf_token = odoo.csrf_token;
this.channelId = options.channelId;
this._super(parent, options);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
_formValidate: function ($form) {
$form.addClass('was-validated');
return $form[0].checkValidity();
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Handle form submission on click button or press ENTER key. Prevent multiple submissions
* @private
* @param {Event} ev
*/
_onClickFormSubmit: function (ev) {
ev.preventDefault();
const form = document.getElementById("slide_category_add_form");
if (!form.classList.contains("o_js_website_slides_form_submitted")) {
if (this._formValidate($(form))) {
form.classList.add("o_js_website_slides_form_submitted");
document.querySelector("button.o_js_website_slides_add_category_btn").disabled = true;
form.submit();
}
}
},
});
import publicWidget from '@web/legacy/js/public/public_widget';
import { CategoryAddDialog } from "@website_slides/js/public/components/category_add_dialog/category_add_dialog";
publicWidget.registry.websiteSlidesCategoryAdd = publicWidget.Widget.extend({
selector: '.o_wslides_js_slide_section_add',
@@ -75,7 +15,21 @@ publicWidget.registry.websiteSlidesCategoryAdd = publicWidget.Widget.extend({
//--------------------------------------------------------------------------
_openDialog: function (channelId) {
new CategoryAddDialog(this, {channelId: channelId}).open();
this.call("dialog", "add", CategoryAddDialog, {
title: _t("Add a section"),
confirmLabel: _t("Save"),
confirm: ({ formEl }) => {
if (!formEl.checkValidity()) {
return false;
}
formEl.classList.add("was-validated");
formEl.submit();
return true;
},
cancelLabel: _t("Back"),
cancel: () => {},
channelId,
});
},
//--------------------------------------------------------------------------
@@ -93,6 +47,5 @@ publicWidget.registry.websiteSlidesCategoryAdd = publicWidget.Widget.extend({
});
export default {
categoryAddDialog: CategoryAddDialog,
websiteSlidesCategoryAdd: publicWidget.registry.websiteSlidesCategoryAdd
};
@@ -1,58 +1,13 @@
/** @odoo-module **/
import publicWidget from '@web/legacy/js/public/public_widget';
import Dialog from '@web/legacy/js/core/dialog';
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import publicWidget from "@web/legacy/js/public/public_widget";
import { _t } from "@web/core/l10n/translation";
const categoryDeleteDialog = Dialog.extend({
template: 'slides.category.delete',
/**
* @constructor
* @param {Widget} parent
* @param {DOM Object} slideTarget
*/
init(parent, slideTarget) {
this.categoryId = parseInt(slideTarget.dataset.categoryId);
this._super(parent, {
title: _t('Delete Category'),
size: 'medium',
buttons: [{
text: _t('Delete'),
classes: 'btn-primary',
click: () => this._onClickDelete(),
}, {
text: _t('Cancel'),
close: true
}]
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Calls 'unlink' method on slides.slide to delete the category and
* reloads page after deletion to re-arrange the content on UI
*
* @private
*/
_onClickDelete() {
this._rpc({
model: 'slide.slide',
method: 'unlink',
args: [this.categoryId],
}).then(function () {
window.location.reload();
});
}
});
publicWidget.registry.websiteSlidesCategoryDelete = publicWidget.Widget.extend({
selector: '.o_wslides_js_category_delete',
selector: ".o_wslides_js_category_delete",
events: {
'click': '_onClickDeleteCateogry',
click: "_onClickDeleteCateogry",
},
//--------------------------------------------------------------------------
@@ -64,11 +19,28 @@ publicWidget.registry.websiteSlidesCategoryDelete = publicWidget.Widget.extend({
* @param {MouseEvent} ev
*/
_onClickDeleteCateogry(ev) {
new categoryDeleteDialog(this, ev.currentTarget).open();
const categoryId = parseInt(ev.currentTarget.dataset.categoryId);
this.call("dialog", "add", ConfirmationDialog, {
title: _t("Delete Category"),
body: _t("Are you sure you want to delete this category?"),
confirmLabel: _t("Delete"),
confirm: async () => {
/**
* Calls 'unlink' method on slides.slide to delete the category and
* reloads page after deletion to re-arrange the content on UI
*/
await this._rpc({
model: "slide.slide",
method: "unlink",
args: [categoryId],
});
window.location.reload();
},
cancel: () => {},
});
},
});
export default {
categoryDeleteDialog: categoryDeleteDialog,
websiteSlidesCategoryDelete: publicWidget.registry.websiteSlidesCategoryDelete
websiteSlidesCategoryDelete: publicWidget.registry.websiteSlidesCategoryDelete,
};
@@ -1,135 +1,7 @@
/** @odoo-module **/
import Dialog from '@web/legacy/js/core/dialog';
import publicWidget from '@web/legacy/js/public/public_widget';
import { _t } from "@web/core/l10n/translation";
import { renderToElement } from "@web/core/utils/render";
var SlideUnsubscribeDialog = Dialog.extend({
template: 'slides.course.unsubscribe.modal',
_texts: {
titleSubscribe: _t("Subscribe"),
titleUnsubscribe: _t("Notifications"),
titleLeaveCourse: _t("Leave the course")
},
/**
* @override
* @param {Object} parent
* @param {Object} options
*/
init: function (parent, options) {
options = Object.assign({
title: options.isFollower === 'True' ? this._texts.titleSubscribe : this._texts.titleUnsubscribe,
size: 'medium',
}, options || {});
this._super(parent, options);
this.set('state', '_subscription');
this.on('change:state', this, this._onChangeType);
this.channelID = parseInt(options.channelId, 10);
this.isFollower = options.isFollower === 'True';
this.enroll = options.enroll;
this.visibility = options.visibility;
},
/**
* @override
*/
start: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
self.$('input#subscribed').prop('checked', self.isFollower);
self._resetModal();
});
},
getSubscriptionState: function () {
return this.$('input#subscribed').prop('checked');
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
_getModalButtons: function () {
var btnList = [];
var state = this.get('state');
if (state === '_subscription') {
btnList.push({text: _t("Save"), classes: "btn-primary", click: this._onClickSubscriptionSubmit.bind(this)});
btnList.push({text: _t("Discard"), close: true});
btnList.push({text: _t("or Leave the course"), classes: "btn-danger ms-auto", click: this._onClickLeaveCourse.bind(this)});
} else if (state === '_leave') {
btnList.push({text: _t("Leave the course"), classes: "btn-danger", click: this._onClickLeaveCourseSubmit.bind(this)});
btnList.push({text: _t("Discard"), click: this._onClickLeaveCourseCancel.bind(this)});
}
return btnList;
},
/**
* @private
*/
_resetModal: function () {
var state = this.get('state');
if (state === '_subscription') {
this.set_title(this.isFollower ? this._texts.titleUnsubscribe : this._texts.titleSubscribe);
this.$('input#subscribed').prop('checked', this.isFollower);
}
else if (state === '_leave') {
this.set_title(this._texts.titleLeaveCourse);
}
this.set_buttons(this._getModalButtons());
},
//--------------------------------------------------------------------------
// Handler
//--------------------------------------------------------------------------
_onClickLeaveCourse: function () {
this.set('state', '_leave');
},
_onClickLeaveCourseCancel: function () {
this.set('state', '_subscription');
},
_onClickLeaveCourseSubmit: async function () {
await this._rpc({
route: '/slides/channel/leave',
params: {channel_id: this.channelID},
});
if (this.visibility === 'public' || this.visibility === 'connected') {
window.location.reload();
} else {
window.location.href = '/slides';
}
},
_onClickSubscriptionSubmit: function () {
if (this.isFollower === this.getSubscriptionState()) {
this.destroy();
return;
}
this._rpc({
route: this.getSubscriptionState() ? '/slides/channel/subscribe' : '/slides/channel/unsubscribe',
params: {channel_id: this.channelID},
}).then(function () {
window.location.reload();
});
},
_onChangeType: function () {
var currentType = this.get('state');
var tmpl;
if (currentType === '_subscription') {
tmpl = 'slides.course.unsubscribe.modal.subscription';
} else if (currentType === '_leave') {
tmpl = 'slides.course.unsubscribe.modal.leave';
}
this.$('.o_w_slide_unsubscribe_modal_container').empty();
this.$('.o_w_slide_unsubscribe_modal_container').append(renderToElement(tmpl, {widget: this}));
this._resetModal();
},
});
import { SlideUnsubscribeDialog } from "./public/components/slide_unsubscribe_dialog/slide_unsubscribe_dialog";
publicWidget.registry.websiteSlidesUnsubscribe = publicWidget.Widget.extend({
selector: '.o_wslides_js_channel_unsubscribe',
@@ -142,7 +14,7 @@ publicWidget.registry.websiteSlidesUnsubscribe = publicWidget.Widget.extend({
_openDialog: function ($element) {
var data = $element.data();
return new SlideUnsubscribeDialog(this, data).open();
this.call("dialog", "add", SlideUnsubscribeDialog, data);
},
//--------------------------------------------------------------------------
@@ -160,6 +32,5 @@ publicWidget.registry.websiteSlidesUnsubscribe = publicWidget.Widget.extend({
});
export default {
SlideUnsubscribeDialog: SlideUnsubscribeDialog,
websiteSlidesUnsubscribe: publicWidget.registry.websiteSlidesUnsubscribe
};
@@ -1,77 +1,13 @@
/** @odoo-module **/
import publicWidget from '@web/legacy/js/public/public_widget';
import Dialog from '@web/legacy/js/core/dialog';
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { _t } from "@web/core/l10n/translation";
var SlideArchiveDialog = Dialog.extend({
template: 'slides.slide.archive',
/**
* @override
*/
init: function (parent, options) {
options = Object.assign({
title: _t('Archive Content'),
size: 'medium',
buttons: [{
text: _t('Archive'),
classes: 'btn-primary',
click: this._onClickArchive.bind(this)
}, {
text: _t('Cancel'),
close: true
}]
}, options || {});
this.$slideTarget = options.slideTarget;
this.slideId = this.$slideTarget.data('slideId');
this._super(parent, options);
},
_checkForEmptySections: function (){
$('.o_wslides_slide_list_category').each(function (){
var $categoryHeader = $(this).find('.o_wslides_slide_list_category_header');
var categorySlideCount = $(this).find('.o_wslides_slides_list_slide:not(.o_not_editable)').length;
var $emptyFlagContainer = $categoryHeader.find('.o_wslides_slides_list_drag').first();
var $emptyFlag = $emptyFlagContainer.find('small');
if (categorySlideCount === 0 && $emptyFlag.length === 0){
$emptyFlagContainer.append($('<small>', {
'class': "ms-1 text-muted fw-bold",
text: _t("(empty)")
}));
}
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Calls 'archive' on slide controller and then visually removes the slide dom element
*/
_onClickArchive: function () {
var self = this;
this._rpc({
route: '/slides/slide/archive',
params: {
slide_id: this.slideId
},
}).then(function (isArchived) {
if (isArchived){
self.$slideTarget.closest('.o_wslides_slides_list_slide').remove();
self._checkForEmptySections();
}
self.close();
});
}
});
import publicWidget from "@web/legacy/js/public/public_widget";
publicWidget.registry.websiteSlidesSlideArchive = publicWidget.Widget.extend({
selector: '.o_wslides_js_slide_archive',
selector: ".o_wslides_js_slide_archive",
events: {
'click': '_onArchiveSlideClick',
click: "_onArchiveSlideClick",
},
//--------------------------------------------------------------------------
@@ -79,7 +15,45 @@ publicWidget.registry.websiteSlidesSlideArchive = publicWidget.Widget.extend({
//--------------------------------------------------------------------------
_openDialog: function ($slideTarget) {
new SlideArchiveDialog(this, {slideTarget: $slideTarget}).open();
const slideId = $slideTarget.data("slideId");
this.call("dialog", "add", ConfirmationDialog, {
title: _t("Archive Content"),
body: _t("Are you sure you want to archive this content?"),
confirmLabel: _t("Archive"),
confirm: async () => {
/**
* Calls 'archive' on slide controller and then visually removes the slide dom element
*/
const isArchived = await this._rpc({
route: "/slides/slide/archive",
params: {
slide_id: slideId,
},
});
if (isArchived) {
$slideTarget.closest(".o_wslides_slides_list_slide").remove();
$(".o_wslides_slide_list_category").each(function () {
var $categoryHeader = $(this).find(".o_wslides_slide_list_category_header");
var categorySlideCount = $(this).find(
".o_wslides_slides_list_slide:not(.o_not_editable)"
).length;
var $emptyFlagContainer = $categoryHeader
.find(".o_wslides_slides_list_drag")
.first();
var $emptyFlag = $emptyFlagContainer.find("small");
if (categorySlideCount === 0 && $emptyFlag.length === 0) {
$emptyFlagContainer.append(
$("<small>", {
class: "ms-1 text-muted fw-bold",
text: _t("(empty)"),
})
);
}
});
}
},
cancel: () => {},
});
},
//--------------------------------------------------------------------------
@@ -98,6 +72,5 @@ publicWidget.registry.websiteSlidesSlideArchive = publicWidget.Widget.extend({
});
export default {
slideArchiveDialog: SlideArchiveDialog,
websiteSlidesSlideArchive: publicWidget.registry.websiteSlidesSlideArchive
websiteSlidesSlideArchive: publicWidget.registry.websiteSlidesSlideArchive,
};
@@ -13,19 +13,4 @@
</div>
</t>
<t t-name="slides.category.add">
<div>
<form action="/slides/category/add" method="POST" id="slide_category_add_form">
<input type="hidden" name="csrf_token" t-att-value="widget.csrf_token"/>
<input type="hidden" name="channel_id" t-att-value="widget.channelId"/>
<div class="mb-3 row">
<label for="section_name" class="col-sm-3 col-form-label">Section name</label>
<div class="col-sm-9">
<input type="text" autocomplete="off" class="form-control" name="name" id="section_name" required="required" placeholder='e.g. "Introduction"'/>
</div>
</div>
</form>
</div>
</t>
</templates>
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="slides.course.unsubscribe.modal">
<div>
<div class="o_w_slide_unsubscribe_modal_container">
<t t-call="slides.course.unsubscribe.modal.subscription"/>
</div>
</div>
</t>
<t t-name="slides.course.unsubscribe.modal.subscription">
<form class="clearfix">
<div class="controls mt8">
<input id="subscribed" name="subscribed" type="checkbox"/>
<label for="subscribed" class="col-form-label fw-normal">Be notified when a new content is added.</label>
</div>
</form>
</t>
<t t-name="slides.course.unsubscribe.modal.leave">
<p>Do you really want to leave the course?</p>
<p>All progress will be lost until you rejoin this course.</p>
</t>
</templates>