合并企业版代码(未测试,先提交到测试分支)

This commit is contained in:
qihao.gong@jikimo.com
2023-04-14 17:42:23 +08:00
parent 7a7b3d7126
commit d28525526a
1300 changed files with 513579 additions and 5426 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70"><defs><path id="a" d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z"/><linearGradient id="c" x1="39.189%" x2="60.296%" y1="0%" y2="100%"><stop offset="0%" stop-color="#D75F94"/><stop offset="100%" stop-color="#D26060"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><g transform="translate(-7 -7)"><rect width="80" height="80" fill="url(#c)" rx="10"/><path fill="#393939" d="M44.756 76H11c-2 0-4-1-4-4V41.482l14.928-13.846.401.487L28 22l18 20 14 17-15.244 17z" opacity=".324"/><path fill="#000" fill-opacity=".383" d="M11 76h61c2.667 0 4.333-1 5-3v4H7v-4c.667 2 2 3 4 3z"/><path fill="#000" d="M27.461 23.926c2.925-.947 6.24-.277 8.536 2.02 2.287 2.286 2.96 5.583 2.032 8.499l2.506 2.506-5.538 5.537-2.477-2.477c-2.936.97-6.275.306-8.585-2.003-2.288-2.289-2.961-5.589-2.03-8.506l3.912 3.911a3.94 3.94 0 0 0 5.569.003 3.938 3.938 0 0 0-.003-5.569l-3.922-3.921zm-3.388 32.151L54.111 26.04c1.638-1.638 4.3-1.647 5.935-.01l.106.105a4.188 4.188 0 0 1 0 5.927L30.048 62.165 21.7 64.406l2.373-8.329zm35.856 5.396a3.918 3.918 0 0 1-5.537.001L44.11 51.192l5.538-5.538L59.93 55.937a3.915 3.915 0 0 1-.001 5.536z" opacity=".3"/><path fill="#FFF" d="M27.461 21.926c2.925-.947 6.24-.277 8.536 2.02 2.287 2.286 2.96 5.583 2.032 8.499l2.506 2.506-5.538 5.537-2.477-2.477c-2.936.97-6.275.306-8.585-2.003-2.288-2.289-2.961-5.589-2.03-8.506l3.912 3.911a3.94 3.94 0 0 0 5.569.003 3.938 3.938 0 0 0-.003-5.569l-3.922-3.921zm-3.388 32.151L54.111 24.04c1.638-1.638 4.3-1.647 5.935-.01l.106.105a4.188 4.188 0 0 1 0 5.927L30.048 60.165 21.7 62.406l2.373-8.329zm35.856 5.396a3.918 3.918 0 0 1-5.537.001L44.11 49.192l5.538-5.538L59.93 53.937a3.915 3.915 0 0 1-.001 5.536z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,17 @@
.o-approval-popover {
.o-approval-popover--header {
background-color: map-get($grays, '200');
padding: 0.5rem 0.75rem;
border-bottom: 1px solid $border-color;
}
.o_web_studio_approval_info {
margin: 0.5rem;
}
}
.o-approval-dialog {
.o_web_studio_approval_info {
margin-top: 1rem;
}
}

View File

@@ -0,0 +1,151 @@
/** @odoo-module */
import { useService } from "@web/core/utils/hooks";
import { _lt } from "@web/core/l10n/translation";
import { renderToMarkup } from "@web/core/utils/render";
const { xml, reactive } = owl;
const missingApprovalsTemplate = xml`
<ul>
<li t-foreach="missingApprovals" t-as="approval" t-key="approval_index">
<t t-esc="approval.message or approval.group_id[1]" />
</li>
</ul>
`;
const notificationTitle = _lt("The following approvals are missing:");
function getMissingApprovals(entries, rules) {
const missingApprovals = [];
const doneApprovals = entries.filter((e) => e.approved).map((e) => e.rule_id[0]);
rules.forEach((r) => {
if (!doneApprovals.includes(r.id)) {
missingApprovals.push(r);
}
});
return missingApprovals;
}
class StudioApproval {
constructor() {
this._data = reactive({});
// Lazy properties to be set by specialization.
this.orm = null;
this.studio = null;
this.notification = null;
this.resModel = null;
this.resId = null;
this.method = null;
this.action = null;
}
get dataKey() {
return `${this.resModel}-${this.resId}-${this.method}-${this.action}`;
}
/**
* The approval's values for a given resModel, resId, method and action.
* If current values don't exist, we fetch them from the server. Owl's fine reactivity
* does the update of every component using that state.
*/
get state() {
if (!(this.dataKey in this._data)) {
this._data[this.dataKey] = { rules: null };
this.fetchApprovals();
}
return this._data[this.dataKey];
}
get inStudio() {
return !!this.studio.mode;
}
displayNotification(data) {
const missingApprovals = getMissingApprovals(data.entries, data.rules);
this.notification.add(renderToMarkup(missingApprovalsTemplate, { missingApprovals }), {
type: "warning",
title: notificationTitle,
});
}
async checkApproval() {
const args = [this.resModel, this.resId, this.method, this.action];
const result = await this.orm.call("studio.approval.rule", "check_approval", args);
const approved = result.approved;
if (!approved) {
this.displayNotification(result);
}
this.fetchApprovals(); // don't wait
return approved;
}
async fetchApprovals() {
const args = [this.resModel, this.method, this.action];
const kwargs = {
res_id: !this.studio.mode && this.resId,
};
Object.assign(this.state, { syncing: true });
const spec = await this.orm.silent.call(
"studio.approval.rule",
"get_approval_spec",
args,
kwargs
);
Object.assign(this.state, spec, { syncing: false });
}
/**
* Create or update an approval entry for a specified rule server-side.
* @param {Number} ruleId
* @param {Boolean} approved
*/
async setApproval(ruleId, approved) {
try {
await this.orm.call("studio.approval.rule", "set_approval", [[ruleId]], {
res_id: this.resId,
approved,
});
} finally {
await this.fetchApprovals();
}
}
/**
* Delete an approval entry for a given rule server-side.
* @param {Number} ruleId
*/
async cancelApproval(ruleId) {
try {
await this.orm.call("studio.approval.rule", "delete_approval", [[ruleId]], {
res_id: this.resId,
});
} finally {
await this.fetchApprovals();
}
}
}
const approvalMap = new WeakMap();
export function useApproval({ record, method, action }) {
const orm = useService("orm");
const studio = useService("studio");
const notification = useService("notification");
let approval = approvalMap.get(record.model);
if (!approval) {
approval = new StudioApproval();
approvalMap.set(record.model, approval);
}
const specialize = {
resModel: record.resModel,
resId: record.resId,
method,
action,
orm,
studio,
notification,
};
return Object.assign(Object.create(approval), specialize);
}

View File

@@ -0,0 +1,34 @@
/** @odoo-module */
import { formatDate, deserializeDate } from "@web/core/l10n/dates";
import { useService } from "@web/core/utils/hooks";
import { Dialog } from "@web/core/dialog/dialog";
const { useState, Component } = owl;
export class StudioApprovalInfos extends Component {
setup() {
this.user = useService("user");
const approval = this.props.approval;
this.approval = approval;
this.state = useState(approval.state);
}
formatDate(val, format) {
return formatDate(deserializeDate(val), { format });
}
getEntry(ruleId) {
return this.state.entries.find((e) => e.rule_id[0] === ruleId);
}
setApproval(ruleId, approved) {
return this.approval.setApproval(ruleId, approved);
}
cancelApproval(ruleId) {
return this.approval.cancelApproval(ruleId);
}
}
StudioApprovalInfos.template = "StudioApprovalInfos";
StudioApprovalInfos.components = { Dialog };

View File

@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="StudioApprovalInfos" owl="1">
<div class="o-approval-popover" t-if="props.isPopover">
<div class="o-approval-popover--header">Approval</div>
<t t-call="StudioApprovalInfos.contents" />
</div>
<t t-else="">
<Dialog title="'Approval'" contentClass="'o-approval-dialog'">
<h3 t-if="approval.action or approval.method">
<strong>Action to approve:</strong> <t t-esc="approval.method ? 'method' : ''"/> <t t-esc="approval.action or approval.method"/>
</h3>
<t t-call="StudioApprovalInfos.contents" />
<t t-set-slot="footer" t-slot-scope="dialog">
<button class="btn btn-secondary" t-on-click="() => dialog.close()">
<t>Close</t>
</button>
</t>
</Dialog>
</t>
</t>
<t t-name="StudioApprovalInfos.contents" owl="1">
<div class="o_web_studio_approval_info" t-att-data-mobile="env.isSmall">
<t t-if="state.rules === null">
<i class="fa fa-circle-o-notch fa-spin"/>
</t>
<div t-else="" t-foreach="state.rules" t-as="rule" t-key="rule.id" class="o_web_studio_approval_rule">
<t t-set="entry" t-value="getEntry(rule.id)"/>
<div t-if="!getEntry(rule.id)" class="o_web_studio_approval_no_entry">
<div class="o_web_studio_approval_avatar">
<img
src="/web/static/img/user_menu_avatar.png"
class="o_web_studio_approval_avatar rounded-circle border border-muted"/>
</div>
<div class="o_web_studio_approval_description">
<t t-if="rule.message" t-esc="rule.message"/>
<t t-else="">Awaiting approval</t>
<p class="small" t-esc="rule.group_id[1]"/>
</div>
<t t-if="rule.can_validate">
<div class="o_web_studio_approval_button">
<button
class="btn btn-primary btn-sm btn-block o_web_approval_approve"
title="Approve"
t-on-click="() => this.setApproval(rule.id, true)"
t-att-disabled="state.syncing || !approval.resId || approval.inStudio"
>
<i class="fa fa-fw fa-check"/>
</button>
</div>
<div class="o_web_studio_approval_button">
<button
class="btn btn-danger btn-sm btn-block o_web_approval_reject"
title="Reject"
t-on-click="() => this.setApproval(rule.id, false)"
t-att-disabled="state.syncing || !approval.resId || approval.inStudio"
>
<i class="fa fa-fw fa-times"/>
</button>
</div>
</t>
</div>
<div t-else="" t-attf-class="o_web_studio_approval_has_entry o_approval_{{getEntry(rule.id).approved?'success':'danger'}}">
<div class="o_web_studio_approval_avatar">
<img
t-attf-src="/web/image/res.users/{{getEntry(rule.id).user_id[0]}}/avatar_128"
t-attf-class="o_web_studio_approval_avatar rounded-circle border border-{{getEntry(rule.id).approved?'info':'danger'}}"
t-att-title="getEntry(rule.id).user_id[1]"/>
</div>
<div class="o_web_studio_approval_description" t-att-title="formatDate(getEntry(rule.id).write_date, 'DDDD')">
<p>
<strong t-esc="getEntry(rule.id).user_id[1]"/> <i t-if="rule.exclusive_user" class="fa fa-shield" title="This rule limits this user to a single approval for this action."/>
<br/>
<span class="small">
<t t-if="getEntry(rule.id).approved">Approved on </t>
<t t-else="">Rejected on </t>
<span t-esc="formatDate(getEntry(rule.id).write_date, 'DD')"/>
</span>
</p>
</div>
<t t-if="rule.can_validate and getEntry(rule.id).user_id[0] === user.userId">
<div class="o_web_studio_approval_button">
<button
class="btn btn-secondary btn-sm btn-block o_web_approval_cancel"
title="Revoke"
t-on-click="(ev) => this.cancelApproval(rule.id)"
t-att-disabled="state.syncing || approval.inStudio"
>
<i class="fa fa-fw fa-undo"/>
</button>
</div>
</t>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,63 @@
/** @odoo-module */
import { useService } from "@web/core/utils/hooks";
import { StudioApprovalInfos } from "@web_studio/approval/approval_infos";
const { useState, Component, onWillUnmount, useRef } = owl;
function useOpenExternal() {
const closeFns = [];
function open(_open) {
const close = _open();
closeFns.push(close);
return close;
}
onWillUnmount(() => {
closeFns.forEach((cb) => cb());
});
return open;
}
export class StudioApproval extends Component {
setup() {
this.dialog = useService("dialog");
this.popover = useService("popover");
this.rootRef = useRef("root");
this.openExternal = useOpenExternal();
const approval = this.props.approval;
this.approval = approval;
this.state = useState(approval.state);
}
toggleApprovalInfo() {
if (this.isOpened) {
this.closeInfos();
this.closeInfos = null;
return;
}
const onClose = () => {
this.isOpened = false;
};
if (this.env.isSmall) {
this.closeInfos = this.openExternal(() =>
this.dialog.add(StudioApprovalInfos, { approval: this.approval }, { onClose })
);
} else {
this.closeInfos = this.openExternal(() =>
this.popover.add(
this.rootRef.el,
StudioApprovalInfos,
{ approval: this.approval, isPopover: true },
{ onClose }
)
);
}
}
getEntry(ruleId) {
return this.state.entries.find((e) => e.rule_id[0] === ruleId);
}
}
StudioApproval.template = "StudioApproval";

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="StudioApproval" owl="1">
<div class="o_web_studio_approval" t-on-click.stop="toggleApprovalInfo" t-ref="root">
<!-- no rules list: first fetch not done, display spinner -->
<t t-if="state.rules === null">
<i class="fa fa-circle-o-notch fa-spin"/>
</t>
<t t-else="state.rules.length">
<!-- data fetched; display avatar for rules and entries -->
<t t-set="num_rules" t-value="state.rules.length"/>
<t t-foreach="state.rules" t-as="rule" t-key="rule.id">
<t t-if="num_rules lte 3 || (num_rules gt 3 and rule_index lt 2)">
<img
t-if="getEntry(rule.id)"
t-key="rule.id"
t-attf-src="/web/image/res.users/{{getEntry(rule.id).user_id[0]}}/avatar_128"
t-att-title="getEntry(rule.id).user_id[1]"
t-att-alt="getEntry(rule.id).user_id[1]"
t-attf-class="o_web_studio_approval_avatar rounded-circle border border-{{getEntry(rule.id).approved?'info':'danger'}}"
/>
<img
t-else=""
t-key="rule.id"
src="/web/static/img/user_menu_avatar.png"
title="Waiting for approval"
alt="User avatar placeholder"
class="o_web_studio_approval_avatar rounded-circle border border-muted"
/>
</t>
<t t-if="rule_last and num_rules gt 3">
<t t-set="extra_rules" t-value="num_rules - 2"/>
<span
t-key="'extra_rules'"
class="o_web_studio_approval_more rounded-circle bg-secondary"
title="Click to see all approval rules.">
+<t t-esc="extra_rules"/>
</span>
</t>
</t>
<t t-if="env.isSmall">
<span class="o_web_studio_approval_more rounded-circle bg-primary">
<i class="fa fa-info"/>
</span>
</t>
</t>
</div>
</t>
</templates>

View File

@@ -0,0 +1,46 @@
/** @odoo-module */
import { ViewButton } from "@web/views/view_button/view_button";
import { ViewCompiler } from "@web/views/view_compiler";
import { patch } from "@web/core/utils/patch";
import { StudioApproval } from "@web_studio/approval/studio_approval";
import { useApproval } from "@web_studio/approval/approval_hook";
patch(ViewCompiler.prototype, "web_studio.ViewCompilerApproval", {
compileButton(el, params) {
const button = this._super(...arguments);
const studioApproval = el.getAttribute("studio_approval") === "True";
if (studioApproval) {
button.setAttribute("studioApproval", studioApproval);
}
return button;
},
});
patch(ViewButton.prototype, "web_studio.ViewButtonApproval", {
setup() {
this._super(...arguments);
if (this.props.studioApproval) {
const { type, name } = this.props.clickParams;
const action = type === "action" && name;
const method = type === "object" && name;
this.approval = useApproval({
record: this.props.record,
action,
method,
});
const onClickViewButton = this.env.onClickViewButton;
owl.useSubEnv({
onClickViewButton: (params) => {
params.beforeExecute = async () => this.approval.checkApproval();
onClickViewButton(params);
},
});
}
},
});
ViewButton.props.push("studioApproval?");
ViewButton.components = Object.assign(ViewButton.components || {}, { StudioApproval });

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="web.views.ViewButton" t-inherit-mode="extension" owl="1">
<xpath expr="./t[@t-tag='props.tag']" position="attributes">
<attribute name="t-attf-studio_approval">{{ approval ? true : undefined }}</attribute>
</xpath>
<xpath expr="//t[@t-slot='contents']" position="after" >
<StudioApproval t-if="approval" approval="approval" resId="approval.resId" inStudio="approval.inStudio"/>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,354 @@
/** @odoo-module **/
import { useAutofocus, useService } from "@web/core/utils/hooks";
import { ModelConfigurator } from "@web_studio/client_action/model_configurator/model_configurator";
import { BG_COLORS, COLORS, ICONS } from "@web_studio/utils";
import { ComponentAdapter, ComponentWrapper, WidgetAdapterMixin } from "web.OwlCompatibility";
import { FieldMany2One } from "web.relational_fields";
import StandaloneFieldManagerMixin from "web.StandaloneFieldManagerMixin";
import Widget from "web.Widget";
import { IconCreator } from "../icon_creator/icon_creator";
const { Component, onWillStart, useExternalListener, useState } = owl;
class ModelSelector extends ComponentAdapter {
constructor() {
Object.assign(arguments[0], { Component: FieldMany2One });
super(...arguments);
}
updateWidget() {}
renderWidget() {}
_trigger_up(ev) {
if (ev.name === "field_changed" && this.props.onFieldChanged) {
this.props.onFieldChanged(ev.data);
}
return super._trigger_up(...arguments);
}
}
export const AppCreatorWrapper = Widget.extend(StandaloneFieldManagerMixin, WidgetAdapterMixin, {
target: "fullscreen",
/**
* This widget is directly bound to its inner owl component and its sole purpose
* is to instanciate it with the adequate properties: it will manually
* mount the component when attached to the dom, will dismount it when detached
* and destroy it when destroyed itself.
* @constructor
*/
init(parent, props) {
this._super(...arguments);
StandaloneFieldManagerMixin.init.call(this);
this.appCreatorComponent = new ComponentWrapper(this, AppCreator, {
...props,
model: this.model,
});
},
async start() {
Object.assign(this.el.style, {
height: "100%",
overflow: "auto",
});
await this._super(...arguments);
return this.appCreatorComponent.mount(this.el);
},
destroy() {
WidgetAdapterMixin.destroy.call(this);
this._super();
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Overriden to register widgets on the fly since they have been instanciated
* by the Component.
* @override
*/
_onFieldChanged(ev) {
const targetWidget = ev.data.__targetWidget;
this._registerWidget(ev.data.dataPointID, targetWidget.name, targetWidget);
StandaloneFieldManagerMixin._onFieldChanged.apply(this, arguments);
},
});
/**
* App creator
*
* Action handling the complete creation of a new app. It requires the user
* to enter an app name, to customize the app icon (@see IconCreator) and
* to finally enter a menu name, with the option to bind the default app
* model to an existing one.
*
* TODO: this component is bound to an action adapter since the action manager
* cannot yet handle owl component. This file must be reviewed as soon as
* the action manager is updated.
* @extends Component
*/
class AppCreator extends Component {
setup() {
// TODO: Many2one component directly attached in XML. For now we have
// to toggle it manually according to the state changes.
this.state = useState({
step: "welcome",
appName: "",
menuName: "",
modelChoice: "new",
modelOptions: [],
modelId: false,
iconData: {
backgroundColor: BG_COLORS[5],
color: COLORS[4],
iconClass: ICONS[0],
type: "custom_icon",
},
});
this.debug = Boolean(AppCreator.env.isDebug());
this.uiService = useService("ui");
this.rpc = useService("rpc");
useAutofocus();
this.invalid = useState({
appName: false,
menuName: false,
modelId: false,
});
useExternalListener(window, "keydown", this.onKeydown);
onWillStart(() => this.onWillStart());
}
async onWillStart() {
const recordId = await this.props.model.makeRecord("ir.actions.act_window", [
{
name: "model",
relation: "ir.model",
type: "many2one",
domain: [
["transient", "=", false],
["abstract", "=", false],
],
},
]);
this.record = this.props.model.get(recordId);
}
//--------------------------------------------------------------------------
// Getters
//--------------------------------------------------------------------------
/**
* @returns {boolean}
*/
get isReady() {
return (
this.state.step === "welcome" ||
(this.state.step === "app" && this.state.appName) ||
(this.state.step === "model" &&
this.state.menuName &&
(this.state.modelChoice === "new" ||
(this.state.modelChoice === "existing" && this.state.modelId)))
);
}
//--------------------------------------------------------------------------
// Protected
//--------------------------------------------------------------------------
/**
* Switch the current step and clean all invalid keys.
* @param {string} step
*/
changeStep(step) {
this.state.step = step;
for (const key in this.invalid) {
this.invalid[key] = false;
}
}
/**
* @returns {Promise}
*/
async createNewApp() {
this.uiService.block();
const iconValue =
this.state.iconData.type === "custom_icon"
? // custom icon data
[
this.state.iconData.iconClass,
this.state.iconData.color,
this.state.iconData.backgroundColor,
]
: // attachment
this.state.iconData.uploaded_attachment_id;
try {
const result = await this.rpc({
route: "/web_studio/create_new_app",
params: {
app_name: this.state.appName,
menu_name: this.state.menuName,
model_choice: this.state.modelChoice,
model_id: this.state.modelChoice && this.state.modelId,
model_options: this.state.modelOptions,
icon: iconValue,
context: this.env.session.user_context,
},
});
this.props.onNewAppCreated(result);
} catch (error) {
if (!error || !(error instanceof Error)) {
this.onPrevious();
} else {
throw error;
}
} finally {
this.uiService.unblock();
}
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @param {Event} ev
*/
onChecked(ev) {
const modelChoice = ev.currentTarget.value;
this.state.modelChoice = modelChoice;
if (this.state.modelChoice === "new") {
this.state.modelId = undefined;
}
}
/**
* @param {Object} detail
*/
onModelIdChanged(detail) {
if (this.state.modelChoice === "existing") {
this.state.modelId = detail.changes.model.id;
this.invalid.modelId = isNaN(this.state.modelId);
} else {
this.state.modelId = false;
this.invalid.modelId = false;
}
}
/**
* @param {Object} icon
*/
onIconChanged(icon) {
for (const key in this.state.iconData) {
delete this.state.iconData[key];
}
Object.assign(this.state.iconData, icon);
}
/**
* @param {InputEvent} ev
*/
onInput(ev) {
const input = ev.currentTarget;
if (this.invalid[input.id]) {
this.invalid[input.id] = !input.value;
}
this.state[input.id] = input.value;
}
/**
* @param {KeyboardEvent} ev
*/
onKeydown(ev) {
if (
ev.key === "Enter" &&
!(
ev.target.classList &&
ev.target.classList.contains("o_web_studio_app_creator_previous")
)
) {
ev.preventDefault();
this.onNext();
}
}
/**
* Handle the confirmation of options in the modelconfigurator
* @param {Object} options
*/
onConfirmOptions(options) {
this.state.modelOptions = Object.entries(options)
.filter((opt) => opt[1].value)
.map((opt) => opt[0]);
return this.onNext();
}
async onNext() {
switch (this.state.step) {
case "welcome": {
this.changeStep("app");
break;
}
case "app": {
if (!this.state.appName) {
this.invalid.appName = true;
} else {
this.changeStep("model");
}
break;
}
case "model": {
if (!this.state.menuName) {
this.invalid.menuName = true;
}
if (this.state.modelChoice === "existing" && !this.state.modelId) {
this.invalid.modelId = true;
} else if (this.state.modelChoice === "new") {
this.invalid.modelId = false;
}
const isValid = Object.values(this.invalid).reduce(
(valid, key) => valid && !key,
true
);
if (isValid) {
if (this.state.modelChoice === "new") {
this.changeStep("model_configuration");
} else {
this.createNewApp();
}
}
break;
}
case "model_configuration": {
// no validation for this step, every configuration is valid
this.createNewApp();
break;
}
}
}
async onPrevious() {
switch (this.state.step) {
case "app": {
this.changeStep("welcome");
break;
}
case "model": {
this.changeStep("app");
break;
}
case "model_configuration": {
this.changeStep("model");
break;
}
}
}
}
AppCreator.components = { ModelSelector, IconCreator, ModelConfigurator };
AppCreator.props = {
model: Object,
onNewAppCreated: { type: Function },
};
AppCreator.template = "web_studio.AppCreator";

View File

@@ -0,0 +1,194 @@
// App creator - style
.o_web_studio_app_creator {
@include o-web-studio-app-creator-background;
.o_web_studio_app_creator_box {
box-shadow: 0 16px 13px -8px rgba($o-web-studio-bg-dark, 0.5);
color: $o-web-studio-bg-dark;
.o_web_studio_app_creator_left {
background-color: white;
h2.o_web_studio_welcome {
font-weight: 200;
margin-top: 70px;
}
h1 {
font-size: 3.5em;
letter-spacing: -1px;
margin-bottom: 0.3em;
}
h5 {
font-size: 13px;
position: absolute;
}
.o_web_studio_app_creator_name .o_web_studio_app_creator_menu {
margin-top: 50px;
}
.o_web_studio_app_creator_model_choice {
margin-top: 25px;
@include o-web-studio-checkbox(
$body-color,
$o-web-studio-bg-medium,
$o-web-studio-bg-dark,
white,
map-get($grays, "600")
);
}
.o_web_studio_app_creator_model .o_external_button {
color: $body-color;
padding-right: 0;
&:hover,
&:focus,
&:active {
background-color: transparent;
color: $o-web-studio-bg-dark;
}
}
.o_web_studio_app_creator_field_warning > label {
color: $o-web-studio-color-danger;
}
}
.o_web_studio_app_creator_right {
background-color: $o-web-studio-bg-dark;
.o_web_studio_welcome_image {
width: 183px;
height: 183px;
border-radius: 4px;
box-shadow: 0 7px 16px -5px #000;
}
}
.o_web_studio_app_creator_next,
.o_web_studio_app_creator_previous {
background-color: white;
border: 1px solid $o-web-studio-text-inactive;
border-radius: 2px;
color: $o-web-studio-text-inactive;
font-size: 11px;
font-weight: 500;
opacity: 0.5;
padding: 7px 15px;
text-transform: uppercase;
> span {
transition: opacity 0.35s ease 0s;
}
&:hover,
&:focus {
border-color: $o-web-studio-text-inactive;
color: $o-web-studio-text-inactive;
}
}
.o_web_studio_app_creator_next {
cursor: no-drop;
&.is_ready {
cursor: pointer;
background-color: $o-brand-primary;
border-color: darken($o-brand-primary, 3%);
color: white;
opacity: 1;
transition: all 0.3s ease 0s;
transition-property: padding, background;
> span {
opacity: 1;
padding-right: 15px;
transition: opacity 0.1s ease 0s;
}
&:hover,
&:focus {
background-color: lighten($o-brand-primary, 2%);
border-color: $o-brand-primary;
color: white;
}
}
}
.o_web_studio_app_creator_previous {
padding: 7px 15px 6px;
}
.o_field_widget.o_field_many2one {
width: 100%;
}
}
}
// App creator - layout
.o_web_studio_app_creator {
min-height: 100%;
padding-top: 100px;
padding-bottom: 100px;
overflow: auto;
.o_web_studio_app_creator_box {
min-height: 350px;
margin: auto;
position: relative;
width: 700px;
display: flex;
&.wide {
width: 800px;
}
.o_web_studio_app_creator_left,
.o_web_studio_app_creator_right {
float: left;
height: 100%;
padding: 16px 32px;
width: 50%;
min-height: 350px;
&.wide {
width: 100%;
}
}
.o_web_studio_app_creator_left {
border-radius: 3px 0 0 3px;
display: flex;
flex-flow: column nowrap;
.o_web_studio_app_creator_left_content {
flex: 1 1 100%;
padding-top: 20px;
.o_field_many2one {
width: 100%;
}
}
.o_web_studio_app_creator_buttons {
flex: 0 0 auto;
.o_web_studio_app_creator_next {
float: right;
}
}
}
.o_web_studio_app_creator_right {
align-items: center;
border-radius: 0 3px 3px 0;
display: flex;
justify-content: center;
.o_web_studio_app_creator_right {
height: 200px;
width: 200px;
}
}
}
}

View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="web_studio.AppCreator" owl="1">
<div class="o_web_studio_app_creator">
<div t-if="state.step !== 'model_configuration'" class="o_web_studio_app_creator_box">
<div t-attf-class="o_web_studio_app_creator_left">
<div class="o_web_studio_app_creator_left_content">
<t t-if="state.step === 'welcome'">
<h2 class="mb0 o_web_studio_welcome">Welcome to</h2>
<h1 class="mt8">Odoo Studio</h1>
<h5 class="text-muted">The fastest way to create a web application.</h5>
</t>
<t t-elif="state.step === 'app'">
<h3 class="mb32">Create your App</h3>
<div class="o_web_studio_app_creator_name" t-att-class="{ o_web_studio_app_creator_field_warning: invalid.appName }">
<label for="appName">Choose an app name</label>
<input type="text" id="appName" class="o_input"
autofocus=""
name="appName"
placeholder="e.g. Real Estate"
t-att-value="state.appName"
t-on-input="onInput"
/>
</div>
</t>
<t t-elif="state.step === 'model'">
<h3 class="mb32">Create your first Menu</h3>
<div class="o_web_studio_app_creator_menu" t-att-class="{ o_web_studio_app_creator_field_warning: invalid.menuName }">
<label for="menuName">Choose your first object name</label>
<input type="text" id="menuName" class="o_input"
autofocus=""
name="menuName"
placeholder="e.g. Properties"
t-att-value="state.menuName"
t-on-input="onInput"
/>
</div>
<div class="o_web_studio_app_creator_model_choice">
<div class="form-check">
<input type="radio"
id="model_choice_new"
name="model_choice"
value="new"
class="form-check-input"
t-on-change="onChecked"
t-att-checked="state.modelChoice === 'new'" />
<label class="form-check-label" for="model_choice_new">New Model</label>
</div>
<div class="form-check">
<input type="radio"
id="model_choice_existing"
name="model_choice"
value="existing"
class="form-check-input"
t-on-change="onChecked"
t-att-checked="state.modelChoice === 'existing'" />
<label class="form-check-label" for="model_choice_existing">Existing Model</label>
</div>
</div>
<div
t-if="state.modelChoice === 'existing'" class="o_web_studio_app_creator_model mt8"
t-att-class="{ o_web_studio_app_creator_field_warning: invalid.modelId }"
>
<label for="name">Model</label>
<ModelSelector
widgetArgs="['model', record, { mode: 'edit' }]"
onFieldChanged.bind="onModelIdChanged"
/>
</div>
</t>
</div>
<div class="o_web_studio_app_creator_buttons">
<button t-if="state.step !== 'welcome'" type="button"
class="btn fa fa-chevron-left o_web_studio_app_creator_previous"
aria-label="Previous"
title="Previous"
t-on-click="onPrevious"
/>
<button type="button"
class="btn o_web_studio_app_creator_next"
aria-label="Next"
title="Next"
t-att-class="{ is_ready: isReady }"
t-on-click="onNext"
>
<span t-if="state.step === 'welcome' or (state.step === 'app' and state.app_name) or (state.step === 'model' and state.modelChoice === 'new' and state.menu_name)">Next</span>
<span t-elif="(state.step === 'model' and state.modelChoice === 'existing' and state.menu_name and !invalid.modelChoice) or (state.step === 'model_configuration')">Create your app</span>
<i class="fa fa-chevron-right"/>
</button>
</div>
</div>
<div class="o_web_studio_app_creator_right" t-if="state.step !== 'model_configuration'">
<div class="o_web_studio_app_creator_right_content">
<t t-if="state.step === 'welcome'">
<img class="o_web_studio_welcome_image" src="/web_studio/static/src/img/studio_app_icon.png"/>
</t>
<IconCreator t-else=""
backgroundColor="state.iconData.backgroundColor"
color="state.iconData.color"
iconClass="state.iconData.iconClass"
editable="state.step === 'app'"
type="state.iconData.type"
webIconData="state.iconData.webIconData"
onIconChange.bind="onIconChanged"
/>
</div>
</div>
</div>
<ModelConfigurator
t-if="state.step === 'model_configuration'"
debug='debug'
label="'Create your app'"
onConfirmOptions.bind="onConfirmOptions"
onPrevious.bind="onPrevious"
/>
</div>
</t>
</templates>

View File

@@ -0,0 +1,9 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
const actionRegistry = registry.category("actions");
actionRegistry.add("action_web_studio_app_creator",
(env) => env.services.studio.open(env.services.studio.MODES.APP_CREATOR)
);

View File

@@ -0,0 +1,109 @@
/** @odoo-module **/
import { StudioActionContainer } from "../studio_action_container";
import { actionService } from "@web/webclient/actions/action_service";
import { useBus, useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { EditorMenu } from "./editor_menu/editor_menu";
import { mapDoActionOptionAPI } from "@web/legacy/backend_utils";
const { Component, EventBus, onWillStart, useSubEnv } = owl;
const editorTabRegistry = registry.category("web_studio.editor_tabs");
const actionServiceStudio = {
dependencies: ["studio"],
start(env) {
const action = actionService.start(env);
const _doAction = action.doAction;
async function doAction(actionRequest, options) {
if (actionRequest === "web_studio.action_edit_report") {
return env.services.studio.setParams({
editedReport: options.report,
});
}
return _doAction(...arguments);
}
return Object.assign(action, { doAction });
},
};
export class Editor extends Component {
setup() {
const services = Object.create(this.env.services);
useSubEnv({
bus: new EventBus(),
services,
});
// Assuming synchronousness
services.router = {
current: { hash: {} },
pushState() {},
};
services.action = actionServiceStudio.start(this.env);
this.studio = useService("studio");
this.actionService = useService("action");
this.rpc = useService("rpc");
useBus(this.studio.bus, "UPDATE", async () => {
const action = await this.getStudioAction();
this.actionService.doAction(action, {
clearBreadcrumbs: true,
});
});
onWillStart(this.onWillStart);
}
async onWillStart() {
this.initialAction = await this.getStudioAction();
}
switchView({ viewType }) {
this.studio.setParams({ viewType, editorTab: "views" });
}
switchViewLegacy(ev) {
this.studio.setParams({ viewType: ev.detail.view_type });
}
switchTab({ tab }) {
this.studio.setParams({ editorTab: tab });
}
async getStudioAction() {
const { editorTab, editedAction, editedReport } = this.studio;
const tab = editorTabRegistry.get(editorTab);
if (tab.action) {
return tab.action;
} else if (editorTab === "reports" && editedReport) {
return "web_studio.report_editor";
} else {
return this.rpc("/web_studio/get_studio_action", {
action_name: editorTab,
model: editedAction.res_model,
view_id: editedAction.view_id && editedAction.view_id[0], // Not sure it is correct or desirable
});
}
}
onDoAction(ev) {
// @legacy;
const payload = ev.detail;
const legacyOptions = mapDoActionOptionAPI(payload.options);
this.actionService.doAction(
payload.action,
Object.assign(legacyOptions || {}, { clearBreadcrumbs: true })
);
}
}
Editor.template = "web_studio.Editor";
Editor.components = {
EditorMenu,
StudioActionContainer,
};

View File

@@ -0,0 +1,32 @@
.o_web_studio_editor {
height: calc(100% - 46px); // 46px is the main navbar height
.o_web_studio_client_action {
position: relative;
height: 100%;
}
> .o_action_manager {
direction: ltr; //Define direction attribute here so when rtlcss preprocessor run, it converts it to rtl
flex: 1 1 auto;
height: calc(100% - 46px); // 46px is the web_studio_menu navbar
overflow: hidden;
> .o_action {
height: 100%;
display: flex;
flex-flow: column nowrap;
> .o_control_panel {
flex: 0 0 auto;
}
> .o_content {
flex: 1 1 auto;
position: relative; // Allow to redistribute the 100% height to its child
overflow: auto;
height: 100%;
}
}
}
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="web_studio.Editor" owl="1">
<div
t-on-studio-edit-view="switchViewLegacy"
t-on-do-action.stop="onDoAction"
class="o_web_studio_editor">
<EditorMenu switchView.bind="switchView" switchTab.bind="switchTab" />
<StudioActionContainer initialAction="initialAction"/>
</div>
</t>
</templates>

View File

@@ -0,0 +1,249 @@
/** @odoo-module **/
import { ComponentAdapter } from "web.OwlCompatibility";
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { NewViewDialog } from "@web_studio/client_action/editor/new_view_dialogs/new_view_dialog";
import { MapNewViewDialog } from "@web_studio/client_action/editor/new_view_dialogs/map_new_view_dialog";
import { ConfirmationDialog, AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import ActionEditor from "web_studio.ActionEditor";
import { ActionEditorMain } from "../../legacy/action_editor_main";
const { Component } = owl;
export class EditorAdapter extends ComponentAdapter {
constructor(props) {
// force dummy Component not to crash
props.Component = Component;
super(...arguments);
}
setup() {
super.setup();
this.studio = useService("studio");
if (this.studio.editedViewType) {
this.props.Component = ActionEditorMain;
} else {
this.props.Component = ActionEditor;
}
this.dialog = useService("dialog");
this.user = useService("user");
this.dialog = useService("dialog");
this.viewService = useService("view");
this.rpc = useService("rpc");
this.wowlEnv = this.env;
this.env = Component.env; // use the legacy env
}
_trigger_up(ev) {
const { name, data } = ev;
if (name === "studio_new_view") {
return this._onNewView(data);
}
if (name === "studio_disable_view") {
return this._onDisableView(data);
}
if (name === "studio_default_view") {
return this._onSetDefaultView(data);
}
if (name === "studio_restore_default_view") {
return this._onRestoreDefaultView(data);
}
if (name === "studio_edit_action") {
return this._onEditAction(data);
}
return super._trigger_up(...arguments);
}
async _onNewView(data) {
const viewType = data.view_type;
const activityAllowed = await this.rpc("/web_studio/activity_allowed", {
model: this.studio.editedAction.res_model,
});
if (viewType === "activity" && !activityAllowed) {
this.env.services.notification.notify({
title: false,
type: "danger",
message: this.env._t("Activity view unavailable on this model"),
});
return;
}
const viewMode = this.studio.editedAction.view_mode + "," + viewType;
const viewAdded = await this.addViewType(this.studio.editedAction, viewType, {
view_mode: viewMode,
});
if (viewAdded) {
return this.studio.reload({ viewType });
}
}
/**
* @private
* @param {Object} action
* @param {String} view_type
* @param {Object} args
* @returns {Promise}
*/
async addViewType(action, viewType, args) {
let viewAdded = await this.rpc("/web_studio/add_view_type", {
action_type: action.type,
action_id: action.id,
res_model: action.res_model,
view_type: viewType,
args: args,
context: this.user.context,
});
if (viewAdded !== true) {
viewAdded = new Promise((resolve) => {
let DialogClass;
const dialogProps = {
confirm: async () => {
await this.editAction(action, args);
resolve(true);
},
cancel: () => resolve(false),
};
if (["gantt", "calendar", "cohort"].includes(viewType)) {
DialogClass = NewViewDialog;
dialogProps.viewType = viewType;
} else if (viewType === "map") {
DialogClass = MapNewViewDialog;
} else {
this.dialog.add(AlertDialog, {
body: this.env._lt(
"Creating this type of view is not currently supported in Studio."
),
});
resolve(false);
}
this.dialog.add(DialogClass, dialogProps);
});
}
return viewAdded;
}
/**
* @private
* @param {OdooEvent} event
*/
async _onEditAction(data) {
const args = data.args;
if (!args) {
return;
}
await this.editAction(this.studio.editedAction, args);
this.studio.reload();
}
/**
* @private
* @param {Object} action
* @param {Object} args
* @returns {Promise}
*/
async editAction(action, args) {
this.env.bus.trigger("clear_cache");
const result = await this.rpc("/web_studio/edit_action", {
action_type: action.type,
action_id: action.id,
args: args,
context: this.user.context,
});
if (result !== true) {
this.dialog.add(AlertDialog, {
body: result,
});
}
}
/**
* @private
* @param {String} view_mode
* @returns {Promise}
*/
async _writeViewMode(viewMode) {
await this.editAction(this.studio.editedAction, { view_mode: viewMode });
this.studio.reload({ viewType: null });
}
_onDisableView(data) {
const viewType = data.view_type;
const viewMode = this.studio.editedAction.view_mode
.split(",")
.filter((m) => m !== viewType);
if (!viewMode.length) {
this.dialog.add(AlertDialog, {
body: this.env._t("You cannot deactivate this view as it is the last one active."),
});
} else {
this._writeViewMode(viewMode.toString());
}
}
_onSetDefaultView(data) {
const viewType = data.view_type;
const actionViewModes = this.studio.editedAction.view_mode.split(",");
const viewMode = actionViewModes.filter((vt) => vt !== viewType);
viewMode.unshift(viewType);
return this._writeViewMode(viewMode.toString());
}
_onRestoreDefaultView(data) {
const message = this.env._t(
"Are you sure you want to restore the default view?\r\nAll customization done with studio on this view will be lost."
);
const { context, views, res_model } = this.studio.editedAction;
const viewType = data.view_type;
const confirm = async () => {
const newContext = Object.assign({}, context, {
studio: true,
lang: false,
});
this.env.bus.trigger("clear_cache");
// To restore the default view from an inherited one, we need first to retrieve the default view id
const result = await this.viewService.loadViews(
{
resModel: res_model,
views,
context: newContext,
},
{ loadIrFilters: true }
);
return this.rpc("/web_studio/restore_default_view", {
view_id: result.views[viewType].id,
});
};
this.dialog.add(ConfirmationDialog, {
body: message,
confirm,
});
}
get widgetArgs() {
const { editedAction, editedViewType, editedControllerState, x2mEditorPath } = this.studio;
if (this.props.Component === ActionEditor) {
return [editedAction];
} else {
return [
{
action: editedAction,
viewType: editedViewType,
controllerState: editedControllerState,
x2mEditorPath: x2mEditorPath,
wowlEnv: this.wowlEnv,
},
];
}
}
}
registry.category("actions").add("web_studio.action_editor", EditorAdapter);

View File

@@ -0,0 +1,180 @@
/** @odoo-module */
import { useBus, useService } from "@web/core/utils/hooks";
import { _lt } from "@web/core/l10n/translation";
import { sprintf } from "@web/core/utils/strings";
import { localization } from "@web/core/l10n/localization";
import { registry } from "@web/core/registry";
const { Component, useState } = owl;
const editorTabRegistry = registry.category("web_studio.editor_tabs");
export class EditorMenu extends Component {
setup() {
this.l10n = localization;
this.studio = useService("studio");
this.rpc = useService("rpc");
this.state = useState({
redo_available: false,
undo_available: false,
snackbar: undefined,
});
this.nextCrumbId = 1;
useBus(this.studio.bus, "UPDATE", async () => {
await this.render(true);
this.state.snackbar = "off";
});
useBus(this.studio.bus, "undo_available", () => {
this.state.undo_available = true;
});
useBus(this.studio.bus, "undo_not_available", () => {
this.state.undo_available = false;
});
useBus(this.studio.bus, "redo_available", () => {
this.state.redo_available = true;
});
useBus(this.studio.bus, "redo_not_available", () => {
this.state.redo_available = false;
});
useBus(this.studio.bus, "toggle_snack_bar", (e) => {
this.state.snackbar = e.detail;
});
}
get breadcrumbs() {
const { editorTab } = this.studio;
const currentTab = this.editorTabs.find((tab) => tab.id === editorTab);
const crumbs = [
{
name: currentTab.name,
handler: () => this.openTab(currentTab.id),
},
];
if (currentTab.id === "views") {
const { editedViewType, x2mEditorPath } = this.studio;
if (editedViewType) {
const currentViewType = this.constructor.viewTypes.find(
(vt) => vt.type === editedViewType
);
crumbs.push({
name: currentViewType.title,
handler: () =>
this.studio.setParams({
x2mEditorPath: [],
}),
});
}
x2mEditorPath.forEach(({ x2mViewType }, index) => {
const viewType = this.constructor.viewTypes.find((vt) => vt.type === x2mViewType);
crumbs.push({
name: sprintf(
this.env._t("Subview %s"),
(viewType && viewType.title) || this.env._t("Other")
),
handler: () =>
this.studio.setParams({
x2mEditorPath: x2mEditorPath.slice(0, index + 1),
}),
});
});
} else if (currentTab.id === "reports" && this.studio.editedReport) {
crumbs.push({
name: this.studio.editedReport.data.name,
handler: () => this.studio.setParams({}),
});
}
for (const crumb of crumbs) {
crumb.id = this.nextCrumbId++;
}
return crumbs;
}
get activeViews() {
const action = this.studio.editedAction;
const viewTypes = (action._views || action.views).map(([, type]) => type);
return this.constructor.viewTypes.filter((vt) => viewTypes.includes(vt.type));
}
get editorTabs() {
const entries = editorTabRegistry.getEntries();
return entries.map((entry) => Object.assign({}, entry[1], { id: entry[0] }));
}
openTab(tab) {
this.props.switchTab({ tab });
}
}
EditorMenu.template = "web_studio.EditorMenu";
EditorMenu.viewTypes = [
{
title: _lt("Form"),
type: "form",
iconClasses: "fa fa-address-card",
},
{
title: _lt("List"),
type: "list",
iconClasses: "oi oi-view-list",
},
{
title: _lt("Kanban"),
type: "kanban",
iconClasses: "oi oi-view-kanban",
},
{
title: _lt("Map"),
type: "map",
iconClasses: "fa fa-map-marker",
},
{
title: _lt("Calendar"),
type: "calendar",
iconClasses: "fa fa-calendar",
},
{
title: _lt("Graph"),
type: "graph",
iconClasses: "fa fa-area-chart",
},
{
title: _lt("Pivot"),
type: "pivot",
iconClasses: "oi oi-view-pivot",
},
{
title: _lt("Gantt"),
type: "gantt",
iconClasses: "fa fa-tasks",
},
{
title: _lt("Dashboard"),
type: "dashboard",
iconClasses: "fa fa-tachometer",
},
{
title: _lt("Cohort"),
type: "cohort",
iconClasses: "oi oi-view-cohort",
},
{
title: _lt("Activity"),
type: "activity",
iconClasses: "fa fa-clock-o",
},
{
title: _lt("Search"),
type: "search",
iconClasses: "oi oi-search",
},
];
editorTabRegistry
.add("views", { name: _lt("Views"), action: "web_studio.action_editor" })
.add("reports", { name: _lt("Reports") })
.add("automations", { name: _lt("Automations") })
.add("acl", { name: _lt("Access Control") })
.add("filters", { name: _lt("Filter Rules") });

View File

@@ -0,0 +1,225 @@
.o_web_studio_menu_after_snackbar {
min-width: 40px;
max-width: 40px;
}
.o_web_studio_menu_before_sections {
flex: 1 1 auto;
min-width: 20px;
}
.o_web_studio_menu_before_snackbar {
min-width: 20px;
max-width: 20px;
}
.o_web_studio_menu_before_undo_redo {
min-width: 8px;
max-width: 8px;
}
.o_web_studio_menu_item {
border-bottom: $o-web-studio-menu-item-border-width solid $o-web-studio-bg-dark;
}
.o_web_studio_menu_undo_redo {
display: flex;
}
.o_web_studio_redo {
margin-left: 5px;
}
.o_web_studio_snackbar_icon {
margin-right: 3px;
}
.o_web_studio_snackbar_text {
overflow: hidden;
text-overflow: ellipsis;
font-weight: bold;
}
.o_web_studio_undo {
margin-right: 5px;
}
.o_web_studio_undo_redo_button {
display: flex;
align-items: center;
padding: 0;
margin: 0 3px;
}
.o_web_studio_undo_redo_separator {
display: flex;
align-items: center;
flex: 0 0 auto;
}
.o_web_studio_undo_redo_separator_line {
border-left: 1px solid $o-web-studio-text-disabled;
height: 1.3rem;
}
.o_web_studio_views_icons_after_separator {
width: $grid-gutter-width * 0.5;
}
.o_web_studio_views_icons_before_separator {
width: $grid-gutter-width * 0.5 + $grid-gutter-width * 0.3;
}
.o_web_studio_views_icons_separator {
border-left: 1px solid $o-web-studio-bg-dark;
height: 100%;
}
.o_web_studio_menu {
display: flex;
background-color: $o-web-studio-bg-medium;
text-align: center;
min-height: $o-web-studio-menu-height;
.o_web_studio_breadcrumb {
display: inline-flex;
flex: 0 1 $o-web-studio-sidebar-width;
&.o_web_studio_breadcrumb_report {
flex-basis: $o-web-studio-report-sidebar-width;
}
> ol {
background-color: inherit;
margin: 12px $o-horizontal-padding;
> li {
font-size: 0.8em;
text-transform: uppercase;
line-height: 1;
font-weight: bold;
color: $o-web-studio-text-inactive;
&.active:not(:first-child) {
color: $o-web-studio-text-light;
}
&.o_back_button:hover {
cursor: pointer;
color: white;
}
+ li:before {
font-weight: normal;
content: "\003E";
}
}
}
}
.o_web_studio_undo,
.o_web_studio_redo {
display: inline-flex;
i {
margin-right: 5px;
}
> button {
padding-left: 0;
background-color: $o-web-studio-bg-medium;
color: $o-web-studio-text-disabled;
outline: none;
box-shadow: none;
> span {
text-transform: uppercase;
font-weight: bold;
font-size: 0.8em;
}
}
&.o_web_studio_active > button {
color: $o-web-studio-text-inactive;
&:hover {
cursor: pointer;
color: $o-web-studio-text-light;
}
}
}
.o_web_studio_snackbar {
min-width: 0;
display: inline-flex;
align-items: center;
color: $o-web-studio-text-inactive;
}
.o_menu_sections {
display: flex;
margin: 0;
padding: 0;
list-style: none;
> li {
display: flex;
align-items: center;
position: relative;
> .dropdown-menu {
right: 0;
left: auto;
}
&:not(.o_web_studio_submenu_icons) {
&:hover,
&:active,
&:focus {
cursor: pointer;
outline: none;
border-color: lighten($o-brand-primary, 10%);
background-color: darken($o-web-studio-bg-medium, 2%);
> a {
color: white;
}
}
&.active {
border-color: $o-brand-primary;
> a {
outline: none;
color: $o-web-studio-text-light;
}
}
}
> a {
font-size: 0.9em;
display: block;
padding: 0 ($grid-gutter-width * 0.3);
color: $o-web-studio-text-inactive;
}
}
.o_web_studio_views_icons {
display: flex;
flex-flow: row wrap;
min-height: 100%;
> a {
display: flex;
align-items: center;
padding: 0 7px;
background-color: transparent;
color: $o-web-studio-text-inactive;
&:hover {
color: $o-web-studio-text-light;
}
}
}
}
}

View File

@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="web_studio.EditorMenu" owl="1">
<div class="o_web_studio_menu">
<div class="o_web_studio_breadcrumb o_web_studio_menu_item">
<ol class="breadcrumb">
<li t-foreach="breadcrumbs"
t-as="crumb"
t-key="crumb.id"
t-on-click="crumb.handler"
class="breadcrumb-item cursor-default"
t-att-class="{active: crumb_last, o_back_button: !crumb_last}"
t-esc="crumb.name"/>
</ol>
</div>
<div
t-if="studio.editedViewType or studio.editedReport"
class="
o_web_studio_menu_item
o_web_studio_menu_undo_redo">
<div class="o_web_studio_menu_before_undo_redo"/>
<div t-attf-class="o_web_studio_undo {{ state.undo_available ? 'o_web_studio_active' : '' }}" t-on-click="() => { this.studio.bus.trigger('undo_clicked'); }">
<button class="btn o_web_studio_undo_redo_button cursor-default"><i class="fa fa-undo"/><span>Undo</span></button>
</div>
<div class="o_web_studio_undo_redo_separator">
<div class="o_web_studio_undo_redo_separator_line"/>
</div>
<div t-attf-class="o_web_studio_redo {{ state.redo_available ? 'o_web_studio_active' : '' }}" t-on-click="() => { this.studio.bus.trigger('redo_clicked'); }">
<button class="btn o_web_studio_undo_redo_button cursor-default"><i class="fa fa-repeat"/><span>Redo</span></button>
</div>
</div>
<div
class="
o_web_studio_menu_before_snackbar
o_web_studio_menu_item"/>
<div
class="
o_web_studio_menu_item
o_web_studio_snackbar">
<t t-if="state.snackbar === 'saved'">
<i class="o_web_studio_snackbar_icon show fa fa-check"/>
<span class="o_web_studio_snackbar_text" t-esc="env._t('Saved')"/>
</t>
<t t-elif="state.snackbar === 'saving'">
<i class="o_web_studio_snackbar_icon show fa fa-circle-o-notch fa-spin"/>
<span class="o_web_studio_snackbar_text" t-esc="env._t('Saving')"/>
</t>
</div>
<div
class="
o_web_studio_menu_after_snackbar
o_web_studio_menu_item"/>
<div
class="
o_web_studio_menu_item
o_web_studio_menu_before_sections"/>
<ul class="o_menu_sections">
<li t-foreach="editorTabs" t-as="tab"
t-key="tab.id"
class="o_web_studio_menu_item" role="button"
t-on-click.prevent="() => this.openTab(tab.id)">
<a href="#" t-esc="tab.name"/>
</li>
<div class="
o_web_studio_views_icons_before_separator
o_web_studio_menu_item"/>
<div class="o_web_studio_views_icons_separator"/>
<div class="
o_web_studio_views_icons_after_separator
o_web_studio_menu_item"/>
<li class="
o_web_studio_menu_item
o_web_studio_submenu_icons">
<div class="o_web_studio_views_icons">
<a href="#" t-foreach="activeViews" t-as="view" t-key="view.type" t-att-title="view.title" t-att-aria-label="view.title"
t-on-click.prevent="() => { this.props.switchView({viewType: view.type}); }">
<i t-att-class="view.iconClasses" />
</a>
</div>
</li>
</ul>
</div>
</t>
</templates>

View File

@@ -0,0 +1,38 @@
/** @odoo-module */
import { NewViewDialog } from "@web_studio/client_action/editor/new_view_dialogs/new_view_dialog";
import { useService } from "@web/core/utils/hooks";
import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
export class MapNewViewDialog extends NewViewDialog {
setup() {
super.setup();
this.dialog = useService("dialog");
this.fieldsChoice = {
res_partner: null,
};
}
get viewType() {
return "map";
}
computeSpecificFields(fields) {
this.partnerFields = fields.filter(
(field) => field.type === "many2one" && field.relation === "res.partner"
);
if (!this.partnerFields.length) {
this.dialog.add(AlertDialog, {
body: this.env._t("Contact Field Required"),
contentClass: "o_web_studio_preserve_space",
});
this.props.close();
} else {
this.fieldsChoice.res_partner = this.partnerFields[0].name;
}
}
}
MapNewViewDialog.template = "web_studio.MapNewViewDialog";
MapNewViewDialog.props = {
...NewViewDialog.props,
};
delete MapNewViewDialog.props.viewType;

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="web_studio.MapNewViewDialog" owl="1" >
<Dialog size="'md'" title="title">
<div class="form-text text-muted">Select the contact field to use to get the coordinates of your records.</div>
<div class="o_web_studio_select mt-4">
<label for="res_partner">Contact Field</label>
<select name="res_partner" class="o_input" t-model="fieldsChoice.res_partner">
<option t-foreach="partnerFields" t-as="field" t-att-value="field.name" t-key="field.name">
<t t-esc="field.string"/><span t-if="env.debug"> (<t t-esc="field.name"/>)</span>
</option>
</select>
</div>
<t t-set-slot="footer" owl="1">
<button class="btn btn-primary" t-on-click="_confirm">
Activate View
</button>
<button class="btn btn-secondary" t-on-click="close">
Cancel
</button>
</t>
</Dialog>
</t>
</templates>

View File

@@ -0,0 +1,99 @@
/** @odoo-module */
import { useService } from "@web/core/utils/hooks";
import { sprintf } from "@web/core/utils/strings";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
const { onWillStart } = owl;
export class NewViewDialog extends ConfirmationDialog {
setup() {
super.setup();
this.orm = useService("orm");
this.rpc = useService("rpc");
this.studio = useService("studio");
this.user = useService("user");
this.mandatoryStopDate = ["gantt", "cohort"].includes(this.viewType);
this.title = sprintf(this.env._t("Generate %s View"), this.viewType);
this.fieldsChoice = {
date_start: null,
date_stop: null,
};
onWillStart(async () => {
const fieldsGet = await this.orm.call(this.studio.editedAction.res_model, "fields_get");
const fields = Object.entries(fieldsGet).map(([fName, field]) => {
field.name = fName;
return field;
});
fields.sort((first, second) => {
if (first.string === second.string) {
return 0;
}
if (first.string < second.string) {
return -1;
}
if (first.string > second.string) {
return 1;
}
});
this.computeSpecificFields(fields);
});
}
get viewType() {
return this.props.viewType;
}
/**
* Compute date, row and measure fields.
*/
computeSpecificFields(fields) {
this.dateFields = [];
this.rowFields = [];
this.measureFields = [];
fields.forEach((field) => {
if (field.store) {
// date fields
if (field.type === "date" || field.type === "datetime") {
this.dateFields.push(field);
}
// row fields
if (this.constructor.GROUPABLE_TYPES.includes(field.type)) {
this.rowFields.push(field);
}
// measure fields
if (this.constructor.MEASURABLE_TYPES.includes(field.type)) {
// id and sequence are not measurable
if (field.name !== "id" && field.name !== "sequence") {
this.measureFields.push(field);
}
}
}
});
if (this.dateFields.length) {
this.fieldsChoice.date_start = this.dateFields[0].name;
this.fieldsChoice.date_stop = this.dateFields[0].name;
}
}
async _confirm() {
await this.rpc("/web_studio/create_default_view", {
model: this.studio.editedAction.res_model,
view_type: this.viewType,
attrs: this.fieldsChoice,
context: this.user.context,
});
super._confirm();
}
}
NewViewDialog.template = "web_studio.NewViewDialog";
NewViewDialog.GROUPABLE_TYPES = ["many2one", "char", "boolean", "selection", "date", "datetime"];
NewViewDialog.MEASURABLE_TYPES = ["integer", "float"];
NewViewDialog.props = {
...ConfirmationDialog.props,
viewType: String,
};
delete NewViewDialog.props.body;

View File

@@ -0,0 +1,9 @@
.o_web_studio_new_view_modal {
.o_web_studio_new_view_dialog {
padding-top: 0px;
.o_web_studio_select {
margin-top: 12px;
}
}
}

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="web_studio.NewViewDialog" owl="1">
<Dialog size="'md'" title="title">
<div class="o_web_studio_new_view_dialog">
<div class="o_web_studio_select">
<label for="date_start">Start Date Field</label>
<select name="date_start" class="o_input" t-model="fieldsChoice.date_start">
<option t-foreach="dateFields" t-as="field" t-att-value="field.name" t-key="field.name">
<t t-esc="field.string"/><span t-if="env.debug"> (<t t-esc="field.name"/>)</span>
</option>
</select>
</div>
<div class="o_web_studio_select">
<label for="date_stop">Stop Date Field</label>
<select name="date_stop" class="o_input" t-model="fieldsChoice.date_stop">
<option t-if="!mandatoryStopDate" />
<option t-foreach="dateFields" t-as="field" t-att-value="field.name" t-key="field.name">
<t t-esc="field.string"/><span t-if="env.debug"> (<t t-esc="field.name"/>)</span>
</option>
</select>
</div>
</div>
<t t-set-slot="footer" owl="1">
<button class="btn btn-primary" t-on-click="_confirm">
Activate View
</button>
<button class="btn btn-secondary" t-on-click="close">
Cancel
</button>
</t>
</Dialog>
</t>
</templates>

View File

@@ -0,0 +1,195 @@
/** @odoo-module **/
import { ComponentAdapter } from "web.OwlCompatibility";
import ReportEditorManager from "web_studio.ReportEditorManager";
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
const { Component, xml } = owl;
class ReportEditorAdapter extends ComponentAdapter {
constructor(props) {
props.Component = ReportEditorManager;
super(...arguments);
}
setup() {
super.setup();
this.actionService = useService("action");
this.user = useService("user");
this.rpc = useService("rpc");
this.orm = useService("orm");
this.studio = useService("studio");
this.reportEnv = {};
this.env = Component.env;
}
get handle() {
return this.studio.editedReport;
}
async onWillStart() {
const proms = [];
await this._readReport();
await this._loadEnvironment();
proms.push(this._readModels());
proms.push(this._readWidgetsOptions());
proms.push(this._getReportViews());
proms.push(this._readPaperFormat());
await Promise.all(proms);
return super.onWillStart();
}
get widgetArgs() {
return [
{
env: this.reportEnv,
//initialState: state,
models: this.models,
paperFormat: this.paperFormat,
report: this.report,
reportHTML: this.reportViews.report_html,
reportMainViewID: this.reportViews.main_view_id,
reportViews: this.reportViews.views,
widgetsOptions: this.widgetsOptions,
},
];
}
/**
* Load and set the report environment.
*
* If the report is associated to the same model as the Studio action, the
* action ids will be used ; otherwise a search on the report model will be
* performed.
*
* @private
* @returns {Promise}
*/
async _loadEnvironment() {
this.reportEnv.modelName = this.report.model;
// TODO: Since 13.0, journal entries are also considered as 'account.move',
// therefore must filter result to remove them; otherwise not possible
// to print invoices and hard to lookup for them if lot of journal entries.
let domain = [];
if (this.report.model === "account.move") {
domain = [["move_type", "!=", "entry"]];
}
const result = await this.orm.search(this.report.model, domain, {
context: this.user.context,
});
this.reportEnv.ids = result;
this.reportEnv.currentId = this.reportEnv.ids && this.reportEnv.ids[0];
}
/**
* Read the models (ir.model) name and model to display them in a
* user-friendly way in the sidebar (see AbstractReportComponent).
*
* @private
* @returns {Promise}
*/
async _readModels() {
const models = await this.orm.searchRead(
"ir.model",
[
["transient", "=", false],
["abstract", "=", false],
],
["name", "model"],
{ context: this.user.context }
);
this.models = {};
models.forEach((model) => {
this.models[model.model] = model.name;
});
}
/**
* @private
* @returns {Promise}
*/
async _readReport() {
const result = await this.orm.read("ir.actions.report", [this.handle.res_id], undefined, {
context: this.user.context,
});
this.report = result[0];
}
/**
* @private
* @returns {Promise}
*/
async _readPaperFormat() {
this.paperFormat = "A4";
const result = await this.rpc("/web_studio/read_paperformat", {
report_id: this.handle.res_id,
context: this.user.context,
});
this.paperFormat = result[0];
}
/**
* Load the widgets options for t-options directive in sidebar.
*
* @private
* @returns {Promise}
*/
async _readWidgetsOptions() {
this.widgetsOptions = await this.rpc("/web_studio/get_widgets_available_options", {
context: this.user.context,
});
}
/**
* @private
* @returns {Promise<Object>}
*/
async _getReportViews() {
// SAD: FIXME calling this when there are no record for the model crashes (no currentId)
// used to show a danger notification
this.reportViews = await this.rpc("/web_studio/get_report_views", {
record_id: this.reportEnv.currentId,
report_name: this.report.report_name,
});
}
_trigger_up(ev) {
switch (ev.name) {
case "studio_edit_report":
this._editReport(ev.data);
break;
case "open_record_form_view":
this.actionService.doAction(
{
type: "ir.actions.act_window",
res_model: "ir.actions.report",
res_id: this.handle.res_id,
views: [[false, "form"]],
target: "current",
},
{ clearBreadcrumbs: true }
);
break;
}
super._trigger_up(...arguments);
}
/**
* @private
* @param {Object} values
* @returns {Promise}
*/
async _editReport(values) {
const result = await this.rpc("/web_studio/edit_report", {
report_id: this.report.id,
values: values,
context: this.user.context,
});
this.report = result[0];
this.render(true);
}
}
// We need this to wrap in a div
// ViewEditor doesn't need this because it extends AbstractEditor, and defines a template
export class ReportEditor extends Component {}
ReportEditor.template = xml`<div class="o_web_studio_client_action"><ReportEditorAdapter /></div>`;
ReportEditor.components = { ReportEditorAdapter };
registry.category("actions").add("web_studio.report_editor", ReportEditor);

View File

@@ -0,0 +1,192 @@
/** @odoo-module **/
import { COLORS, BG_COLORS, ICONS } from "@web_studio/utils";
import { FileInput } from "@web/core/file_input/file_input";
import CustomFileInput from "web.CustomFileInput";
import { useService } from "@web/core/utils/hooks";
const { Component, onWillUpdateProps, useRef, useState } = owl;
const DEFAULT_ICON = {
backgroundColor: BG_COLORS[5],
color: COLORS[4],
iconClass: ICONS[0],
};
/**
* Icon creator
*
* Component which purpose is to design an app icon. It can be an uploaded image
* which will be displayed as is, or an icon customized with the help of presets
* of colors and icon symbols (@see web_studio.utils for the full list of colors
* and icon classes).
* @extends Component
*/
export class IconCreator extends Component {
/**
* @param {Object} [props]
* @param {string} [props.backgroundColor] Background color of the custom
* icon.
* @param {string} [props.color] Color of the custom icon.
* @param {boolean} props.editable
* @param {string} [props.iconClass] Font Awesome class of the custom icon.
* @param {string} props.type 'base64' (if an actual image) or 'custom_icon'.
* @param {number} [props.uploaded_attachment_id] Databse ID of an uploaded
* attachment
* @param {string} [props.webIconData] Base64-encoded string representing
* the icon image.
*/
setup() {
this.COLORS = COLORS;
this.BG_COLORS = BG_COLORS;
this.ICONS = ICONS;
this.iconRef = useRef("app-icon");
// FIXME: for now, the IconCreator can be spawned in a pure wowl environment (by clicking
// on the 'edit' icon of an existing app) and in the legacy environment (through the app
// creator)
this.FileInput = FileInput;
this.fileInputProps = {
acceptedFileExtensions: "image/png",
resModel: "res.users",
};
try {
const user = useService("user");
this.orm = useService("orm");
this.fileInputProps.resId = user.userId;
} catch (e) {
if (e.message === "Service user is not available") {
// we are in a legacy environment, so use the legacy CustomFileInput as
// the new one requires the new http service
this.FileInput = CustomFileInput;
this.fileInputProps = {
accepted_file_extensions: "image/png",
action: "/web/binary/upload_attachment",
id: this.env.session.uid,
model: "res.users",
};
}
}
this.rpc = useService("rpc");
this.state = useState({ iconClass: this.props.iconClass });
this.show = useState({
backgroundColor: false,
color: false,
iconClass: false,
});
onWillUpdateProps((nextProps) => {
if (
this.constructor.enableTransitions &&
nextProps.iconClass !== this.props.iconClass
) {
this.applyIconTransition(nextProps.iconClass);
} else {
this.state.iconClass = nextProps.iconClass;
}
});
}
applyIconTransition(nextIconClass) {
const iconEl = this.iconRef.el;
if (!iconEl) {
return;
}
iconEl.classList.remove("o-fading-in");
iconEl.classList.remove("o-fading-out");
iconEl.onanimationend = () => {
this.state.iconClass = nextIconClass;
iconEl.onanimationend = () => {
iconEl.onanimationend = null;
iconEl.classList.remove("o-fading-in");
};
iconEl.classList.remove("o-fading-out");
iconEl.classList.add("o-fading-in");
};
iconEl.classList.add("o-fading-out");
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
onDesignIconClick() {
this.props.onIconChange({
type: "custom_icon",
...DEFAULT_ICON,
});
}
/**
* @param {Object[]} files
*/
async onFileUploaded([file]) {
if (!file) {
// Happens when cancelling upload
return;
}
let res;
if (this.orm) {
res = await this.orm.read("ir.attachment", [file.id], ["datas"]);
} else {
res = await this.rpc({
model: "ir.attachment",
method: "read",
args: [[file.id], ["datas"]],
});
}
this.props.onIconChange({
type: "base64",
uploaded_attachment_id: file.id,
webIconData: "data:image/png;base64," + res[0].datas.replace(/\s/g, ""),
});
}
/**
* @param {string} palette
* @param {string} value
*/
onPaletteItemClick(palette, value) {
if (this.props[palette] === value) {
return; // same value
}
this.props.onIconChange({
backgroundColor: this.props.backgroundColor,
color: this.props.color,
iconClass: this.props.iconClass,
type: "custom_icon",
[palette]: value,
});
}
/**
* @param {string} palette
*/
onTogglePalette(palette) {
for (const pal in this.show) {
if (pal === palette) {
this.show[pal] = !this.show[pal];
} else if (this.show[pal]) {
this.show[pal] = false;
}
}
}
}
IconCreator.defaultProps = DEFAULT_ICON;
IconCreator.props = {
backgroundColor: { type: String, optional: 1 },
color: { type: String, optional: 1 },
editable: { type: Boolean, optional: 1 },
iconClass: { type: String, optional: 1 },
type: { validate: (t) => ["base64", "custom_icon"].includes(t) },
uploaded_attachment_id: { type: Number, optional: 1 },
webIconData: { type: String, optional: 1 },
onIconChange: Function,
};
IconCreator.template = "web_studio.IconCreator";
IconCreator.enableTransitions = true;

View File

@@ -0,0 +1,208 @@
@keyframes appCreatorIconFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes appCreatorIconFadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.o_web_studio_icon_creator {
h6 {
color: $o-web-studio-text-inactive;
}
.o_web_studio_uploaded_image {
width: 128px;
height: 128px;
margin-bottom: 32px;
background-repeat: no-repeat;
background-position: center center;
background-size: cover;
}
.o_web_studio_icon {
float: left;
.o_app_icon {
width: 128px;
height: 128px;
box-shadow: 0 7px 16px -5px #000;
display: table-cell;
vertical-align: middle;
text-align: center;
border-radius: 6px;
transition: background 0.2s ease 0s;
> i {
padding-left: 2px;
font-size: 6em;
transition: color 0.2s ease 0s;
&.o-fading-in {
animation: appCreatorIconFadeIn 0.8s;
}
&.o-fading-out {
animation: appCreatorIconFadeOut 0.05s;
}
}
}
}
.o_web_studio_selectors {
float: right;
position: relative;
}
.o_web_studio_palette,
.o_web_studio_icons_library {
background-color: white;
border-radius: 3px;
overflow: hidden;
width: 225px;
padding: 5px;
box-shadow: 0 7px 16px -5px rgba(black, 0.4);
@include o-position-absolute(auto, auto, auto, 40px);
z-index: 1;
.o_web_studio_selector {
display: inline-block;
margin: 5px;
border-radius: 0;
border: 2px solid white;
width: 30px;
height: 30px;
transition-property: none;
&:before {
display: none;
}
&:hover {
box-shadow: 0 0 0 2px $o-brand-primary;
}
}
}
.o_web_studio_icons_library {
width: 360px;
.o_web_studio_selector {
font-size: 1.5em;
box-shadow: none;
}
}
.o_web_studio_selector {
$tmp-selector-size: 22px;
width: $tmp-selector-size;
height: $tmp-selector-size;
margin: 6px 0 27px 17px;
text-align: center;
border-radius: 3px;
border: 2px solid $o-web-studio-bg-dark;
box-shadow: 0 0 0 1px darken($o-web-studio-bg-light, 50%);
cursor: pointer;
position: relative;
transition: all 0.3s ease 0s;
transition-property: opacity, box-shadow;
background-color: white;
&:before {
opacity: 0.5;
content: "\f0d7";
width: 15px;
text-align: right;
font-family: "FontAwesome";
color: $o-web-studio-text-light;
@include o-position-absolute(1px, -15px);
}
.o_web_studio_selector_pointer {
@include o-position-absolute(($tmp-selector-size/2)-1, $tmp-selector-size);
display: block;
width: 30px;
border-top: 1px dotted white;
transition: all 0.3s ease 0s;
&:after {
content: "\B0";
font-weight: 100;
line-height: 0;
font-size: 50px;
color: white;
text-shadow: 0 0 1px $o-web-studio-bg-dark;
@include o-position-absolute(12px, auto, auto, -16px);
transition: all 0.3s ease 0s;
}
}
&:nth-child(2) {
.o_web_studio_selector_pointer {
width: 60px;
}
}
&:nth-child(3) {
.o_web_studio_selector_pointer {
width: 79px;
&:after {
content: "\f0d8";
font-family: "FontAwesome";
line-height: 0;
font-size: 18px;
@include o-position-absolute(-18px, auto, auto, -5px);
}
&:before {
content: "";
height: 13px;
border-left: 1px dotted white;
@include o-position-absolute(-13px, auto, auto, 0);
}
}
}
.o_web_studio_palette + .o_web_studio_selector_pointer {
opacity: 1;
border-top: 1px solid white;
&:before {
border-left-style: solid;
}
}
&:hover {
box-shadow: 0 0 0 1px $o-web-studio-bg-light;
.o_web_studio_selector_pointer {
border-top: 1px solid white;
&:before {
border-left-style: solid;
}
}
&:before,
.o_web_studio_selector_pointer {
opacity: 1;
}
}
}
}
.o_web_studio_app_creator_right {
.o_web_studio_selector_pointer {
opacity: 0;
}
&:hover .o_web_studio_selector_pointer {
opacity: 0.5;
}
}

View File

@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="web_studio.IconCreator" owl="1">
<div class="o_web_studio_icon_creator">
<div t-if="props.type === 'base64'" class="o_web_studio_uploaded_image"
t-attf-style="background-image: url({{props.webIconData}})"
/>
<t t-else="">
<h6 t-if="props.editable">Design your Icon</h6>
<div class="o_web_studio_icon">
<div class="o_app_icon" t-attf-style="background-color: {{props.backgroundColor}};">
<i t-att-class="state.iconClass"
t-attf-style="color: {{props.color}};"
t-ref="app-icon"
/>
</div>
</div>
<t t-if="props.editable">
<div class="o_web_studio_selectors">
<div class="o_web_studio_selector"
t-attf-style="background-color: {{props.backgroundColor}};"
t-on-click="() => this.onTogglePalette('backgroundColor')"
>
<span class="o_web_studio_selector_pointer" />
<div t-if="show.backgroundColor" class="o_web_studio_palette"
t-on-click.stop=""
t-on-mouseleave="() => show.backgroundColor = false"
>
<div t-foreach="BG_COLORS" t-as="backgroundColor"
t-key="backgroundColor_index"
class="o_web_studio_selector"
t-attf-style="background-color: {{backgroundColor}}"
t-on-click="() => this.onPaletteItemClick('backgroundColor', backgroundColor)"
/>
</div>
</div>
<div class="o_web_studio_selector"
t-attf-style="background-color: {{props.color}};"
t-on-click="() => this.onTogglePalette('color')"
>
<span class="o_web_studio_selector_pointer" />
<div t-if="show.color" class="o_web_studio_palette"
t-on-click.stop=""
t-on-mouseleave="() => show.color = false"
>
<div t-foreach="COLORS" t-as="color"
t-key="color_index"
class="o_web_studio_selector"
t-attf-style="background-color: {{color}}"
t-on-click="() => this.onPaletteItemClick('color', color)"
/>
</div>
</div>
<div class="o_web_studio_selector" t-on-click="() => this.onTogglePalette('iconClass')">
<i t-att-class="state.iconClass" />
<span class="o_web_studio_selector_pointer" />
<div t-if="show.iconClass" class="o_web_studio_icons_library"
t-on-click.stop=""
t-on-mouseleave="() => show.iconClass = false"
>
<div t-foreach="ICONS" t-as="iconClass"
t-key="iconClass_index"
class="o_web_studio_selector"
t-on-click="() => this.onPaletteItemClick('iconClass', iconClass)"
>
<i t-att-class="iconClass" />
</div>
</div>
</div>
</div>
<div class="clearfix" />
</t>
</t>
<div t-if="props.editable" class="o_web_studio_upload">
<a t-if="props.type === 'base64'" href="#" t-on-click.prevent.stop="onDesignIconClick">
Design your Icon
</a>
<span class="text-muted"> or </span>
<t t-component="FileInput" t-props="fileInputProps" onUpload.bind="onFileUploaded">
<a href="#">
<t t-if="props.type === 'base64'">upload one</t>
<t t-else="">upload it</t>
</a>
</t>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,119 @@
// =========== Colours ===========
$o-web-studio-color-danger: #e6586c;
$o-web-studio-color-warning: #f0ad4e;
$o-web-studio-color-success: #40ad67;
$o-web-studio-color-info: #6999a8;
$o-web-studio-bg-lighter: #f2f2f2;
$o-web-studio-bg-light: #eaeaea;
$o-web-studio-bg-medium: #3a3f46;
$o-web-studio-bg-dark: #262c34;
$o-web-studio-bg-darker: darken($o-web-studio-bg-dark, 2%);
$o-web-studio-text-light: $o-web-studio-bg-light;
$o-web-studio-text-inactive: darken($o-web-studio-text-light, 20%);
$o-web-studio-text-disabled: gray;
$o-web-studio-text-dark: #15161a;
// =========== Fonts ===========
$o-web-studio-font-family-monospace: Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro,
monospace;
// =========== Shadows ===========
$o-web-studio-shadow-base: 0 0px 26px -12px;
$o-web-studio-shadow-active: 0 10px 26px -12px;
// =========== Fonts ===========
$o-web-studio-font-family-monospace: Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro,
monospace;
// =========== Layout ===========
$o-web-studio-sidebar-width: 300px;
$o-web-studio-report-sidebar-width: 350px;
$o-web-studio-sidebar-margin: $grid-gutter-width * 0.5;
$o-web-studio-view-type-width: 115px;
$o-web-studio-menu-height: $o-navbar-height;
// =========== Menu ===========
$o-web-studio-menu-item-border-width: 3px;
// Dialog
.o_web_client.o_in_studio .modal {
.modal-header,
.modal-footer {
background-color: lighten($o-web-studio-bg-light, 7%);
}
.btn-primary {
background-color: $o-brand-primary;
}
.btn-danger {
background-color: $o-web-studio-color-danger;
}
.btn-secondary {
color: $o-web-studio-bg-medium;
}
.o_field_many2one {
width: 100%;
}
// Edit menu modal
.o_web_studio_edit_menu_modal {
> ul {
padding: 1em 0 0;
> li:first-child > div .js_menu_label {
font-weight: bold;
}
li {
margin-top: -1px;
}
ul {
padding-left: 30px;
}
.form-control {
box-shadow: none;
}
.btn-primary {
border: 1px solid;
border-color: darken($o-brand-primary, 10%) $o-brand-primary;
}
.btn-danger {
border: 1px solid;
border-color: darken($o-web-studio-color-danger, 10%) $o-web-studio-color-danger;
}
}
}
// Edit menu icon modal
.o_web_studio_edit_menu_icon_modal .modal-body {
border-radius: 0 3px 3px 0;
display: flex;
justify-content: center;
align-items: center;
overflow: visible !important;
background-color: $o-web-studio-bg-dark;
}
// New menu item modal
&.o_web_studio_add_menu_modal {
background: rgba(black, 0.5);
.o_field_widget {
width: 100%;
}
.modal-dialog {
margin-top: $grid-gutter-width;
}
}
.o_web_studio_preserve_space {
white-space: pre;
}
}

View File

@@ -0,0 +1,177 @@
@mixin o-web-studio-checkbox(
$label-color: $o-web-studio-text-inactive,
$label-color-active: $o-web-studio-text-light,
$label-color-hover: $o-web-studio-text-light,
$unchecked-color: $o-web-studio-bg-dark,
$unchecked-border: $o-web-studio-bg-dark
) {
label {
cursor: pointer;
font-size: 12px;
color: $label-color;
&:active {
color: $label-color-active;
}
&:hover {
color: $label-color-hover;
}
}
.form-check {
> input {
padding: 6px 6px;
margin-top: 6px;
&:not(:checked) {
border: 1px solid $unchecked-border;
background-color: $o-white;
}
&:checked {
background-image: url("/web_studio/static/src/img/ui/checkbox_active.svg");
}
}
&.o_web_studio_checkbox_inactive > input {
background-image: url("/web_studio/static/src/img/ui/checkbox_indeterminate.svg");
}
}
}
@mixin o-web-studio-select($top: 29px, $bottom: 37px) {
position: relative;
select,
select:active,
select:focus {
color: $o-web-studio-text-inactive;
background-image: none;
box-shadow: none;
border-color: $o-web-studio-bg-dark;
}
&:after,
&:before {
color: $o-web-studio-text-inactive;
font-family: "FontAwesome";
font-size: 8px;
content: "\f077";
@include o-position-absolute($top, 8px);
}
&:after {
content: "\f078";
@include o-position-absolute($bottom, 8px);
}
&:hover {
&:after,
&:before {
color: $o-web-studio-text-light;
}
}
}
@mixin o-web-studio-btn-variation($normal-color, $active-color) {
border-color: $normal-color;
background-color: $normal-color;
color: $o-web-studio-bg-light;
&:hover,
&:active,
&:focus,
&.active {
background-color: $active-color;
border-color: $active-color;
color: white;
}
}
@mixin o-web-studio-btn {
font-size: 0.8em;
color: $o-web-studio-bg-light;
text-transform: uppercase;
&.btn-secondary {
@include o-web-studio-btn-variation($o-web-studio-bg-dark, $o-web-studio-bg-darker);
}
&.btn-primary {
@include o-web-studio-btn-variation($o-brand-primary, darken($o-brand-primary, 5%));
}
&.btn-danger {
@include o-web-studio-btn-variation(
$o-web-studio-color-danger,
darken($o-web-studio-color-danger, 5%)
);
}
&.btn-warning {
@include o-web-studio-btn-variation(
$o-web-studio-color-warning,
darken($o-web-studio-color-warning, 5%)
);
}
&.btn-dark {
@include o-web-studio-btn-variation(
$o-web-studio-bg-medium,
darken($o-web-studio-bg-medium, 5%)
);
}
}
@mixin o-web-studio-sidebar-btn-link($color: $o-enterprise-primary-color) {
color: $color;
&:hover {
color: darken($color, 5%);
}
}
@mixin o-web-studio-thumbnails-container {
width: 100%;
height: 100%;
overflow: auto;
background-color: $o-web-studio-bg-light;
}
@mixin o-web-studio-thumbnails {
padding-top: 20px;
overflow: auto;
display: flex;
align-content: flex-start;
align-items: flex-start;
flex-flow: row wrap;
}
@mixin o-web-studio-thumbnail {
display: inline-flex;
justify-content: center;
align-items: baseline;
float: left;
position: relative;
height: 140px;
width: $o-web-studio-view-type-width;
background-color: white;
border: 1px solid white;
border-radius: 2px;
box-shadow: $o-web-studio-shadow-base;
&:hover {
box-shadow: $o-web-studio-shadow-active;
}
.o_web_studio_thumbnail {
height: 115px;
width: 100%;
cursor: pointer;
display: flex;
img {
width: 60px;
margin: auto;
align-self: center;
}
}
.o_web_studio_name {
@include o-position-absolute($bottom: 10px, $left: 10px);
font-weight: 500;
font-size: 12px;
color: $o-web-studio-text-dark;
text-transform: capitalize;
}
}
@mixin o-web-studio-app-creator-background {
background-image: linear-gradient(90deg, #565d78, #65545c);
}

View File

@@ -0,0 +1,220 @@
/** @odoo-module **/
import config from "web.config";
import Dialog from "web.Dialog";
import { ComponentWrapper, WidgetAdapterMixin } from "web.OwlCompatibility";
import { _t } from "web.core";
const { Component, useState } = owl;
export class ModelConfigurator extends Component {
setup() {
this.state = useState({
/** You might wonder why I defined all these strings here and not in the template.
* The reason is that I wanted clear templates that use a single element to render an option,
* meaning that the label and helper text had to be defined here in the code.
*/
options: {
use_partner: {
label: this.env._t("Contact details"),
help: this.env._t("Get contact, phone and email fields on records"),
value: false,
},
use_responsible: {
label: this.env._t("User assignment"),
help: this.env._t("Assign a responsible to each record"),
value: false,
},
use_date: {
label: this.env._t("Date & Calendar"),
help: this.env._t("Assign dates and visualize records in a calendar"),
value: false,
},
use_double_dates: {
label: this.env._t("Date range & Gantt"),
help: this.env._t(
"Define start/end dates and visualize records in a Gantt chart"
),
value: false,
},
use_stages: {
label: this.env._t("Pipeline stages"),
help: this.env._t("Stage and visualize records in a custom pipeline"),
value: false,
},
use_tags: {
label: this.env._t("Tags"),
help: this.env._t("Categorize records with custom tags"),
value: false,
},
use_image: {
label: this.env._t("Picture"),
help: this.env._t("Attach a picture to a record"),
value: false,
},
lines: {
label: this.env._t("Lines"),
help: this.env._t("Add details to your records with an embedded list view"),
value: false,
},
use_notes: {
label: this.env._t("Notes"),
help: this.env._t("Write additional notes or comments"),
value: false,
},
use_value: {
label: this.env._t("Monetary value"),
help: this.env._t("Set a price or cost on records"),
value: false,
},
use_company: {
label: this.env._t("Company"),
help: this.env._t("Restrict a record to a specific company"),
value: false,
},
use_sequence: {
label: this.env._t("Custom Sorting"),
help: this.env._t("Manually sort records in the list view"),
value: true,
},
use_mail: {
label: this.env._t("Chatter"),
help: this.env._t("Send messages, log notes and schedule activities"),
value: true,
},
use_active: {
label: this.env._t("Archiving"),
help: this.env._t("Archive deprecated records"),
value: true,
},
},
saving: false,
});
this.multiCompany = this.env.session.display_switch_company_menu;
}
/**
* Handle the confirmation of the dialog, just fires an event
* to whoever instanciated it.
*/
onConfirm() {
this.props.onConfirmOptions({ ...this.state.options });
this.state.saving = true;
}
}
class ModelConfiguratorOption extends Component {}
ModelConfigurator.template = "web_studio.ModelConfigurator";
ModelConfigurator.components = { ModelConfiguratorOption };
ModelConfigurator.props = {
debug: { type: Boolean, optional: true },
embed: { type: Boolean, optional: true },
label: { type: String },
onConfirmOptions: Function,
onPrevious: Function,
};
ModelConfiguratorOption.template = "web_studio.ModelConfiguratorOption";
ModelConfiguratorOption.props = {
name: String,
option: {
type: Object,
shape: {
label: String,
debug: {
type: Boolean,
optional: true,
},
help: String,
value: Boolean,
},
},
};
/**
* Wrapper to make the ModelConfigurator usable as a standalone dialog. Used notably
* by the 'NewMenuDialog' in Studio. Note that since the ModelConfigurator does not
* have its own modal, I choose to use the classic Dialog and use it as an adapter
* instead of using an owlDialog + another adapter on top of it. Don't @ me.
*
* I've taken a few liberties with the standard Dialog: removed the footer
* (there's no need for it, the modelconfigurator has its own footer), it's a single
* size, etc. Nothing crazy.
*/
export const ModelConfiguratorDialog = Dialog.extend(WidgetAdapterMixin, {
/**
* @override
*/
init(parent, options) {
const res = this._super.apply(this, arguments);
this.renderFooter = false;
(this.title = _t("Suggested features for your new model")),
(this.confirmLabel = options.confirmLabel);
this.onForceClose = () => this.trigger_up("cancel_options");
return res;
},
/**
* Owl Wrapper override, as described in web.OwlCompatibility
* @override
*/
async start() {
const res = await this._super.apply(this, arguments);
this.component = new ComponentWrapper(this, ModelConfigurator, {
label: this.confirmLabel,
embed: true,
debug: Boolean(config.isDebug()),
onPrevious: this.onPrevious.bind(this),
onConfirmOptions: (payload) => this.trigger_up("confirm_options", payload),
});
this.component.mount(this.el);
return res;
},
/**
* Proper handler calling since Dialog doesn't seem to do it
* @override
*/
close() {
this.on_detach_callback();
return this._super.apply(this, arguments);
},
/**
* Needed because of the WidgetAdapterMixin
* @override
*/
destroy() {
WidgetAdapterMixin.destroy.call(this);
return this._super();
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @override
*/
on_attach_callback() {
WidgetAdapterMixin.on_attach_callback.call(this);
return this._super.apply(this, arguments);
},
/**
* @override
*/
on_detach_callback() {
WidgetAdapterMixin.on_detach_callback.call(this);
return this._super.apply(this, arguments);
},
/**
* Handle the 'previous' button, which in this case should close the Dialog.
* @private
*/
onPrevious() {
this.trigger_up("cancel_options");
this.close();
},
});

View File

@@ -0,0 +1,75 @@
// App creator - layout
.o_web_studio_model_configurator {
min-height: 350px;
margin: auto;
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
background-color: white;
h1 {
font-size: 1.5rem;
margin: 0 -16px 16px -16px;
padding: 0 16px 8px 16px;
border-bottom: 1px solid $modal-content-border-color;
width: calc(100% + 64px);
}
h2 {
padding: 0.25rem 16px;
background-color: #e9ecef;
font-size: 15px;
font-weight: bold;
width: calc(100% + 64px);
margin: 16px -16px;
}
.form-check > .form-check-label {
padding-left: 0.6rem;
border-left: 1px solid $o-web-studio-text-inactive;
}
/* embedded in Dialog with in the EditMenu
leave the Dialog handle width and padding */
&:not(.embed) {
width: map-get($container-max-widths, lg);
padding: 16px 32px;
box-shadow: 0 16px 13px -8px rgba($o-web-studio-bg-dark, 0.5);
}
.o_web_studio_model_configurator_buttons {
display: flex;
justify-content: space-between;
margin-top: 16px;
// common to both buttons
.o_web_studio_model_configurator_next,
.o_web_studio_model_configurator_previous {
border-radius: 2px;
padding: 7px 15px;
}
.o_web_studio_model_configurator_previous {
background-color: white;
border: 1px solid $o-web-studio-text-inactive;
color: $o-web-studio-text-inactive;
}
.o_web_studio_model_configurator_next {
background-color: $o-brand-primary;
border-color: darken($o-brand-primary, 3%);
color: white;
> span {
padding-right: 15px;
}
&:hover,
&:focus {
background-color: lighten($o-brand-primary, 2%);
border-color: $o-brand-primary;
color: white;
}
}
}
}
.o_web_studio_new_model_modal {
label.o_studio_error {
color: $o-web-studio-color-danger;
}
}

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="web_studio.ModelConfigurator" owl="1">
<div class="container o_web_studio_model_configurator" t-att-class="{ embed: props.embed }">
<div class="row" t-if="!props.embed">
<h1>Suggested features for your new model</h1>
</div>
<div class="row">
<t t-foreach="state.options" t-as="option_name" t-key="option_name">
<ModelConfiguratorOption t-if="!state.options[option_name].debug and (option_name !== 'use_company' || multiCompany)"
name="option_name"
option="state.options[option_name]"
/>
</t>
</div>
<div class="o_web_studio_model_configurator_buttons">
<button type="button"
class="btn fa fa-chevron-left o_web_studio_model_configurator_previous"
t-att-class="{ disabled: state.saving }"
aria-label="Previous"
title="Previous"
t-on-click="props.onPrevious"
/>
<button type="button"
class="btn o_web_studio_model_configurator_next btn-primary"
t-att-class="{ disabled: state.saving }"
aria-label="Next"
title="Next"
t-on-click="onConfirm"
t-att-disabled="state.saving"
>
<span t-esc="props.label"/>
<i
class="fa"
t-att-class="{'fa-chevron-right': !state.saving, 'fa-circle-o-notch': state.saving, 'fa-spin': state.saving}"
/>
</button>
</div>
</div>
</t>
<t t-name="web_studio.ModelConfiguratorOption" owl="1">
<div class="col-6">
<div class="form-check my-3 o_web_studio_model_configurator_option">
<input type="checkbox" class="form-check-input" t-att-id="props.name" t-att-name="props.name" t-model="props.option.value"/>
<label t-att-for="props.name" class="form-check-label">
<strong t-esc="props.option.label"/>
<div class="form-text text-muted" t-esc="props.option.help"/>
</label>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,97 @@
/** @odoo-module */
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { useService } from "@web/core/utils/hooks";
import { browser } from "@web/core/browser/browser";
import { download } from "@web/core/network/download";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { FileInput } from "@web/core/file_input/file_input";
const { Component } = owl;
export class HomeMenuCustomizer extends Component {
setup() {
this.rpc = useService("rpc");
this.ui = useService("ui");
this.notification = useService("notification");
this.company = useService("company");
this.user = useService("user");
this.actionManager = useService("action");
this.menus = useService("menu");
this.dialogManager = useService("dialog");
}
setBackgroundImage(attachment_id) {
return this.rpc("/web_studio/set_background_image", {
attachment_id: attachment_id,
context: this.user.context,
});
}
/**
* Export all customizations done by Studio in a zip file containing Odoo
* modules.
*/
exportCusto() {
download({ url: "/web_studio/export", data: {} });
}
/**
* Open a dialog allowing to import new modules
* (e.g. exported customizations).
*/
importCusto() {
const action = {
name: "Import modules",
res_model: "base.import.module",
views: [[false, "form"]],
type: "ir.actions.act_window",
target: "new",
context: {
dialog_size: "medium",
},
};
const options = {
onClose: () => this.menus.reload(),
};
this.actionManager.doAction(action, options);
}
async confirmReset() {
this.ui.block();
try {
await this.rpc("/web_studio/reset_background_image", {
context: this.user.context,
});
browser.location.reload();
} finally {
this.ui.unblock();
}
}
resetBackground() {
this.dialogManager.add(ConfirmationDialog, {
body: this.env._t("Are you sure you want to reset the background image?"),
title: this.env._t("Confirmation"),
confirm: () => this.confirmReset(),
});
}
async onBackgroundUpload([file]) {
if (!file) {
this.notification.add(this.env._t("Could not change the background"), {
sticky: true,
type: "warning",
});
} else {
this.ui.block();
try {
await this.setBackgroundImage(file.id);
browser.location.reload();
} finally {
this.ui.unblock();
}
}
}
}
HomeMenuCustomizer.template = "web_studio.HomeMenuCustomizer";
HomeMenuCustomizer.components = { Dropdown, DropdownItem, FileInput };

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web_studio.HomeMenuCustomizer" owl="1">
<div class="o_web_studio_home_studio_menu">
<Dropdown>
<t t-set-slot="toggler">
Customizations
</t>
<DropdownItem class="'o_web_studio_change_background'" parentClosingMode="'none'">
<FileInput
acceptedFileExtensions="'image/*'"
onUpload.bind="onBackgroundUpload"
resId="company.currentCompany.id"
resModel="'res.company'"
>
Change Background
</FileInput>
</DropdownItem>
<DropdownItem t-if="menus.getMenu('root').backgroundImage"
class="'o_web_studio_reset_default_background'"
onSelected.bind="resetBackground"
>
Reset Default Background
</DropdownItem>
<DropdownItem class="'o_web_studio_import'" onSelected.bind="importCusto">
Import
</DropdownItem>
<DropdownItem class="'o_web_studio_export'" onSelected.bind="exportCusto">
Export
</DropdownItem>
</Dropdown>
</div>
</t>
</templates>

View File

@@ -0,0 +1,64 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { EnterpriseNavBar } from "@web_enterprise/webclient/navbar/navbar";
import { NotEditableActionError } from "../../studio_service";
import { HomeMenuCustomizer } from "./home_menu_customizer/home_menu_customizer";
import { EditMenuItem } from "../../legacy/edit_menu_adapter";
import { NewModelItem } from "@web_studio/legacy/new_model_adapter";
const { onMounted } = owl;
export class StudioNavbar extends EnterpriseNavBar {
setup() {
super.setup();
this.studio = useService("studio");
this.actionManager = useService("action");
this.user = useService("user");
this.dialogManager = useService("dialog");
this.notification = useService("notification");
onMounted(() => {
this.env.bus.off("HOME-MENU:TOGGLED", this);
this._updateMenuAppsIcon();
});
}
onMenuToggle() {
this.studio.toggleHomeMenu();
}
closeStudio() {
this.studio.leave();
}
async onNavBarDropdownItemSelection(menu) {
if (menu.actionID) {
try {
await this.studio.open(this.studio.MODES.EDITOR, menu.actionID);
} catch (e) {
if (e instanceof NotEditableActionError) {
const options = { type: "danger" };
this.notification.add(
this.env._t("This action is not editable by Studio"),
options
);
return;
}
throw e;
}
}
}
get hasBackgroundAction() {
return this.studio.editedAction || this.studio.MODES.APP_CREATOR === this.studio.mode;
}
get isInApp() {
return this.studio.mode === this.studio.MODES.EDITOR;
}
_onNotesClicked() {
// LPE fixme: dbuuid should be injected into session_info python side
// LPE Fixme: this could be either the local AM or the GlobalAM
// we don(t care i-here as we open an url anyway)
this.actionManager.doAction(action);
}
}
StudioNavbar.template = "web_studio.StudioNavbar";
StudioNavbar.components.HomeMenuCustomizer = HomeMenuCustomizer;
StudioNavbar.components.EditMenuItem = EditMenuItem;
StudioNavbar.components.NewModelItem = NewModelItem;

View File

@@ -0,0 +1,57 @@
.o_studio_navbar {
background: $o-web-studio-bg-dark;
border-color: $o-web-studio-bg-dark;
&.o_main_navbar {
grid-template-areas: "apps brand sections studiomenu systray";
grid-template-columns:
minmax($o-navbar-height, max-content)
max-content
minmax($o-navbar-height, max-content)
minmax(max-content, 1fr)
fit-content(100%)
}
.o_menu_sections {
color: lightgrey;
height: $o-web-studio-menu-height - 1;
}
.o-studio--menu {
grid-area: studiomenu;
display: flex;
}
.o_web_edit_menu,
.o_web_create_new_model {
color: $o-web-studio-text-inactive;
margin-left: 15px;
padding: 2px 10px 2px 10px;
font-size: 12px;
}
.o_studio_buttons {
grid-area: systray;
display: flex;
.o_web_studio_notes,
.o_web_studio_leave {
height: 100%;
display: table;
margin: 0 20px;
> a {
color: $o-web-studio-bg-light;
display: table-cell;
vertical-align: middle;
}
}
.o_web_studio_leave {
margin: 0;
> a {
font-weight: bold;
}
}
}
}

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web_studio.StudioNavbar" t-inherit="web_enterprise.EnterpriseNavBar" t-inherit-mode="primary">
<xpath expr="//nav" position="attributes">
<attribute name="class" add="o_studio_navbar" separator=" "/>
</xpath>
<xpath expr="//a[contains(@class, 'o_menu_toggle')]" position="attributes">
<attribute name="t-on-click.prevent">onMenuToggle</attribute>
</xpath>
<xpath expr='//nav' position="inside">
<HomeMenuCustomizer t-if="studio.mode === studio.MODES.HOME_MENU"/>
<t t-call="web_studio.menuButtons" />
<t t-call="web_studio.masterButtons" />
</xpath>
<xpath expr="//div[contains(@class, 'o_menu_systray')]" position="replace"/>
</t>
<t t-name="web_studio.menuButtons" owl="1">
<div class="o-studio--menu ms-auto">
<t t-if="studio.mode === studio.MODES.EDITOR" >
<EditMenuItem />
<NewModelItem />
</t>
</div>
</t>
<t t-name="web_studio.masterButtons" owl="1">
<div class="o_studio_buttons">
<div ref="studioNotes" class="o_web_studio_notes">
<a href="#" t-on-click.prevent="_onNotesClicked">
Notes
</a>
</div>
<div class="o_web_studio_leave" t-on-click="closeStudio">
<a class="btn btn-primary">Close</a>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,14 @@
/** @odoo-module **/
import { ActionContainer } from "@web/webclient/actions/action_container";
import { useService } from "@web/core/utils/hooks";
export class StudioActionContainer extends ActionContainer {
setup() {
super.setup();
this.actionService = useService("action");
if (this.props.initialAction) {
this.actionService.doAction(this.props.initialAction);
}
}
}

View File

@@ -0,0 +1,22 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { LazyComponent } from "@web/core/assets";
import { loadLegacyViews } from "@web/legacy/legacy_views";
import { loadWysiwyg } from "web_editor.loader";
import { useService } from "@web/core/utils/hooks";
const { Component, onWillStart, xml } = owl;
class StudioActionLoader extends Component {
setup() {
this.orm = useService("orm");
onWillStart(loadWysiwyg);
onWillStart(() => loadLegacyViews({ orm: this.orm }));
}
}
StudioActionLoader.components = { LazyComponent };
StudioActionLoader.template = xml`
<LazyComponent bundle="'web_studio.studio_assets'" Component="'StudioClientAction'" props="props"/>
`;
registry.category("actions").add("studio", StudioActionLoader);

View File

@@ -0,0 +1,88 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useBus, useService } from "@web/core/utils/hooks";
import { cleanDomFromBootstrap } from "@web/legacy/utils";
import { computeAppsAndMenuItems } from "@web/webclient/menus/menu_helpers";
import { ComponentAdapter } from "web.OwlCompatibility";
import { AppCreatorWrapper } from "./app_creator/app_creator";
import { Editor } from "./editor/editor";
import { StudioNavbar } from "./navbar/navbar";
import { StudioHomeMenu } from "./studio_home_menu/studio_home_menu";
const { Component, onWillStart, onMounted, onPatched, onWillUnmount } = owl;
export class StudioClientAction extends Component {
setup() {
this.studio = useService("studio");
useBus(this.studio.bus, "UPDATE", () => {
this.render(true);
cleanDomFromBootstrap();
});
this.menus = useService("menu");
this.actionService = useService("action");
this.homeMenuProps = {
apps: computeAppsAndMenuItems(this.menus.getMenuAsTree("root")).apps,
};
useBus(this.env.bus, "MENUS:APP-CHANGED", () => {
this.homeMenuProps = {
apps: computeAppsAndMenuItems(this.menus.getMenuAsTree("root")).apps,
};
this.render(true);
});
this.AppCreatorWrapper = AppCreatorWrapper; // to remove
onWillStart(this.onWillStart);
onMounted(this.onMounted);
onPatched(this.onPatched);
onWillUnmount(this.onWillUnmount);
}
onWillStart() {
return this.studio.ready;
}
onMounted() {
this.studio.pushState();
document.body.classList.add("o_in_studio"); // FIXME ?
}
onPatched() {
this.studio.pushState();
}
onWillUnmount() {
document.body.classList.remove("o_in_studio");
}
async onNewAppCreated({ action_id, menu_id }) {
await this.menus.reload();
this.menus.setCurrentMenu(menu_id);
const action = await this.actionService.loadAction(action_id);
this.studio.setParams({
mode: this.studio.MODES.EDITOR,
editorTab: "views",
action,
viewType: "form",
});
}
}
StudioClientAction.template = "web_studio.StudioClientAction";
StudioClientAction.components = {
StudioNavbar,
StudioHomeMenu,
Editor,
ComponentAdapter: class extends ComponentAdapter {
setup() {
super.setup();
this.env = Component.env;
}
},
};
StudioClientAction.target = "fullscreen";
registry.category("lazy_components").add("StudioClientAction", StudioClientAction);
// force: true to bypass the studio lazy loading action next time and just use this one directly
registry.category("actions").add("studio", StudioClientAction, { force: true });

View File

@@ -0,0 +1,3 @@
.o_studio {
height: 100%;
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="web_studio.StudioClientAction" owl="1">
<div class="o_studio">
<StudioNavbar />
<StudioHomeMenu t-if="studio.mode === studio.MODES.HOME_MENU" apps="homeMenuProps.apps" />
<ComponentAdapter t-elif="studio.mode === studio.MODES.APP_CREATOR"
Component="AppCreatorWrapper"
props="{ onNewAppCreated: (result) => this.onNewAppCreated(result) }"
/>
<Editor t-elif="studio.mode === studio.MODES.EDITOR" />
</div>
</t>
</templates>

View File

@@ -0,0 +1,71 @@
/** @odoo-module **/
import { Dialog } from "@web/core/dialog/dialog";
import { _lt } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { IconCreator } from "@web_studio/client_action/icon_creator/icon_creator";
const { Component, useState } = owl;
export class IconCreatorDialog extends Component {
setup() {
this.user = useService("user");
this.rpc = useService("rpc");
this.menus = useService("menu");
this.initialAppData = { ...this.props.editedAppData };
this.editedAppData = useState(this.props.editedAppData);
}
/**
* @param {Object} icon
*/
onIconChanged(icon) {
for (const key in this.editedAppData) {
delete this.editedAppData[key];
}
for (const key in icon) {
this.editedAppData[key] = icon[key];
}
}
async saveIcon() {
const { type } = this.initialAppData;
const appId = this.props.appId;
let iconValue;
if (this.editedAppData.type !== type) {
// different type
if (this.editedAppData.type === "base64") {
iconValue = this.editedAppData.uploaded_attachment_id;
} else {
const { iconClass, color, backgroundColor } = this.editedAppData;
iconValue = [iconClass, color, backgroundColor];
}
} else if (this.editedAppData.type === "custom_icon") {
// custom icon changed
const { iconClass, color, backgroundColor } = this.editedAppData;
if (
this.initialAppData.iconClass !== iconClass ||
this.initialAppData.color !== color ||
this.initialAppData.backgroundColor !== backgroundColor
) {
iconValue = [iconClass, color, backgroundColor];
}
} else if (this.editedAppData.uploaded_attachment_id) {
// new attachment
iconValue = this.editedAppData.uploaded_attachment_id;
}
if (iconValue) {
await this.rpc("/web_studio/edit_menu_icon", {
context: this.user.context,
icon: iconValue,
menu_id: appId,
});
await this.menus.reload();
}
this.props.close();
}
}
IconCreatorDialog.title = _lt("Edit Application Icon");
IconCreatorDialog.template = "web_studio.IconCreatorDialog";
IconCreatorDialog.components = { Dialog, IconCreator };

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="web_studio.IconCreatorDialog" owl="1">
<Dialog title="this.constructor.title" contentClass="'o_web_studio_edit_menu_icon_modal'" size="'md'">
<IconCreator editable="true" t-props="props.editedAppData" onIconChange.bind="onIconChanged"/>
<t t-set-slot="footer">
<button class="btn btn-primary" t-on-click="saveIcon">Confirm</button>
<button class="btn btn-secondary" t-on-click="props.close">Cancel</button>
</t>
</Dialog>
</t>
</templates>

View File

@@ -0,0 +1,119 @@
/** @odoo-module **/
import { HomeMenu } from "@web_enterprise/webclient/home_menu/home_menu";
import { useService } from "@web/core/utils/hooks";
import { NotEditableActionError } from "../../studio_service";
import { IconCreatorDialog } from "./icon_creator_dialog/icon_creator_dialog";
const { onMounted, useRef } = owl;
const NEW_APP_BUTTON = {
isNewAppButton: true,
label: "New App",
webIconData: "/web_studio/static/src/img/default_icon_app.png",
};
/**
* Studio home menu
*
* Studio version of the standard enterprise home menu. It has roughly the same
* implementation, with the exception of the app icon edition and the app creator.
* @extends HomeMenu
*/
export class StudioHomeMenu extends HomeMenu {
/**
* @param {Object} props
* @param {Object[]} props.apps application icons
* @param {string} props.apps[].action
* @param {number} props.apps[].id
* @param {string} props.apps[].label
* @param {string} props.apps[].parents
* @param {(boolean|string|Object)} props.apps[].webIcon either:
* - boolean: false (no webIcon)
* - string: path to Odoo icon file
* - Object: customized icon (background, class and color)
* @param {string} [props.apps[].webIconData]
* @param {string} props.apps[].xmlid
*/
setup() {
super.setup(...arguments);
this.user = useService("user");
this.studio = useService("studio");
this.notifications = useService("notification");
this.dialog = useService("dialog");
this.root = useRef("root");
onMounted(() => {
this.canEditIcons = true;
});
}
//--------------------------------------------------------------------------
// Getters
//--------------------------------------------------------------------------
get displayedApps() {
return [...super.displayedApps, NEW_APP_BUTTON];
}
//--------------------------------------------------------------------------
// Protected
//--------------------------------------------------------------------------
async _openMenu(menu) {
if (menu.isNewAppButton) {
this.canEditIcons = false;
return this.studio.open(this.studio.MODES.APP_CREATOR);
} else {
try {
await this.studio.open(this.studio.MODES.EDITOR, menu.actionID);
this.menus.setCurrentMenu(menu);
} catch (e) {
if (e instanceof NotEditableActionError) {
const options = { type: "danger" };
this.notifications.add(
this.env._t("This action is not editable by Studio"),
options
);
return;
}
throw e;
}
}
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @param {Object} app
*/
onEditIconClick(app) {
if (!this.canEditIcons) {
return;
}
const editedAppData = {};
if (app.webIconData) {
Object.assign(editedAppData, {
webIconData: app.webIconData,
type: "base64",
});
} else {
Object.assign(editedAppData, {
backgroundColor: app.webIcon.backgroundColor,
color: app.webIcon.color,
iconClass: app.webIcon.iconClass,
type: "custom_icon",
});
}
const dialogProps = {
editedAppData,
appId: app.id,
};
this.dialog.add(IconCreatorDialog, dialogProps);
}
}
StudioHomeMenu.props = { apps: HomeMenu.props.apps };
StudioHomeMenu.template = "web_studio.StudioHomeMenu";

View File

@@ -0,0 +1,61 @@
// Home menu in studio mode
.o_studio_home_menu {
@include o-web-studio-app-creator-background;
.database_expiration_panel {
visibility: hidden;
}
.o_app {
position: relative;
&:not(.o_web_studio_new_app) {
opacity: 0.5;
.o_app_icon {
transition-property: none;
}
&:hover,
&:focus,
&.o_focused {
background-color: transparent;
opacity: 0.9;
}
}
&.o_web_studio_new_app {
background-color: rgba(white, 0.05);
border-color: rgba(white, 0.1);
.o_app_icon {
box-shadow: none;
}
&:hover,
&:focus,
&.o_focused {
border-color: rgba(white, 0.6);
}
}
.o_web_studio_edit_icon {
visibility: hidden;
@include o-position-absolute($top: 5px, $right: 25px);
i {
background-color: $o-brand-primary;
border-radius: 20%;
color: white;
padding: 0 1px;
}
}
&:hover .o_web_studio_edit_icon {
opacity: 0.6;
visibility: visible;
&:hover {
opacity: 1;
}
}
}
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="web_studio.StudioHomeMenu" t-inherit="web_enterprise.HomeMenu" t-inherit-mode="primary" owl="1" t-translation="off">
<xpath expr="//div[hasclass('o_home_menu')]" position="attributes">
<attribute name="class" add="o_studio_home_menu" separator=" " />
</xpath>
<!-- New App button -->
<xpath expr="//div[hasclass('o_apps')]/a[hasclass('o_app')][hasclass('o_menuitem')]" position="attributes">
<attribute name="t-att-class">{
o_focused: appIndex === app_index,
o_web_studio_new_app: app.isNewAppButton
}</attribute>
</xpath>
<!-- Edit icons -->
<xpath expr="//div[hasclass('o_apps')]/a[hasclass('o_app')][hasclass('o_menuitem')]" position="inside">
<a t-if="!app.isNewAppButton" class="o_web_studio_edit_icon" t-on-click.stop.prevent="() => this.onEditIconClick(app)">
<i class="fa fa-pencil-square" />
</a>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,39 @@
/** @odoo-module */
import { WithSearch } from "@web/search/with_search/with_search";
const { Component, xml, useSubEnv } = owl;
const HEIGHT = "height: 100%;";
export class StudioView extends Component {
setup() {
this.style = this.props.setOverlay ? `pointer-events: none; ${HEIGHT}` : HEIGHT;
this.withSearchProps = {
resModel: this.props.controllerProps.resModel,
SearchModel: this.props.SearchModel,
context: this.props.context,
domain: this.props.domain,
};
useSubEnv({
config: { ...this.env.config },
__beforeLeave__: null,
});
}
}
StudioView.components = { WithSearch };
StudioView.template = xml`
<div t-att-style="style">
<WithSearch t-props="withSearchProps" t-slot-scope="search">
<t t-component="props.Controller"
t-props="props.controllerProps"
context="search.context"
domain="search.domain"
groupBy="search.groupBy"
orderBy="search.orderBy"
comparison="search.comparison"
/>
</WithSearch>
</div>
`;

View File

@@ -0,0 +1,41 @@
// Old BS4 value as now is in rem from Bootstrap 4 (file /scss/_variables.scss)
$grid-gutter-width: 30px;
// =========== Colours ===========
$o-web-studio-color-danger: #e6586c;
$o-web-studio-color-warning: #f0ad4e;
$o-web-studio-color-success: #40ad67;
$o-web-studio-color-info: #6999a8;
$o-web-studio-bg-lighter: #f2f2f2;
$o-web-studio-bg-light: #eaeaea;
$o-web-studio-bg-medium: #3a3f46;
$o-web-studio-bg-dark: #262c34;
$o-web-studio-bg-darker: darken($o-web-studio-bg-dark, 2%);
$o-web-studio-text-light: $o-web-studio-bg-light;
$o-web-studio-text-inactive: darken($o-web-studio-text-light, 20%);
$o-web-studio-text-disabled: gray;
$o-web-studio-text-dark: #15161a;
// =========== Fonts ===========
$o-web-studio-font-family-monospace: Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro,
monospace;
// =========== Shadows ===========
$o-web-studio-shadow-base: 0 0px 26px -12px;
$o-web-studio-shadow-active: 0 10px 26px -12px;
// =========== Fonts ===========
$o-web-studio-font-family-monospace: Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro,
monospace;
// =========== Layout ===========
$o-web-studio-sidebar-width: 300px;
$o-web-studio-report-sidebar-width: 350px;
$o-web-studio-sidebar-margin: $grid-gutter-width * 0.5;
$o-web-studio-view-type-width: 115px;
$o-web-studio-menu-height: $o-navbar-height;
// =========== Menu ===========
$o-web-studio-menu-item-border-width: 3px;

View File

@@ -0,0 +1,41 @@
/** @odoo-module */
import { formView } from "@web/views/form/form_view";
const { Component } = owl;
const components = formView.Controller.components;
export class ChatterContainer extends components.ChatterContainer {
_insertFromProps(props) {
props = { ...props };
delete props.studioXpath;
return super._insertFromProps(props);
}
onClick(ev) {
this.env.config.onNodeClicked({
xpath: this.props.studioXpath,
target: ev.target,
});
}
}
ChatterContainer.template = "web_studio.ChatterContainer";
ChatterContainer.props = {
...ChatterContainer.props,
studioXpath: String,
};
export class ChatterContainerHook extends Component {
onClick() {
this.env.config.onViewChange({
structure: "chatter",
...this.props.chatterData,
});
}
}
ChatterContainerHook.template = "web_studio.ChatterContainerHook";
ChatterContainerHook.components = { ChatterContainer: components.ChatterContainer };
ChatterContainerHook.props = {
chatterData: Object,
threadModel: String,
};

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web_studio.ChatterContainer" t-inherit="mail.ChatterContainer" owl="1">
<xpath expr="./div" position="inside">
<div class="o_web_studio_overlay"/>
</xpath>
<xpath expr="./div" position="attributes">
<attribute name="t-on-click.capture.stop">onClick</attribute>
</xpath>
</t>
<t t-name="web_studio.ChatterContainerHook" owl="1">
<div t-if="env.config.chatterAllowed" class="o_web_studio_add_chatter o_chatter" t-on-click.capture.stop="onClick">
<span class="container">
<span>
<i class="fa fa-comments" style="margin-right:10px"/>
Add Chatter Widget
</span>
</span>
<div class="o_Studio_ChatterContainer">
<ChatterContainer threadModel="props.threadModel"/>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,112 @@
/** @odoo-module */
import { ComponentWrapper } from "web.OwlCompatibility";
import { formView } from "@web/views/form/form_view";
import { FormEditorRenderer } from "./form_editor_renderer/form_editor_renderer";
import { FormEditorController } from "./form_editor_controller/form_editor_controller";
import { FormEditorCompiler } from "./form_editor_compiler";
import { mapActiveFieldsToFieldsInfo } from "@web/views/legacy_utils";
import { OPTIONS_BY_WIDGET } from "@web_studio/legacy/js/views/view_editor_sidebar";
import { registry } from "@web/core/registry";
const formEditor = {
...formView,
Compiler: FormEditorCompiler,
Renderer: FormEditorRenderer,
Controller: FormEditorController,
};
registry.category("studio_editors").add("form", formEditor);
function isVisible(el) {
const style = window.getComputedStyle(el);
return style.display !== "none";
}
class FormEditorWrapper extends ComponentWrapper {
setup() {
super.setup();
const { archInfo, fields } = this.props.controllerProps;
const { activeFields } = archInfo;
const fieldsInfo = mapActiveFieldsToFieldsInfo(
activeFields,
fields,
this.env.config.type,
this.env
);
for (const viewInfo of Object.values(fieldsInfo)) {
const _fieldsInfo = Object.values(viewInfo).filter(
(f) => f.widget in OPTIONS_BY_WIDGET
);
for (const fieldInfo of _fieldsInfo) {
const missingOptions = OPTIONS_BY_WIDGET[fieldInfo.widget].filter(
({ name }) => !(name in fieldInfo.options)
);
for (const option of missingOptions) {
fieldInfo.options[option.name] = option.default;
}
}
}
this.state = {
fieldsInfo,
getFieldNames: () => {
return Object.keys(activeFields);
},
viewType: this.env.config.type,
};
}
getLocalState() {
return {
lastClickedXpath: this.lastClickedXpath || null,
};
}
setLastClickedXpath(lastClickedXpath) {
this.lastClickedXpath = lastClickedXpath || null;
}
setLocalState(state = {}) {
this.lastClickedXpath = state.lastClickedXpath || null;
if (!this.el) {
return;
}
const lastClickedXpath = this.lastClickedXpath;
this.unselectedElements();
if (lastClickedXpath) {
const el = this.el.querySelector(`[data-studio-xpath="${lastClickedXpath}"]`);
if (el && isVisible(el)) {
this.env.config.onNodeClicked({
xpath: lastClickedXpath,
target: el,
});
//////////////////
// factorize code!
el.closest(".o-web-studio-editor--element-clickable").classList.add(
"o-web-studio-editor--element-clicked"
);
///////////////
return;
}
this.props.resetSidebar();
}
}
unselectedElements() {
this.lastClickedXpath = null;
const clickedEl = this.el.querySelector(".o-web-studio-editor--element-clicked");
if (clickedEl) {
clickedEl.classList.remove("o-web-studio-editor--element-clicked");
}
}
handleDrop() {}
highlightNearestHook($helper, position) {
const draggedEl = $helper[0];
const studioStructure = $helper.data("structure");
const pos = { x: position.pageX, y: position.pageY };
draggedEl.dataset.studioStructure = studioStructure;
return this.env.config.executeCallback("highlightNearestHook", draggedEl, pos);
}
setSelectable() {}
selectField(fName) {
this.env.config.executeCallback("selectField", fName);
}
}
registry.category("wowl_editors_wrappers").add("form", FormEditorWrapper);

View File

@@ -0,0 +1,237 @@
/** @odoo-module */
import { appendAttr, isComponentNode } from "@web/views/view_compiler";
import { computeXpath } from "@web_studio/client_action/view_editors/xml_utils";
import { createElement } from "@web/core/utils/xml";
import { formView } from "@web/views/form/form_view";
import { objectToString } from "@web/views/form/form_compiler";
const interestingSelector = [
":not(field) sheet", // A hook should be present to add elements in the sheet
":not(field) field", // should be clickable and draggable
":not(field) notebook", // should be able to add pages
":not(field) page", // should be clickable
":not(field) button", // should be clickable
":not(field) label", // should be clickable
":not(field) group", // any group: outer or inner
":not(field) group group > *", // content of inner groups serves as main dropzone
":not(field) div.oe_chatter",
":not(field) .oe_avatar",
].join(", ");
export class FormEditorCompiler extends formView.Compiler {
compile(key, params = {}) {
params.enableInvisible = true;
const xml = this.templates[key];
// One pass to compute and add the xpath for the arch's node location
// onto that node.
for (const el of xml.querySelectorAll(interestingSelector)) {
const xpath = computeXpath(el);
el.setAttribute("studioXpath", xpath);
}
// done after construction of xpaths
this.addChatter = true;
this.chatterData = {
remove_message_ids: false,
remove_follower_ids: false,
remove_activity_ids: false,
};
this.avatars = [];
let buttonBox = xml.querySelector("div.oe_button_box");
const buttonHook = createElement("ButtonHook", { add_buttonbox: !buttonBox });
if (buttonBox) {
buttonBox.prepend(buttonHook);
}
const compiled = super.compile(key, params);
const sheetBg = compiled.querySelector(".o_form_sheet_bg");
if (sheetBg) {
const studioHook = createElement("StudioHook", {
xpath: `"${sheetBg.getAttribute("studioXpath")}"`,
position: "'inside'",
type: "'insideSheet'",
});
sheetBg.querySelector(".o_form_sheet").prepend(studioHook);
}
if (this.addChatter) {
const chatterContainerHook = createElement("ChatterContainerHook", {
threadModel: `props.record.resModel`,
chatterData: objectToString(this.chatterData),
});
const el = compiled.querySelector(".o_form_sheet") || compiled;
el.after(chatterContainerHook);
} else {
const parent = compiled.querySelector(".o_FormRenderer_chatterContainer");
parent.removeAttribute("t-attf-class"); // avoid class o-aside
parent.removeAttribute("t-if");
}
if (!buttonBox) {
buttonBox = createElement("div", { class: "oe_button_box" });
buttonBox.prepend(buttonHook);
const compiledButtonBox = this.compileButtonBox(buttonBox, {});
const el = compiled.querySelector(".o_form_sheet") || compiled;
el.prepend(compiledButtonBox);
}
const fieldStatus = compiled.querySelector(`Field[type="'statusbar'"]`); // change selector at some point
if (!fieldStatus) {
const add_statusbar = !compiled.querySelector(".o_form_statusbar");
const statusBarFieldHook = createElement("StatusBarFieldHook", { add_statusbar });
const el = compiled.querySelector(".o_form_sheet_bg") || compiled;
el.prepend(statusBarFieldHook);
}
// Note: the ribon does not allow to remove an existing avatar!
const title = compiled.querySelector(".oe_title");
if (title) {
if (
!title.querySelector(":scope > h1 > [isAvatar]") && // check it works with <field class="oe_avatar" ... />
!title.parentElement.querySelector(":scope > [isAvatar]")
) {
const avatarHook = createElement("AvatarHook", {
fields: `props.record.fields`,
});
const h1 = title.querySelector(":scope > h1");
if (h1 && h1.classList.contains("d-flex") && h1.classList.contains("flex-row")) {
avatarHook.setAttribute("class", `'oe_avatar ms-3 p-3 o_web_studio_avatar h4'`);
h1.append(avatarHook);
} else {
avatarHook.setAttribute("class", `'oe_avatar ms-3 me-3 o_web_studio_avatar'`);
title.before(avatarHook);
}
}
}
for (const el of this.avatars) {
el.removeAttribute("isAvatar");
}
compiled.querySelectorAll(":not(.o_form_statusbar) Field").forEach((el) => {
el.setAttribute("hasEmptyPlaceholder", "true");
});
compiled
.querySelectorAll(`InnerGroup > t[t-set-slot][subType="'item_component'"] Field`)
.forEach((el) => {
el.setAttribute("hasLabel", "true");
});
return compiled;
}
applyInvisible(invisible, compiled, params) {
// Just return the node if it is always Visible
if (!invisible) {
return compiled;
}
let isVisileExpr;
// If invisible is dynamic (via Domain), pass a props or apply the studio class.
if (typeof invisible !== "boolean") {
const recordExpr = params.recordExpr || "props.record";
isVisileExpr = `!evalDomainFromRecord(${recordExpr},${JSON.stringify(invisible)})`;
if (isComponentNode(compiled)) {
compiled.setAttribute("studioIsVisible", isVisileExpr);
} else {
appendAttr(compiled, "class", `o_web_studio_show_invisible:!${isVisileExpr}`);
}
} else {
if (isComponentNode(compiled)) {
compiled.setAttribute("studioIsVisible", "false");
} else {
appendAttr(compiled, "class", `o_web_studio_show_invisible:true`);
}
}
// Finally, put a t-if on the node that accounts for the parameter in the config.
const studioShowExpr = `env.config.studioShowInvisible`;
isVisileExpr = isVisileExpr ? `(${isVisileExpr} or ${studioShowExpr})` : studioShowExpr;
if (compiled.hasAttribute("t-if")) {
const formerTif = compiled.getAttribute("t-if");
isVisileExpr = `( ${formerTif} ) and ${isVisileExpr}`;
}
compiled.setAttribute("t-if", isVisileExpr);
return compiled;
}
createLabelFromField(fieldId, fieldName, fieldString, label, params) {
const studioXpath = label.getAttribute("studioXpath");
const formLabel = super.createLabelFromField(...arguments);
formLabel.setAttribute("studioXpath", `"${studioXpath}"`);
if (formLabel.hasAttribute("t-if")) {
formLabel.setAttribute("studioIsVisible", formLabel.getAttribute("t-if"));
formLabel.removeAttribute("t-if");
}
return formLabel;
}
compileNode(node, params = {}, evalInvisible = true) {
const nodeType = node.nodeType;
// Put a xpath on the currentSlot containing the future compiled element.
// Do it early not to be bothered by recursive call to compileNode.
const currentSlot = params.currentSlot;
if (nodeType === 1 && currentSlot && !currentSlot.hasAttribute("studioXpath")) {
const parentElement = node.parentElement;
if (parentElement && parentElement.tagName === "page") {
const xpath = computeXpath(node.parentElement);
currentSlot.setAttribute("studioXpath", `"${xpath}"`);
if (!node.parentElement.querySelector(":scope > group")) {
const hookProps = {
position: "'inside'",
type: "'page'",
xpath: `"${xpath}"`,
};
currentSlot.setAttribute("studioHookProps", objectToString(hookProps));
}
} else {
const xpath = node.getAttribute("studioXpath");
currentSlot.setAttribute("studioXpath", `"${xpath}"`);
}
}
const compiled = super.compileNode(node, params, true); // always evalInvisible
if (nodeType === 1) {
// Put a xpath on anything of interest.
if (node.hasAttribute("studioXpath")) {
const xpath = node.getAttribute("studioXpath");
if (isComponentNode(compiled)) {
compiled.setAttribute("studioXpath", `"${xpath}"`);
} else if (!compiled.hasAttribute("studioXpath")) {
compiled.setAttribute("studioXpath", xpath);
}
}
if (node.classList.contains("oe_chatter")) {
this.addChatter = false;
// compiled is not ChatterContainer!
const chatterNode = compiled.querySelector("ChatterContainer");
const xpath = node.getAttribute("studioXpath");
chatterNode.setAttribute("studioXpath", `"${xpath}"`);
compiled.classList.add("o-web-studio-editor--element-clickable");
}
if (node.classList.contains("oe_avatar")) {
compiled.setAttribute("isAvatar", true);
this.avatars.push(compiled);
}
const name = node.getAttribute("name"); // not sure that part works
if (name === "message_ids") {
this.chatterData.remove_message_ids = true;
} else if (name === "message_follower_ids") {
this.chatterData.remove_follower_ids = true;
} else if (name === "activity_ids") {
this.chatterData.remove_activity_ids = true;
}
}
return compiled;
}
isAlwaysInvisible() {
return false;
}
}

View File

@@ -0,0 +1,53 @@
/** @odoo-module */
import { formView } from "@web/views/form/form_view";
function rebindLegacyDatapoint(datapoint, basicModel) {
const newDp = {};
const descrs = Object.getOwnPropertyDescriptors(datapoint);
Object.defineProperties(newDp, descrs);
newDp.id = "__can'ttouchthis__";
newDp.evalModifiers = basicModel._evalModifiers.bind(basicModel, newDp);
newDp.getContext = basicModel._getContext.bind(basicModel, newDp);
newDp.getDomain = basicModel._getDomain.bind(basicModel, newDp);
newDp.getFieldNames = basicModel._getFieldNames.bind(basicModel, newDp);
newDp.isDirty = basicModel.isDirty.bind(basicModel, newDp.id);
newDp.isNew = basicModel.isNew.bind(basicModel, newDp.id);
return newDp;
}
function applyParentRecordOnModel(model, parentRecord) {
const legacyHandle = parentRecord.__bm_handle__;
const legacyDp = parentRecord.model.__bm__.localData[legacyHandle];
const load = model.load;
model.load = async (...args) => {
const res = await load.call(model, ...args);
const localData = model.__bm__.localData;
const parentDp = rebindLegacyDatapoint(legacyDp, model.__bm__);
localData[parentDp.id] = parentDp;
const rootDp = localData[model.root.__bm_handle__];
rootDp.parentID = parentDp.id;
return res;
};
}
export class FormEditorController extends formView.Controller {
setup() {
super.setup();
this.mailTemplate = null;
this.hasAttachmentViewerInArch = false;
if (this.props.parentRecord) {
applyParentRecordOnModel(this.model, this.props.parentRecord);
}
}
}
FormEditorController.props = {
...formView.Controller.props,
parentRecord: { type: [Object, { value: null }], optional: true },
};

View File

@@ -0,0 +1,26 @@
/** @odoo-module */
import { _lt } from "@web/core/l10n/translation";
const { Component, xml } = owl;
/*
* Injected in the Field.js template
* Allows to overlay the Field's Component widget to prompt
* for editing a x2many subview
*/
export class FieldContentOverlay extends Component {}
FieldContentOverlay.translateViewType = {
list: _lt("List"),
form: _lt("Form"),
};
FieldContentOverlay.template = xml`
<div class="position-relative">
<t t-slot="default" />
<div class="o-web-studio-edit-x2manys-buttons w-100 h-100 d-flex justify-content-center gap-3 position-absolute start-0 top-0 opacity-75 bg-dark" t-if="props.displayOverlay" style="z-index: 1000;">
<button class="btn btn-primary btn-secondary o_web_studio_editX2Many align-self-center"
t-foreach="['list', 'form']" t-as="vType" t-key="vType"
t-on-click="() => props.onEditViewType(vType)"
t-att-data-type="vType">
Edit <t t-esc="constructor.translateViewType[vType]" /> view
</button>
</div>
</div>`;

View File

@@ -0,0 +1,200 @@
/** @odoo-module */
import { formView } from "@web/views/form/form_view";
import { studioIsVisible } from "@web_studio/client_action/view_editors/utils";
import { StudioHook } from "../studio_hook_component";
const { Component, useChildSubEnv, useEffect, useRef } = owl;
const components = formView.Renderer.components;
/*
* Overrides for FormGroups: Probably the trickiest part of all, especially InnerGroup
* - Append droppable hooks below every visible field, or on empty OuterGroup
* - Elements deal with invisible themselves
*/
// An utility function that extends the common API parts of groups
function extendGroup(GroupClass) {
class Group extends GroupClass {
get allClasses() {
let classes = super.allClasses;
if (!studioIsVisible(this.props)) {
classes = `${classes || ""} o_web_studio_show_invisible`;
}
if (this.props.studioXpath) {
classes = `${classes || ""} o-web-studio-editor--element-clickable`;
}
return classes;
}
_getItems() {
const items = super._getItems();
return items.map(([k, v]) => {
v = Object.assign({}, v);
v.studioIsVisible = v.isVisible;
v.isVisible = v.isVisible || this.env.config.studioShowInvisible;
if (v.subType === "item_component") {
v.props.studioIsVisible = v.studioIsVisible;
v.props.studioXpath = v.studioXpath;
}
return [k, v];
});
}
}
Group.props = [...GroupClass.props, "studioXpath?", "studioIsVisible?"];
Group.components = { ...GroupClass.components, StudioHook };
return Group;
}
// A component to display fields with an automatic label.
// Those are the only ones (for now), to be draggable internally
// It should shadow the Field and its Label below
class InnerGroupItemComponent extends Component {
setup() {
const labelRef = useRef("labelRef");
const fieldRef = useRef("fieldRef");
this.labelRef = labelRef;
useEffect(
(studioIsVisible, labelEl, fieldEl) => {
// Only label act as the business unit for studio
if (labelEl) {
const clickable = labelEl.querySelector(
".o-web-studio-editor--element-clickable"
);
if (clickable) {
clickable.classList.remove("o-web-studio-editor--element-clickable");
}
labelEl.classList.add("o-web-studio-editor--element-clickable");
const invisible = labelEl.querySelector(".o_web_studio_show_invisible");
if (invisible) {
invisible.classList.remove("o_web_studio_show_invisible");
}
labelEl.classList.toggle("o_web_studio_show_invisible", !studioIsVisible);
labelEl.classList.add("o-draggable");
}
if (fieldEl) {
const clickable = fieldEl.querySelector(
".o-web-studio-editor--element-clickable"
);
if (clickable) {
clickable.classList.remove("o-web-studio-editor--element-clickable");
}
const invisible = fieldEl.querySelector(".o_web_studio_show_invisible");
if (invisible) {
invisible.classList.remove("o_web_studio_show_invisible");
}
fieldEl.classList.add("o-web-studio-element-ghost");
}
},
() => [this.cell.studioIsVisible, labelRef.el, fieldRef.el]
);
const config = Object.create(this.env.config);
config.onNodeClicked = (params) => {
params = { ...params, target: labelRef.el || params.target };
return this.env.config.onNodeClicked(params);
};
useChildSubEnv({ config });
this.onMouseFieldIO = (ev) => {
labelRef.el.classList.toggle("o-web-studio-ghost-hovered", ev.type === "mouseover");
};
}
get cell() {
return this.props.cell;
}
onClicked(ev) {
if (ev.target.closest(".o-web-studio-element-ghost")) {
ev.stopPropagation();
}
this.env.config.onNodeClicked({ xpath: this.cell.studioXpath, target: this.labelRef.el });
}
}
InnerGroupItemComponent.template = "web_studio.Form.InnerGroup.ItemComponent";
const _InnerGroup = extendGroup(components.InnerGroup);
export class InnerGroup extends _InnerGroup {
setup() {
super.setup();
this.rootRef = useRef("rootRef");
}
onGroupClicked(ev) {
if (ev.target.closest(".o-web-studio-editor--element-clickable") !== this.rootRef.el) {
return;
}
this.env.config.onNodeClicked({
xpath: this.props.studioXpath,
target: ev.target,
});
}
getRows() {
const rows = super.getRows();
if (!this.env.config.studioShowInvisible) {
rows.forEach((row) => {
row.isVisible = row.some((cell) => cell.studioIsVisible);
});
}
return rows;
}
getStudioHooks() {
const hooks = new Map();
const rows = this.getRows();
const hasRows = rows.length >= 1 && rows[0].length;
if (!hasRows) {
hooks.set("inside", {
xpath: this.props.studioXpath,
position: "inside",
subTemplate: "formGrid",
colSpan: this.props.maxCols,
});
}
for (const rowIdx in rows) {
const row = rows[rowIdx];
const colSpan = row.reduce((acc, val) => acc + val.itemSpan || 1, 0);
if (!hooks.has("beforeFirst")) {
const cell = row[0];
if (cell) {
hooks.set("beforeFirst", {
xpath: cell.studioXpath,
position: "before",
subTemplate: "formGrid",
width: cell.width,
colSpan,
});
}
}
if (
row.every((cell) => !cell.studioIsVisible) &&
!this.env.config.studioShowInvisible
) {
continue;
}
const cell = row[row.length - 1];
if (cell) {
hooks.set(`afterRow ${rowIdx}`, {
xpath: cell.studioXpath,
position: "after",
subTemplate: "formGrid",
width: cell.width,
colSpan,
});
}
}
return hooks;
}
}
InnerGroup.components.InnerGroupItemComponent = InnerGroupItemComponent;
InnerGroup.template = "web_studio.Form.InnerGroup";
// Simple override for OuterGroups
export const OuterGroup = extendGroup(components.OuterGroup);
OuterGroup.template = "web_studio.Form.OuterGroup";

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<!-- OUTERGROUP -->
<t t-name="web_studio.Form.OuterGroup" t-inherit="web.Form.OuterGroup" owl="1">
<xpath expr="//div[contains(@class,'o_group')]" position="after">
<StudioHook xpath="props.studioXpath" position="'inside'"/>
</xpath>
</t>
<!-- INNERGROUP -->
<!-- Push a StudioHook at the beginning of the inner group -->
<t t-name="web_studio.Form.InnerGroup" t-inherit="web.Form.InnerGroup">
<xpath expr="./div" position="attributes">
<attribute name="t-ref">rootRef</attribute>
<attribute name="t-on-click">onGroupClicked</attribute>
</xpath>
<xpath expr="//div[@t-foreach='getRows()']" position="before">
<t t-set="studioHooks" t-value="getStudioHooks()" />
<StudioHook t-if="studioHooks.has('beforeFirst')" t-props="studioHooks.get('beforeFirst')" />
</xpath>
<xpath expr="//div[@t-foreach='getRows()']" position="inside">
<StudioHook t-if="studioHooks.has(`afterRow ${row_index}`)" t-props="studioHooks.get(`afterRow ${row_index}`)"/>
</xpath>
<xpath expr="//t[@t-call='web.Form.InnerGroup.ItemComponent']" position="replace">
<InnerGroupItemComponent t-if="cell.studioIsVisible or env.config.studioShowInvisible" cell="cell" slots="props.slots"/>
</xpath>
<xpath expr="//div[@t-foreach='getRows()']" position="after">
<StudioHook t-if="studioHooks.has('inside')" t-props="studioHooks.get('inside')"/>
</xpath>
</t>
<t t-name="web_studio.Form.InnerGroup.ItemComponent" t-inherit="web.Form.InnerGroup.ItemComponent">
<xpath expr="./div[1]" position="attributes">
<attribute name="t-ref">labelRef</attribute>
<attribute name="t-on-click">onClicked</attribute>
<attribute name="t-att-data-field-name">cell.props.fieldName</attribute>
<attribute name="t-att-data-studio-xpath">cell.studioXpath</attribute>
<attribute name="data-studio-structure">field</attribute>
</xpath>
<xpath expr="./div[2]" position="attributes">
<attribute name="t-on-click">onClicked</attribute>
<attribute name="t-ref">fieldRef</attribute>
<attribute name="t-on-mouseover">onMouseFieldIO</attribute>
<attribute name="t-on-mouseout">onMouseFieldIO</attribute>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,245 @@
/** @odoo-module */
import { useDraggable } from "@web/core/utils/draggable";
import { closest, touching } from "@web/core/utils/ui";
import { formView } from "@web/views/form/form_view";
import * as formEditorRendererComponents from "@web_studio/client_action/view_editors/form/form_editor_renderer/form_editor_renderer_components";
import {
cleanHooks,
getActiveHook,
getDroppedValues,
getHooks,
} from "@web_studio/client_action/view_editors/utils";
import { ChatterContainer, ChatterContainerHook } from "../chatter_container";
import { StudioHook } from "../studio_hook_component";
import { InnerGroup, OuterGroup } from "./form_editor_groups";
const { useRef, useEffect, useSubEnv } = owl;
const hookPositionTolerance = 50;
const components = formView.Renderer.components;
function updateCurrentElement(mainEl, currentTarget) {
for (const el of mainEl.querySelectorAll(".o-web-studio-editor--element-clicked")) {
el.classList.remove("o-web-studio-editor--element-clicked");
}
const clickable = currentTarget.closest(".o-web-studio-editor--element-clickable");
if (clickable) {
clickable.classList.add("o-web-studio-editor--element-clicked");
}
}
const HOOK_CLASS_WHITELIST = [
"o_web_studio_field_picture",
"o_web_studio_field_html",
"o_web_studio_field_many2many",
"o_web_studio_field_one2many",
"o_web_studio_field_tabs",
"o_web_studio_field_columns",
"o_web_studio_field_lines",
];
const HOOK_TYPE_BLACKLIST = ["genericTag", "afterGroup", "afterNotebook", "insideSheet"];
const isBlackListedHook = (draggedEl, hookEl) =>
!HOOK_CLASS_WHITELIST.some((cls) => draggedEl.classList.contains(cls)) &&
HOOK_TYPE_BLACKLIST.some((t) => hookEl.dataset.type === t);
function canDropNotebook(hookEl) {
if (hookEl.dataset.type === "page") {
return false;
}
if (hookEl.closest(".o_group")) {
return false;
}
return true;
}
function canDropGroup(hookEl) {
if (hookEl.dataset.type === "insideGroup") {
return false;
}
if (hookEl.closest(".o_group")) {
return false;
}
return true;
}
export class FormEditorRenderer extends formView.Renderer {
setup() {
super.setup();
const rootRef = useRef("compiled_view_root");
this.rootRef = rootRef;
// Handle click on elements
const config = Object.create(this.env.config);
const originalNodeClicked = config.onNodeClicked;
config.onNodeClicked = (params) => {
updateCurrentElement(rootRef.el, params.target);
return originalNodeClicked(params);
};
useSubEnv({ config });
// Prepare a legacy handler for JQuery UI's droppable
const onLegacyDropped = (ev, ui) => {
const hitHook = getActiveHook(rootRef.el);
if (!hitHook) {
return cleanHooks(rootRef.el);
}
const { xpath, position } = hitHook.dataset;
const $droppedEl = ui.draggable || $(ev.target);
const droppedData = $droppedEl.data();
const isNew = $droppedEl[0].classList.contains("o_web_studio_component");
droppedData.isNew = isNew;
// Fieldname is useless here since every dropped element is new.
const values = getDroppedValues({ droppedData, xpath, position });
cleanHooks(rootRef.el);
this.env.config.structureChange(values);
};
// Deals with invisible modifier by reacting to config.studioShowVisible.
useEffect(
(rootEl, showInvisible) => {
if (!rootEl) {
return;
}
rootEl.classList.add("o_web_studio_form_view_editor");
if (showInvisible) {
rootEl
.querySelectorAll(
":not(.o_FormRenderer_chatterContainer) .o_invisible_modifier"
)
.forEach((el) => {
el.classList.add("o_web_studio_show_invisible");
el.classList.remove("o_invisible_modifier");
});
} else {
rootEl
.querySelectorAll(
":not(.o_FormRenderer_chatterContainer) .o_web_studio_show_invisible"
)
.forEach((el) => {
el.classList.remove("o_web_studio_show_invisible");
el.classList.add("o_invisible_modifier");
});
}
// FIXME: legacy: interoperability with legacy studio components
$(rootEl).droppable({
accept: ".o_web_studio_component",
drop: onLegacyDropped,
});
return () => {
$(rootEl).droppable("destroy");
};
},
() => [rootRef.el, this.env.config.studioShowInvisible]
);
// do this in another way?
useEffect(
(rootEl) => {
if (rootEl) {
const optCols = rootEl.querySelectorAll("i.o_optional_columns_dropdown_toggle");
for (const col of optCols) {
col.classList.add("text-muted");
}
}
},
() => [rootRef.el]
);
// A function that highlights relevant areas when dragging a component/field
const highlightNearestHook = (draggedEl, { x, y }) => {
cleanHooks(rootRef.el);
const mouseToleranceRect = {
x: x - hookPositionTolerance,
y: y - hookPositionTolerance,
width: hookPositionTolerance * 2,
height: hookPositionTolerance * 2,
};
const touchingEls = touching(getHooks(rootRef.el), mouseToleranceRect);
const closestHookEl = closest(touchingEls, { x, y });
if (!closestHookEl) {
return false;
}
const draggingStructure = draggedEl.dataset.studioStructure;
switch (draggingStructure) {
case "notebook": {
if (!canDropNotebook(closestHookEl)) {
return false;
}
break;
}
case "group": {
if (!canDropGroup(closestHookEl)) {
return false;
}
break;
}
}
if (isBlackListedHook(draggedEl, closestHookEl)) {
return false;
}
closestHookEl.classList.add("o_web_studio_nearest_hook");
return true;
};
this.env.config.registerCallback("highlightNearestHook", highlightNearestHook);
useDraggable({
ref: rootRef,
elements: ".o-draggable",
onDragStart({ element }) {
element.classList.add("o-draggable--dragging");
},
onDrag({ x, y, element }) {
element.classList.remove("o-draggable--drop-ready");
if (highlightNearestHook(element, { x, y })) {
element.classList.add("o-draggable--drop-ready");
}
},
onDragEnd({ element }) {
element.classList.remove("o-draggable--dragging");
},
onDrop: ({ element }) => {
const targetHook = getActiveHook(rootRef.el);
if (!targetHook) {
return;
}
const { xpath, position } = targetHook.dataset;
const droppedData = element.dataset;
const values = getDroppedValues({ droppedData, xpath, position });
cleanHooks(rootRef.el);
if (!values) {
return;
}
this.env.config.structureChange(values);
},
});
this.env.config.registerCallback("selectField", (fName) => {
const fieldElement = rootRef.el.querySelector(`.o_field_widget[name="${fName}"]`);
if (fieldElement) {
fieldElement.click();
}
});
}
}
FormEditorRenderer.components = {
...components,
...formEditorRendererComponents,
ChatterContainer,
ChatterContainerHook,
InnerGroup,
OuterGroup,
StudioHook,
};

View File

@@ -0,0 +1,318 @@
.o_web_studio_client_action {
.o_web_studio_editor_manager .o_web_studio_view_renderer .o_web_studio_form_view_editor {
$-web-studio-form-edition-gap: 10px;
flex-flow: column nowrap;
.o_form_sheet_bg {
flex: 1 0 auto;
border-bottom: 1px solid map-get($grays, '300');
width: auto;
padding: 0px;
overflow: unset;
.o_form_sheet {
@include make-container-max-widths();
}
}
// Draggable style at each step
// To move to the parent when all is converted
.o-draggable {
&, & * {
cursor: move !important;
}
&.o-draggable--dragging {
width: unset !important;
transform: rotate(-5deg);
box-shadow: 0 5px 25px -10px black;
transition: transform 0.6s, box-shadow 0.3s;
&.o-draggable--drop-ready {
opacity: 1!important;
line-height: 2;
border: 2px solid $o-brand-primary;
}
}
}
.o-web-studio-editor--element-clickable {
// HOVER HANDLING
// native selector: the main sibling is hovered
&:hover, &:hover + .o-web-studio-element-ghost,
// manual selector:the second sibling is hovered; programmatically add a class
&.o-web-studio-ghost-hovered, &.o-web-studio-ghost-hovered + .o-web-studio-element-ghost {
cursor: pointer;
outline: 1px dashed $o-brand-primary;
}
// CLICK HANDLING
// On the main sibling
&.o-web-studio-editor--element-clicked:not(.o-draggable--dragging),
// On the slave sibling
&.o-web-studio-editor--element-clicked:not(.o-draggable--dragging) + .o-web-studio-element-ghost {
outline: 2px solid $o-brand-primary;
&.btn {
outline: 2px solid darken($o-brand-primary, 5%);
}
&:not(.o_statusbar_status) {
outline-offset: -2px;
}
}
}
.o_FormRenderer_chatterContainer {
max-width: 100%;
}
.o_Chatter {
width: 100%;
overflow: unset;
flex: 1000 0 auto;
padding: $grid-gutter-width*0.25 0;
}
&:not(.o_form_nosheet) {
padding: 0;
}
.o_form_sheet_bg {
display: flex;
flex-flow: column nowrap;
padding: 16px;
}
.o_form_sheet, .o_ChatterContainer {
max-width: 100%;
}
.o_form_sheet > .o_group {
margin-left: -$-web-studio-form-edition-gap;
margin-right: -$-web-studio-form-edition-gap;
}
.o_ChatterContainer {
position: relative;
.o_web_studio_overlay {
@include o-position-absolute(0, 0, 0, 0);
transition: all 0.2s ease 0s;
&:hover {
background-color: rgba(white, 0.4);
}
}
}
.o_web_studio_statusbar_hook {
text-align: center;
color: $o-brand-primary;
background-color: lightgray;
font-weight: bold;
cursor: pointer;
height: 42px;
line-height: 42px;
opacity: 0.5;
transition: opacity 0.3s ease 0s;
&:hover {
opacity: 1;
}
}
.o_statusbar_status {
> .o_arrow_button {
// make the buttons look inactive in Studio
&:hover, &:active, &:focus {
color: map-get($grays, '700');
background-color: white;
}
}
}
&.o_web_studio_form_view_editor {
.o_web_studio_hook {
:first-child {
line-height: 0px;
}
td, th {
padding: 0;
}
> td {
padding: 0;
}
span.o_web_studio_hook_separator {
display: block;
position: relative;
border: 0px dashed transparent;
transition: all 0.2s cubic-bezier(0.215, 0.610, 0.355, 1.000) 0s;
}
}
.o_web_studio_hook.o_web_studio_nearest_hook {
cursor: pointer !important;
outline: none;
span.o_web_studio_hook_separator {
background-color: rgba($o-brand-primary, 0.02);
border: 1px dashed rgba($o-brand-primary, 0.5);
padding: 20px;
margin: 4px 0;
}
}
.o_inner_group {
transition: border-color 0.5s;
padding: $-web-studio-form-edition-gap*0.5;
border-collapse: separate;
border: $-web-studio-form-edition-gap*0.5 solid rgba($o-brand-primary, 0.1);
tr:not(.o_web_studio_hovered) td > *:not(.o_horizontal_separator) {
opacity: 0.5;
}
tr:not(.o_web_studio_new_line) {
td {
border: $-web-studio-form-edition-gap*0.5 solid transparent;
border-bottom-width: 1px;
&.o_td_label {
min-width: 140px;
}
}
}
}
// drag&drop
td.ui-draggable-helper-ready {
// border doesn't work because of border-collapse
outline: 2px solid $o-brand-primary;
}
}
.o_field_image img {
max-width: 100%;
height: auto;
&[src="/web/static/img/placeholder.png"] {
padding: 3% 6%;
background: $o-web-studio-bg-lighter;
}
}
.o_web_studio_button_hook {
width: 48px;
cursor: pointer;
text-align: center;
padding: 0;
border-left: none;
}
.oe_button_box:hover .o_web_studio_button_hook:before {
content: "\f0fe";
font-family: FontAwesome;
font-size: 16px;
color: $o-brand-primary;
}
.o_notebook li {
margin-right: 3px;
}
.o_web_studio_add_chatter {
position: relative;
margin: 16px -16px;
cursor: pointer;
color: $o-brand-primary;
background-color: lightgray;
opacity: 0.5;
font-weight: bold;
transition: all ease 0.1s;
> .o_Studio_ChatterContainer {
padding: 16px;
max-width: 90%;
opacity: 0;
pointer-events: none;
transition: all 0.2s ease 0s;
}
> span {
position: relative;
max-width: 100%;
display: block;
transition: width 0.2s ease .1s;
> span {
@include o-position-absolute($left:0);
width: 100%;
height: 52px;
padding-top: 25px;
text-align: center;
}
}
&:hover {
width: auto;
background-color: rgba(white, 0.9);
color: transparent;
opacity: 1;
transition: all .2s ease .2s;
> span {
width: 100%;
}
> .o_Studio_ChatterContainer {
transition: all .8s ease .1s;
max-width: 100%;
opacity: 1;
}
}
}
.o_web_studio_avatar {
color: $o-brand-primary;
background-color: lightgray;
font-weight: bold;
cursor: pointer;
padding: 38px;
opacity: 0.5;
&:hover {
opacity: 1;
}
}
&.o_xxl_form_view {
height: auto;
}
// Button box
.oe_button_box {
&, & + .oe_avatar {
+ .oe_title {
width: 400px;
}
}
.oe_stat_button {
&:hover {
background-color: #e6e6e6;
}
.o_button_icon {
color: #7C7BAD;
}
}
}
}
}
table.o_web_studio_new_elements {
border-collapse: separate;
border-spacing: 20px;
td.o_web_studio_new_element {
&[disabled] {
opacity: 0.5;
cursor: auto;
}
cursor: pointer;
border: 1px solid #ddd;
text-align: center;
> i {
font-size: 35px;
}
}
}
.o_web_studio_new_button_dialog {
> div {
margin-top: 16px;
}
.o_web_studio_icon_selector {
display: inline-block;
padding: 3px;
font-size: medium;
&.o_selected {
outline: 1px solid $o-brand-primary;
}
}
}

View File

@@ -0,0 +1,328 @@
/** @odoo-module */
import { Dialog } from "@web/core/dialog/dialog";
import { FieldContentOverlay } from "./field_content_overlay";
import { formView } from "@web/views/form/form_view";
import { StudioHook } from "../studio_hook_component";
import { studioIsVisible } from "@web_studio/client_action/view_editors/utils";
import { useService } from "@web/core/utils/hooks";
import { fieldVisualFeedback } from "@web/views/fields/field";
const { useState, Component } = owl;
export function useStudioRef(refName = "studioRef", onClick) {
// create two hooks and call them here?
const comp = owl.useComponent();
const ref = owl.useRef(refName);
owl.useEffect(
(el) => {
if (el) {
el.setAttribute("data-studio-xpath", comp.props.studioXpath);
}
},
() => [ref.el]
);
if (onClick) {
const handler = onClick.bind(comp);
owl.useEffect(
(el) => {
if (el) {
el.addEventListener("click", handler, { capture: true });
return () => {
el.removeEventListener("click", handler);
};
}
},
() => [ref.el]
);
}
}
/**
* Overrides and extensions of components used by the FormRenderer
* As a rule of thumb, elements should be able to handle the props
* - studioXpath: the xpath to the node in the form's arch to which the component
* refers
* - They generally be clicked on to change their characteristics (in the Sidebar)
* - The click doesn't trigger default behavior (the view is inert)
* - They can be draggable (FormLabel referring to a field)
* - studioIsVisible: all components whether invisible or not, are compiled and rendered
* this props allows to toggle the class o_invisible_modifier
* - They can have studio hooks, that are placeholders for dropping content (new elements, field, or displace elements)
*/
const components = formView.Renderer.components;
export class Widget extends components.Widget {
get widgetProps() {
const widgetProps = super.widgetProps;
delete widgetProps.studioXpath;
delete widgetProps.hasEmptyPlaceholder;
delete widgetProps.hasLabel;
delete widgetProps.studioIsVisible;
return widgetProps;
}
}
/*
* Field:
* - Displays an Overlay for X2Many fields
* - handles invisible
*/
export class Field extends components.Field {
setup() {
super.setup();
this.state = useState({
displayOverlay: false,
});
useStudioRef("rootRef", this.onClick);
}
get fieldComponentProps() {
const fieldComponentProps = super.fieldComponentProps;
delete fieldComponentProps.studioXpath;
delete fieldComponentProps.hasEmptyPlaceholder;
delete fieldComponentProps.hasLabel;
delete fieldComponentProps.studioIsVisible;
return fieldComponentProps;
}
get classNames() {
const classNames = super.classNames;
classNames["o_web_studio_show_invisible"] = !studioIsVisible(this.props);
classNames["o-web-studio-editor--element-clickable"] = true;
if (!this.props.hasLabel && classNames["o_field_empty"]) {
delete classNames["o_field_empty"];
classNames["o_web_studio_widget_empty"] = true;
}
return classNames;
}
getEmptyPlaceholder() {
const { hasEmptyPlaceholder, hasLabel, fieldInfo, name, record } = this.props;
if (hasLabel || !hasEmptyPlaceholder) {
return false;
}
const { empty } = fieldVisualFeedback(this.FieldComponent, record, name, fieldInfo);
return empty ? record.activeFields[name].string : false;
}
isX2ManyEditable(props) {
const { name, record } = props;
const field = record.fields[name];
if (!["one2many", "many2many"].includes(field.type)) {
return false;
}
const activeField = record.activeFields[name];
if (["many2many_tags", "hr_org_chart"].includes(activeField.widget)) {
return false;
}
return true;
}
onEditViewType(viewType) {
const { name, record, studioXpath } = this.props;
this.env.config.onEditX2ManyView({ viewType, fieldName: name, record, xpath: studioXpath });
}
onClick(ev) {
if (ev.target.classList.contains("o_web_studio_editX2Many")) {
return;
}
ev.stopPropagation();
ev.preventDefault();
this.env.config.onNodeClicked({
xpath: this.props.studioXpath,
target: ev.target,
});
this.state.displayOverlay = !this.state.displayOverlay;
}
}
Field.components = { ...Field.components, FieldContentOverlay };
Field.template = "web_studio.Field";
/*
* FormLabel:
* - Can be draggable if in InnerGroup
*/
export class FormLabel extends components.FormLabel {
setup() {
super.setup();
useStudioRef("rootRef", this.onClick);
}
get className() {
let className = super.className;
if (!studioIsVisible(this.props)) {
className += " o_web_studio_show_invisible";
}
className += " o-web-studio-editor--element-clickable";
return className;
}
onClick(ev) {
ev.preventDefault();
this.env.config.onNodeClicked({
xpath: this.props.studioXpath,
target: ev.target,
});
}
}
FormLabel.template = "web_studio.FormLabel";
/*
* ViewButton:
* - Deals with invisible
* - Click is overriden not to trigger the bound action
*/
export class ViewButton extends components.ViewButton {
setup() {
super.setup();
useStudioRef("rootRef");
}
getClassName() {
let className = super.getClassName();
if (!studioIsVisible(this.props)) {
className += " o_web_studio_show_invisible";
}
className += " o-web-studio-editor--element-clickable";
return className;
}
onClick(ev) {
if (this.props.tag === "a") {
ev.preventDefault();
}
this.env.config.onNodeClicked({
xpath: this.props.studioXpath,
target: ev.currentTarget,
});
}
}
ViewButton.template = "web_studio.ViewButton";
ViewButton.props = [...components.ViewButton.props, "studioIsVisible?", "studioXpath"];
/*
* Notebook:
* - Display every page, the elements in the page handle whether they are invisible themselves
* - Push a droppable hook on every empty page
* - Can add a new page
*/
export class Notebook extends components.Notebook {
computePages(props) {
const pages = super.computePages(props);
pages.forEach((p) => {
p[1].studioIsVisible = p[1].isVisible;
p[1].isVisible = p[1].isVisible || this.env.config.studioShowInvisible;
});
return pages;
}
onNewPageClicked() {
this.env.config.structureChange({
type: "add",
structure: "page",
position: "inside",
xpath: this.props.studioXpath,
});
}
}
Notebook.template = "web_studio.Notebook.Hook";
Notebook.components = { ...components.Notebook.components, StudioHook };
Notebook.props = { ...components.Notebook.props, studioXpath: String };
export class StatusBarFieldHook extends Component {
onClick() {
this.env.config.onViewChange({
add_statusbar: this.props.add_statusbar,
type: "add",
structure: "field",
field_description: {
field_description: "Pipeline status bar",
type: "selection",
selection: [
["status1", this.env._t("First Status")],
["status2", this.env._t("Second Status")],
["status3", this.env._t("Third Status")],
],
default_value: true,
},
target: {
tag: "header",
},
new_attrs: {
widget: "statusbar",
options: "{'clickable': '1'}",
},
position: "inside",
});
}
}
StatusBarFieldHook.template = "web_studio.StatusBarFieldHook";
class FieldSelectorDialog extends Component {
setup() {
this.selectRef = owl.useRef("select");
}
onConfirm() {
const field = this.selectRef.el.value;
this.props.onConfirm(field);
this.props.close();
}
onCancel() {
this.props.close();
}
}
FieldSelectorDialog.template = "web_studio.FieldSelectorDialog";
FieldSelectorDialog.components = { Dialog };
export class AvatarHook extends Component {
setup() {
this.dialogService = useService("dialog");
}
onClick() {
const fields = [];
for (const field of Object.values(this.props.fields)) {
if (field.type === "binary") {
fields.push(field);
}
}
this.dialogService.add(FieldSelectorDialog, {
fields,
showNew: true,
onConfirm: (field) => {
this.env.config.onViewChange({
structure: "avatar_image",
field,
});
},
});
}
}
AvatarHook.template = "web_studio.AvatarHook";
AvatarHook.props = { fields: Object, class: { type: String, optional: true } };
export class ButtonHook extends Component {
onClick() {
this.env.config.onViewChange({
structure: "button",
type: "add",
add_buttonbox: this.props.add_buttonbox,
});
}
}
ButtonHook.template = "web_studio.ButtonHook";
export class ButtonBox extends components.ButtonBox {
getButtons() {
const maxVisibleButtons = this.getMaxButtons();
const visible = [];
const additional = [];
for (const [slotName, slot] of Object.entries(this.props.slots)) {
if (this.env.config.studioShowInvisible || !("isVisible" in slot) || slot.isVisible) {
if (visible.length >= maxVisibleButtons) {
additional.push(slotName);
} else {
visible.push(slotName);
}
}
}
return { visible, additional };
}
}

View File

@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<!-- NOTEBOOK -->
<t t-name="web_studio.Notebook.Hook" t-inherit="web.Notebook" owl="1">
<xpath expr="//li[@t-foreach='navItems']" position="attributes" >
<attribute name="t-att-class">{o_web_studio_show_invisible: !navItem[1].studioIsVisible, "o-web-studio-editor--element-clickable": true}</attribute>
<attribute name="t-on-click">(ev) => env.config.onNodeClicked({xpath: navItem[1].studioXpath, target: ev.target })</attribute>
<attribute name="t-att-data-studio-xpath">navItem[1].studioXpath</attribute>
</xpath>
<xpath expr="//li[@t-foreach='navItems']" position="after" >
<li class="nav-item" t-on-click.prevent="onNewPageClicked">
<a href="#" class="nav-link" >
<i class="fa fa-plus-square" />
</a>
</li>
</xpath>
<xpath expr="//div[contains(@class,'tab-pane')]/t[2]" position="after">
<StudioHook t-if="!page" t-props="props.slots[state.currentPage].studioHookProps" />
</xpath>
</t>
<t t-name="web_studio.StatusBarFieldHook" owl="1">
<div class="o_web_studio_statusbar_hook" t-on-click="onClick">
Add a pipeline status bar
</div>
</t>
<t t-name="web_studio.FormLabel" t-inherit="web.FormLabel" owl="1">
<xpath expr="./label" position="attributes" >
<attribute name="t-ref">rootRef</attribute>
</xpath>
</t>
<t t-name="web_studio.FieldSelectorDialog" owl="1">
<Dialog title="'Select a Field'">
<div class="o_web_studio_kanban_helper">
<label for="field">Field</label>
<select id="field" name="field" class="o_input" t-ref="select">
<option t-if="props.showNew" class="o_new" value=''>New Field</option>
<option t-foreach="props.fields" t-as="field" t-key="field_index" t-att-value="field.name">
<t t-esc="field.string"/> <span t-if="env.debug">(<t t-esc="field.name"/>)</span>
</option>
</select>
</div>
<t t-set-slot="footer">
<button class="btn btn-primary" t-on-click="onConfirm">
Confirm
</button>
<button class="btn btn-secondary" t-on-click="onCancel">
Cancel
</button>
</t>
</Dialog>
</t>
<t t-name="web_studio.ViewButton" t-inherit="web.views.ViewButton" owl="1">
<xpath expr="./t" position="attributes">
<attribute name="t-ref">rootRef</attribute>
</xpath>
</t>
<t t-name="web_studio.AvatarHook" owl="1">
<div t-att-class="props.class" t-on-click="onClick">
Add Picture
</div>
</t>
<t t-name="web_studio.ButtonHook" owl="1">
<button class="btn oe_stat_button o_web_studio_button_hook" t-on-click.stop="onClick"/>
</t>
<t t-name="web_studio.Field" t-inherit="web.Field" owl="1">
<xpath expr="./div" position="attributes">
<attribute name="t-ref">rootRef</attribute>
</xpath>
<xpath expr="./div" position="before">
<t t-set="isX2ManyEditable" t-value="isX2ManyEditable(props)" />
<t t-set="emptyPlaceholder" t-value="getEmptyPlaceholder()" />
</xpath>
<xpath expr="//t[@t-component]" position="attributes">
<attribute name="t-if">!isX2ManyEditable and !emptyPlaceholder</attribute>
</xpath>
<xpath expr="//t[@t-component]" position="after">
<FieldContentOverlay t-if="isX2ManyEditable" displayOverlay="state.displayOverlay" onEditViewType.bind="onEditViewType">
<t t-component="FieldComponent" t-props="fieldComponentProps"/>
</FieldContentOverlay>
<span t-if="emptyPlaceholder" t-esc="emptyPlaceholder" />
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,30 @@
/** @odoo-module **/
const { Component, xml } = owl;
const formGrid = xml`
<div class="o_web_studio_hook"
t-attf-class="g-col-sm-{{ props.colSpan }}"
t-att-data-xpath="props.xpath"
t-att-data-position="props.position"
t-att-data-type="props.type">
<span class="o_web_studio_hook_separator" />
</div>
`;
const defaultTemplate = xml`
<div class="o_web_studio_hook" t-att-data-xpath="props.xpath" t-att-data-position="props.position" t-att-data-type="props.type">
<span class="o_web_studio_hook_separator" />
</div>
`;
export class StudioHook extends Component {
getTemplate(templateName) {
return this.constructor.subTemplates[templateName || "defaultTemplate"];
}
}
StudioHook.template = xml`<t t-call="{{ getTemplate(props.subTemplate) }}" />`;
StudioHook.props = ["xpath?", "position?", "type?", "colSpan?", "subTemplate?", "width?"];
StudioHook.subTemplates = {
formGrid,
defaultTemplate,
};

View File

@@ -0,0 +1,88 @@
/** @odoo-module */
export function cleanHooks(el) {
for (const hookEl of el.querySelectorAll(".o_web_studio_nearest_hook")) {
hookEl.classList.remove("o_web_studio_nearest_hook");
}
}
export function getActiveHook(el) {
return el.querySelector(".o_web_studio_nearest_hook");
}
// A naive function that determines if the toXpath on which we dropped
// our object is actually the same as the fromXpath of the element we dropped.
// Naive because it won't evaluate xpath, just guess whether they are equivalent
// under precise conditions.
function isToXpathEquivalentFromXpath(position, toXpath, fromXpath) {
if (toXpath === fromXpath) {
return true;
}
const toParts = toXpath.split("/");
const fromParts = fromXpath.split("/");
// Are the paths at least in the same parent node ?
if (toParts.slice(0, -1).join("/") !== fromParts.slice(0, -1).join("/")) {
return false;
}
const nodeIdxRegExp = /(\w+)(\[(\d+)\])?/;
const toMatch = toParts[toParts.length - 1].match(nodeIdxRegExp);
const fromMatch = fromParts[fromParts.length - 1].match(nodeIdxRegExp);
// Are the paths comparable in terms of their node tag ?
if (fromMatch[1] !== toMatch[1]) {
return false;
}
// Is the position actually referring to the same place ?
if (position === "after" && parseInt(toMatch[3] || 1) + 1 === parseInt(fromMatch[3] || 1)) {
return true;
}
return false;
}
export function getDroppedValues({ droppedData, xpath, fieldName, position }) {
const isNew = droppedData.isNew;
let values;
if (isNew) {
values = {
type: "add",
structure: droppedData.structure,
field_description: droppedData.field_description,
xpath,
new_attrs: droppedData.new_attrs,
position: position,
};
} else {
if (isToXpathEquivalentFromXpath(position, xpath, droppedData.studioXpath)) {
return;
}
values = {
type: "move",
xpath,
position: position,
structure: "field",
new_attrs: {
name: droppedData.fieldName,
},
};
}
return values;
}
export function getHooks(el) {
return [...el.querySelectorAll(".o_web_studio_hook")];
}
export function extendEnv(env, extension) {
const nextEnv = Object.create(env);
const descrs = Object.getOwnPropertyDescriptors(extension);
Object.defineProperties(nextEnv, descrs);
return Object.freeze(nextEnv);
}
// A standardized method to determine if a component is visible
export function studioIsVisible(props) {
return props.studioIsVisible !== undefined ? props.studioIsVisible : true;
}

View File

@@ -0,0 +1,101 @@
/** @odoo-module */
const nodeWeak = new WeakMap();
export function computeXpath(node, upperBoundTag = "form") {
if (nodeWeak.has(node)) {
return nodeWeak.get(node);
}
const tagName = node.tagName;
let count = 1;
let previous = node;
while ((previous = previous.previousElementSibling)) {
if (previous.tagName === tagName) {
count++;
}
}
let xpath = `${tagName}[${count}]`;
const parent = node.parentElement;
if (tagName !== upperBoundTag) {
const parentXpath = computeXpath(parent, upperBoundTag);
xpath = `${parentXpath}/${xpath}`;
} else {
xpath = `/${xpath}`;
}
nodeWeak.set(node, xpath);
return xpath;
}
function xmlNodeToLegacyNode(xpath, node) {
const attrs = {};
for (const att of node.getAttributeNames()) {
attrs[att] = node.getAttribute(att);
}
if (attrs.modifiers) {
attrs.modifiers = JSON.parse(attrs.modifiers);
} else {
attrs.modifiers = {};
}
if (!attrs.studioXpath) {
attrs.studioXpath = xpath;
} else if (attrs.studioXpath !== xpath) {
// WOWL to remove
throw new Error("You rascal!");
}
const legNode = {
tag: node.tagName,
attrs,
};
return legNode;
}
export function getLegacyNode(xpath, xml) {
const nodes = getNodesFromXpath(xpath, xml);
if (nodes.length !== 1) {
throw new Error(`xpath ${xpath} yielded no or multiple nodes`);
}
return xmlNodeToLegacyNode(xpath, nodes[0]);
}
export function xpathToLegacyXpathInfo(xpath) {
// eg: /form[1]/field[3]
// RegExp notice: group 1 : form ; group 2: [1], group 3: 1
const xpathInfo = [];
const matches = xpath.matchAll(/\/?(\w+)(\[(\d+)\])?/g);
for (const m of matches) {
const info = {
tag: m[1],
indice: parseInt(m[3] || 1),
};
xpathInfo.push(info);
}
return xpathInfo;
}
function getXpathNodes(xpathResult) {
const nodes = [];
let res;
while ((res = xpathResult.iterateNext())) {
nodes.push(res);
}
return nodes;
}
export function getNodesFromXpath(xpath, xml) {
const owner = "evaluate" in xml ? xml : xml.ownerDocument;
const xpathResult = owner.evaluate(xpath, xml, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE);
return getXpathNodes(xpathResult);
}
const parser = new DOMParser();
export const parseStringToXml = (str) => {
return parser.parseFromString(str, "text/xml");
};
const serializer = new XMLSerializer();
export const serializeXmlToString = (xml) => {
return serializer.serializeToString(xml);
};

View File

@@ -0,0 +1,6 @@
.o_web_studio_view_renderer > div > .o_view_controller {
height: 100%;
> .o_content {
height: 100%;
}
}

View File

@@ -0,0 +1,18 @@
/** @odoo-module **/
import { HomeMenu } from "@web_enterprise/webclient/home_menu/home_menu";
import { url } from "@web/core/utils/urls";
import { patch } from "@web/core/utils/patch";
patch(HomeMenu.prototype, "web_studio.HomeMenuBackground", {
setup() {
this._super();
if (!this.menus.getMenu("root").backgroundImage) {
return;
}
this.backgroundImageUrl = url("/web/image", {
id: this.env.services.company.currentCompany.id,
model: "res.company",
field: "background_image",
});
},
});

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web_studio.HomeMenu" t-inherit="web_enterprise.HomeMenu" t-inherit-mode="extension" owl="1" t-translation="off">
<xpath expr="//div" position="attributes">
<attribute name="t-attf-style">
{{ backgroundImageUrl ? "background-image: url(" + backgroundImageUrl + ");" : '' }}
</attribute>
</xpath>
</t>
</templates>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 727 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 15 15">
<g fill="none" fill-rule="evenodd">
<path fill="#21B799" d="M0 0h15v15H0z"/>
<path fill="#00634F" d="M6.4 12L3 8.5l1-1L6.3 10l5-5 1 1"/>
<path fill="#FFF" d="M6.4 11L3 7.5l1-1L6.3 9l5-5 1 1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 305 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 15 15">
<path fill="#21B799" d="M0 0h15v15H0z" opacity=".5"/>
<path fill="#009679" d="M3 9V8h9v1"/>
<path fill="#FFF" d="M3 8V6h9v2"/>
</svg>

After

Width:  |  Height:  |  Size: 224 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -0,0 +1,463 @@
/** @odoo-module **/
/**
* Formerly action_editor_action, slightly adapted
*/
import core from "web.core";
import Dialog from "web.Dialog";
import dom from "web.dom";
import session from "web.session";
import Widget from "web.Widget";
import ActionEditor from "web_studio.ActionEditor";
import bus from "web_studio.bus";
import ViewEditorManager from "web_studio.ViewEditorManager";
const _t = core._t;
export const ActionEditorMain = Widget.extend({
custom_events: {
studio_default_view: "_onSetDefaultView",
studio_restore_default_view: "_onRestoreDefaultView",
studio_disable_view: "_onDisableView",
studio_edit_view: "_onEditView",
studio_edit_action: "_onEditAction",
},
/**
* @constructor
* @param {Object} options
* @param {Object} options.action - action description
* @param {Boolean} options.chatter_allowed
* @param {string} [options.controllerState]
* @param {boolean} [options.noEdit] - do not edit a view
* @param {string} [options.viewType]
* @param {Object} [options.x2mEditorPath]
*/
init: function (parent, options) {
this._super.apply(this, arguments);
this.wowlEnv = options.wowlEnv;
this._title = _t("Studio");
if (this.controlPanelProps) {
this.controlPanelProps.title = this._title;
}
this.options = options;
this.action = options.action;
this._setEditedView(options.viewType);
// We set the x2mEditorPath since when we click on the studio breadcrumb
// a new view_editor_manager is instantiated and then the previous
// x2mEditorPath is needed to reload the previous view_editor_manager
// state.
this.x2mEditorPath = options.x2mEditorPath;
this.activityAllowed = undefined;
this.controllerState = options.controllerState || {};
},
/**
* @override
*/
willStart: function () {
if (!this.action) {
return Promise.reject();
}
var defs = [this._super.apply(this, arguments), this._isActivityAllowed()];
return Promise.all(defs);
},
/**
* @override
*/
start: function () {
var self = this;
var def;
this.$el.addClass("o_web_studio_client_action");
var isEditable = _.contains(ActionEditor.prototype.VIEW_TYPES, this.viewType);
if (this.options.noEdit || !isEditable) {
// click on "Views" in menu or view we cannot edit
this.action_editor = new ActionEditor(this, this.action);
def = this.action_editor.appendTo(this.$(".o_content"));
} else {
// directly edit the view instead of displaying all views
def = this._editView();
}
return Promise.all([def, this._super.apply(this, arguments)]).then(function () {
self._pushState();
bus.trigger("studio_main", self.action);
if (!self.options.noEdit) {
// TODO: try to put it in editView
bus.trigger("edition_mode_entered", self.viewType);
}
// add class when activating a pivot/graph view through studio
const model = self.view_editor && self.view_editor.view.model;
if (model && model._isInSampleMode) {
self.el.classList.add("o_legacy_view_sample_data");
}
});
},
/**
* @override
*/
on_attach_callback: function () {
this.isInDOM = true;
if (this.view_editor) {
this.view_editor.on_attach_callback();
}
},
/**
* @override
*/
on_detach_callback: function () {
this.isInDOM = false;
if (this.view_editor) {
this.view_editor.on_detach_callback();
}
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
* @param {Object} action
* @param {Object} args
* @returns {Promise}
*/
_editAction: function (action, args) {
var self = this;
core.bus.trigger("clear_cache");
return this._rpc({
route: "/web_studio/edit_action",
params: {
action_type: action.type,
action_id: action.id,
args: args,
context: session.user_context,
},
}).then(function (result) {
if (result !== true) {
Dialog.alert(self, result);
} else {
return self._reloadAction(action.id);
}
});
},
/**
* @private
*/
_editView: function () {
var self = this;
// the default view needs to be created before `loadViews` or the
// renderer will not be aware that a new view exists
var defs = [this._getStudioViewArch(this.action.res_model, this.viewType, this.viewId)];
if (this.viewType === "form") {
defs.push(this._isChatterAllowed());
}
return Promise.all(defs).then(function () {
// add studio in loadViews context to retrieve groups server-side
// We load views in the base language to make sure we read/write on the source term field
// of ir.ui.view
const context = Object.assign({}, self.action.context, { studio: true, lang: false });
const resModel = self.action.res_model;
const views = self.views;
const actionId = self.action.id;
const loadActionMenus = false;
const loadIrFilters = true;
const loadViewDef = self.wowlEnv.services.view.loadViews(
{ context, resModel, views },
{ actionId, loadActionMenus, loadIrFilters }
);
return loadViewDef.then(async function (viewDescriptions) {
const legacyFieldsView = viewDescriptions.__legacy__;
const fields_views = legacyFieldsView.fields_views;
for (const viewType in fields_views) {
const fvg = fields_views[viewType];
fvg.viewFields = fvg.fields;
fvg.fields = viewDescriptions.fields;
}
if (!self.action.controlPanelFieldsView) {
let controlPanelFieldsView;
if (fields_views.search) {
controlPanelFieldsView = Object.assign({}, fields_views.search, {
favoriteFilters: legacyFieldsView.filters,
fields: legacyFieldsView.fields,
viewFields: fields_views.search.fields,
});
}
// in case of Studio navigation, the processing done on the
// action in ActWindowActionManager@_executeWindowAction
// is by-passed
self.action.controlPanelFieldsView = controlPanelFieldsView;
}
if (!self.controllerState.currentId) {
self.controllerState.currentId =
self.controllerState.resIds && self.controllerState.resIds[0];
// FIXME: legacy/wowl views compatibility
// This can be reworked when studio will be converted
if (!self.controllerState.currentId && self.viewType === "form") {
const result = await self._rpc({
model: self.action.res_model,
method: "search",
args: [[]],
kwargs: { limit: 1 },
});
self.controllerState.currentId = result[0];
}
}
var params = {
action: self.action,
fields_view: fields_views[self.viewType],
viewType: self.viewType,
chatter_allowed: self.chatter_allowed,
studio_view_id: self.studioView.studio_view_id,
studio_view_arch: self.studioView.studio_view_arch,
x2mEditorPath: self.x2mEditorPath,
controllerState: self.controllerState,
wowlEnv: self.wowlEnv,
viewDescriptions,
};
self.view_editor = new ViewEditorManager(self, params);
var fragment = document.createDocumentFragment();
return self.view_editor.appendTo(fragment).then(function () {
if (self.action_editor) {
dom.detach([{ widget: self.action_editor }]);
}
dom.append(self.$el, [fragment], {
in_DOM: self.isInDOM,
callbacks: [{ widget: self.view_editor }],
});
});
});
});
},
/**
* @private
* @param {String} model
* @param {String} view_type
* @param {Integer} view_id
* @returns {Promise}
*/
_getStudioViewArch: function (model, view_type, view_id) {
var self = this;
core.bus.trigger("clear_cache");
return this._rpc({
route: "/web_studio/get_studio_view_arch",
params: {
model: model,
view_type: view_type,
view_id: view_id,
// We load views in the base language to make sure we read/write on the source term field
// of ir.ui.view
context: _.extend({}, session.user_context, { lang: false }),
},
}).then(function (studioView) {
self.studioView = studioView;
});
},
/**
* Determines whether the model that will be edited supports mail_activity.
*
* @private
* @returns {Promise}
*/
_isActivityAllowed: function () {
var self = this;
var modelName = this.action.res_model;
return this._rpc({
route: "/web_studio/activity_allowed",
params: {
model: modelName,
},
}).then(function (activityAllowed) {
self.activityAllowed = activityAllowed;
});
},
/**
* @private
* Determines whether the model
* that will be edited supports mail_thread
* @returns {Promise}
*/
_isChatterAllowed: function () {
var self = this;
var res_model = this.action.res_model;
return this._rpc({
route: "/web_studio/chatter_allowed",
params: {
model: res_model,
},
}).then(function (isChatterAllowed) {
self.chatter_allowed = isChatterAllowed;
});
},
/**
* @private
*/
_pushState: function () {
// as there is no controller, we need to update the state manually
var state = {
action: this.action.id,
model: this.action.res_model,
view_type: this.viewType,
};
// TODO: necessary?
if (this.action.context) {
var active_id = this.action.context.active_id;
if (active_id) {
state.active_id = active_id;
}
var active_ids = this.action.context.active_ids;
// we don't push active_ids if it's a single element array containing the active_id
// to make the url shorter in most cases
if (active_ids && !(active_ids.length === 1 && active_ids[0] === active_id)) {
state.active_ids = this.action.context.active_ids.join(",");
}
}
this.trigger_up("push_state", {
state: state,
studioPushState: true, // see action_manager @_onPushState
});
},
/**
* @private
* @param {Integer} actionID
* @returns {Promise}
*/
_reloadAction: function (actionID) {
var self = this;
return new Promise(function (resolve) {
self.trigger_up("reload_action", {
actionID: actionID,
onSuccess: resolve,
});
});
},
/**
* @private
* @param {string} [viewType]
*/
_setEditedView: function (viewType) {
var views = this.action._views || this.action.views;
this.views = views.slice();
// search is not in action.view
var searchview_id = this.action.search_view_id && this.action.search_view_id[0];
this.views.push([searchview_id || false, "search"]);
var view = _.find(this.views, function (v) {
return v[1] === viewType;
});
this.view = view || this.views[0]; // see action manager
this.viewId = this.view[0];
this.viewType = this.view[1];
},
/**
* @private
* @param {String} view_mode
* @returns {Promise}
*/
_writeViewMode: function (view_mode, initial_view_mode) {
var self = this;
var def = this._editAction(this.action, { view_mode: view_mode });
return def.then(function (result) {
if (initial_view_mode) {
result.initial_view_types = initial_view_mode.split(",");
}
/* non-working action removed in #21562: we should never get here */
return self.do_action("action_web_studio_action_editor", {
action: result,
noEdit: true,
});
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {OdooEvent} event
*/
_onDisableView: function (event) {
var view_type = event.data.view_type;
var view_mode = _.without(this.action.view_mode.split(","), view_type);
if (!view_mode.length) {
Dialog.alert(this, _t("You cannot deactivate this view as it is the last one active."));
} else {
this._writeViewMode(view_mode.toString());
}
},
/**
* @private
* @param {OdooEvent} event
*/
_onEditAction: function (event) {
var self = this;
var args = event.data.args;
if (!args) {
return;
}
this._editAction(this.action, args).then(function (result) {
self.action = result;
});
},
/**
* @private
* @param {OdooEvent} event
* @param {string} event.data.view_type
*/
_onEditView: function (event) {
this._setEditedView(event.data.view_type);
this._editView().then(function () {
bus.trigger("edition_mode_entered", event.data.view_type);
});
},
/**
* @private
*/
_onRestoreDefaultView: function (event) {
var self = this;
var message = _t(
"Are you sure you want to restore the default view?\r\nAll customization done with studio on this view will be lost."
);
Dialog.confirm(this, message, {
confirm_callback: function () {
var context = _.extend({}, self.action.context, { studio: true, lang: false });
//To restore the default view from an inherited one, we need first to retrieve the default view id
var loadViewDef = self.loadViews(self.action.res_model, context, self.views, {
load_filters: true,
});
loadViewDef.then(function (fields_views) {
self._rpc({
route: "/web_studio/restore_default_view",
params: {
view_id: fields_views[event.data.view_type].view_id,
},
});
});
},
dialogClass: "o_web_studio_preserve_space",
});
},
/**
* @private
* @param {OdooEvent} event
*/
_onSetDefaultView: function (event) {
var selected_view_type = event.data.view_type;
var view_types = _.map(this.action.views, ({ type }) => type);
var view_mode = _.without(view_types, selected_view_type);
view_mode.unshift(selected_view_type);
view_mode = view_mode.toString();
this._writeViewMode(view_mode, this.action.view_mode);
},
});

View File

@@ -0,0 +1,77 @@
/** @odoo-module */
import { useService } from "@web/core/utils/hooks";
import { ComponentAdapter } from "web.OwlCompatibility";
import { MenuItem } from "web_studio.EditMenu";
const { Component, onMounted, onPatched, onWillUpdateProps, xml } = owl;
class EditMenuItemAdapter extends ComponentAdapter {
constructor(props) {
props.Component = MenuItem;
super(...arguments);
}
setup() {
super.setup();
this.menus = useService("menu");
this.env = Component.env;
onMounted(() => {
if (this.props.keepOpen) {
this.widget.editMenu(this.props.scrollToBottom);
}
});
}
get currentMenuId() {
return this.menus.getCurrentApp().id;
}
get legacyMenuData() {
return this.menus.getMenuAsTree("root");
}
get widgetArgs() {
return [this.legacyMenuData, this.currentMenuId];
}
_trigger_up(ev) {
if (ev.name === "reload_menu_data") {
this.props.reloadMenuData(ev);
}
super._trigger_up(...arguments);
}
updateWidget() {}
renderWidget() {}
}
// why a high order component ?
// - support navbar re-rendering without having to fiddle too much in
// the legacy widget's code
// - allow to support the keepopen, and autoscroll features (yet to come)
export class EditMenuItem extends Component {
setup() {
this.menus = useService("menu");
this.localId = 0;
this.editMenuParams = {};
onWillUpdateProps(() => {
this.localId++;
});
onPatched(() => {
this.editMenuParams = {};
});
}
reloadMenuData(ev) {
const { keep_open, scroll_to_bottom } = ev.data;
this.editMenuParams = { keepOpen: keep_open, scrollToBottom: scroll_to_bottom };
this.menus.reload();
}
}
EditMenuItem.components = { EditMenuItemAdapter };
EditMenuItem.template = xml`
<t>
<div t-if="!menus.getCurrentApp()"/>
<EditMenuItemAdapter t-else="" t-key="localId" t-props="editMenuParams" reloadMenuData.bind="reloadMenuData" />
</t>
`;

View File

@@ -0,0 +1,205 @@
odoo.define('web_studio.ApprovalComponent', function (require) {
'use strict';
const Dialog = require('web.OwlDialog');
const Popover = require("web.Popover");
const { useService } = require("@web/core/utils/hooks");
const { Component, onMounted, onWillUnmount, onWillStart, onWillUpdateProps, useState } = owl;
class ApprovalComponent extends Component {
//--------------------------------------------------------------------------
// Lifecycle
//--------------------------------------------------------------------------
setup() {
this.state = useState({
entries: null,
rules: null,
showInfo: false,
syncing: false,
init: true,
});
this.rpc = useService("rpc");
onMounted( () => {
this.env.bus.on('refresh-approval', this, this._onRefresh);
});
onWillUnmount( () => {
this.env.bus.off('refresh-approval', this, this._onRefresh);
});
onWillStart(async () => {
await this._fetchApprovalData();
});
onWillUpdateProps(async () => {
await this._fetchApprovalData();
});
}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @param {Number} ruleId: id of the rule for which the entry is requested.
* @returns {Object}
*/
getEntry(ruleId) {
return this.state.entries.find((e) => e.rule_id[0] === ruleId);
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Delete an approval entry for a given rule server-side.
* @private
* @param {Number} ruleId
*/
_cancelApproval(ruleId) {
this._setSyncing(true);
this.rpc({
model: 'studio.approval.rule',
method: 'delete_approval',
args: [[ruleId]],
kwargs: {
res_id: this.props.resId,
},
context: this.env.session.user_context,
}).then(async () => {
this._notifyChange();
await this._fetchApprovalData()
this._setSyncing(false);
}).guardedCatch(async () => {
this._notifyChange();
await this._fetchApprovalData()
this._setSyncing(false);
});
}
/**
* @private
*/
async _fetchApprovalData() {
const spec = await this.rpc(
{
model: 'studio.approval.rule',
method: 'get_approval_spec',
args: [this.props.model, this.props.method, this.props.action],
kwargs: {
res_id: !this.props.inStudio && this.props.resId,
},
context: this.env.session.user_context,
},
{ shadow: true }
);
// reformat the dates
spec.entries.forEach((entry) => {
entry.long_date = moment.utc(entry.write_date).local().format('LLL');
entry.short_date = moment.utc(entry.write_date).local().format('LL');
});
Object.assign(this.state, spec);
this.state.init = false;
}
/**
* Notifies other widgets that an approval change has occurred server-side,
* this is useful if more than one button with the same action is in the view
* to avoid displaying conflicting approval data.
* @private
*/
_notifyChange() {
this.env.bus.trigger('refresh-approval', {
approvalSpec: [this.props.model, this.props.resId, this.props.method, this.props.action],
});
}
/**
* Create or update an approval entry for a specified rule server-side.
* @private
* @param {Number} ruleId
* @param {Boolean} approved
*/
_setApproval(ruleId, approved) {
this._setSyncing(true);
this.rpc({
model: 'studio.approval.rule',
method: 'set_approval',
args: [[ruleId]],
kwargs: {
res_id: this.props.resId,
approved: approved,
},
context: this.env.session.user_context,
}).then(async () => {
this._notifyChange();
await this._fetchApprovalData();
this._setSyncing(false);
}).guardedCatch(async () => {
this._notifyChange();
await this._fetchApprovalData();
this._setSyncing(false);
});
}
/**
* Mark the widget as syncing; this is used to disable buttons while
* an action is being processed server-side.
* @param {Boolean} value: true to mark the widget as syncing, false otherwise.
*/
_setSyncing(value) {
this.state.syncing = value;
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Handle notification dispatched by the bus; if another action has modifed
* approval data server-side for an approval spec that matches this widget,
* it will update itself.
* @param {Object} ev
*/
_onRefresh(ev) {
if (
ev.approvalSpec[0] === this.props.model &&
ev.approvalSpec[1] === this.props.resId &&
ev.approvalSpec[2] === this.props.method &&
ev.approvalSpec[3] === this.props.action
) {
// someone clicked on this widget's button, which
// might trigger approvals server-side
// refresh the widget
this._fetchApprovalData();
}
}
/**
* Display or hide more information through a popover (desktop) or
* dialog (mobile).
* @param {DOMEvent} ev
*/
_toggleInfo() {
this.state.showInfo = !this.state.showInfo;
}
}
ApprovalComponent.template = 'Studio.ApprovalComponent.Legacy';
ApprovalComponent.components = { Dialog, Popover };
ApprovalComponent.props = {
action: [Number, Boolean],
actionName: { type: String, optional: true },
inStudio: Boolean,
method: [String, Boolean],
model: String,
resId: { type: Number, optional: true },
};
return ApprovalComponent;
});

View File

@@ -0,0 +1,44 @@
odoo.define('web_studio.bus', function (require) {
"use strict";
var Bus = require('web.Bus');
var bus = new Bus();
/* Events on this bus
* ==================
*
* `studio_toggled`
* Studio has been toggled
* @param mode: ['app_creator', 'main']
*
* `studio_main`
* Studio main has been opened
* @param action: the edited action
*
* `action_changed`
* the action used by Studio has been changed (updated server side).
* @param action: the updated action
*
* `edition_mode_entered`
* the view has entered in edition mode.
* @param view_type
*
* `toggle_snack_bar`
* a temporary message needs to be displayed.
* @param type either 'saved' or 'saving'
*
* `(un,re)do_clicked`
* during the view edition, the button (un,re)do has been clicked.
*
* `(un,re)do_available`
* during the view edition, the button (un,re)do has become available.
*
* `(un,re)do_not_available`
* during the view edition, the button (un,re)do has become unavailable.
*
*/
return bus;
});

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