合并企业版代码(未测试,先提交到测试分支)
BIN
web_studio/static/description/icon.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
1
web_studio/static/description/icon.svg
Normal 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 |
17
web_studio/static/src/approval/approval.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
151
web_studio/static/src/approval/approval_hook.js
Normal 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);
|
||||
}
|
||||
34
web_studio/static/src/approval/approval_infos.js
Normal 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 };
|
||||
100
web_studio/static/src/approval/approval_infos.xml
Normal 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>
|
||||
63
web_studio/static/src/approval/studio_approval.js
Normal 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";
|
||||
51
web_studio/static/src/approval/studio_approval.xml
Normal 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>
|
||||
46
web_studio/static/src/approval/view_button_approval.js
Normal 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 });
|
||||
11
web_studio/static/src/approval/view_button_approval.xml
Normal 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>
|
||||
354
web_studio/static/src/client_action/app_creator/app_creator.js
Normal 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";
|
||||
194
web_studio/static/src/client_action/app_creator/app_creator.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
120
web_studio/static/src/client_action/app_creator/app_creator.xml
Normal 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>
|
||||
@@ -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)
|
||||
);
|
||||
109
web_studio/static/src/client_action/editor/editor.js
Normal 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,
|
||||
};
|
||||
32
web_studio/static/src/client_action/editor/editor.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
web_studio/static/src/client_action/editor/editor.xml
Normal 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>
|
||||
249
web_studio/static/src/client_action/editor/editor_adapter.js
Normal 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);
|
||||
@@ -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") });
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
192
web_studio/static/src/client_action/icon_creator/icon_creator.js
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
119
web_studio/static/src/client_action/main.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
177
web_studio/static/src/client_action/mixins.scss
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
64
web_studio/static/src/client_action/navbar/navbar.js
Normal 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;
|
||||
57
web_studio/static/src/client_action/navbar/navbar.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
44
web_studio/static/src/client_action/navbar/navbar.xml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
22
web_studio/static/src/client_action/studio_action_loader.js
Normal 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);
|
||||
88
web_studio/static/src/client_action/studio_client_action.js
Normal 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 });
|
||||
@@ -0,0 +1,3 @@
|
||||
.o_studio {
|
||||
height: 100%;
|
||||
}
|
||||
17
web_studio/static/src/client_action/studio_client_action.xml
Normal 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>
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
39
web_studio/static/src/client_action/studio_view.js
Normal 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>
|
||||
`;
|
||||
41
web_studio/static/src/client_action/variables.scss
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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>`;
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
88
web_studio/static/src/client_action/view_editors/utils.js
Normal 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;
|
||||
}
|
||||
101
web_studio/static/src/client_action/view_editors/xml_utils.js
Normal 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);
|
||||
};
|
||||
6
web_studio/static/src/client_action/views.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
.o_web_studio_view_renderer > div > .o_view_controller {
|
||||
height: 100%;
|
||||
> .o_content {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
18
web_studio/static/src/home_menu/home_menu.js
Normal 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",
|
||||
});
|
||||
},
|
||||
});
|
||||
12
web_studio/static/src/home_menu/home_menu.xml
Normal 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>
|
||||
BIN
web_studio/static/src/img/default_icon_app.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
web_studio/static/src/img/report_hook_address.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
web_studio/static/src/img/report_hook_field_tbody.png
Normal file
|
After Width: | Height: | Size: 440 B |
BIN
web_studio/static/src/img/report_hook_field_thead.png
Normal file
|
After Width: | Height: | Size: 418 B |
BIN
web_studio/static/src/img/report_hook_information.png
Normal file
|
After Width: | Height: | Size: 727 B |
BIN
web_studio/static/src/img/report_hook_table.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
web_studio/static/src/img/report_hook_title.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
web_studio/static/src/img/report_hook_total.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
web_studio/static/src/img/studio_app_icon.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
web_studio/static/src/img/studio_icon_small.png
Normal file
|
After Width: | Height: | Size: 494 B |
7
web_studio/static/src/img/ui/checkbox_active.svg
Normal 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 |
5
web_studio/static/src/img/ui/checkbox_indeterminate.svg
Normal 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 |
BIN
web_studio/static/src/img/ui/studio_icons.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
web_studio/static/src/img/ui/studio_icons@2x.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
web_studio/static/src/img/view_type/activity.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
web_studio/static/src/img/view_type/calendar.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
web_studio/static/src/img/view_type/cohort.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
web_studio/static/src/img/view_type/dashboard.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
web_studio/static/src/img/view_type/form.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
web_studio/static/src/img/view_type/gantt.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
web_studio/static/src/img/view_type/graph.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
web_studio/static/src/img/view_type/kanban.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
web_studio/static/src/img/view_type/list.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
web_studio/static/src/img/view_type/map.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
web_studio/static/src/img/view_type/pivot.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
web_studio/static/src/img/view_type/search.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
463
web_studio/static/src/legacy/action_editor_main.js
Normal 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);
|
||||
},
|
||||
});
|
||||
77
web_studio/static/src/legacy/edit_menu_adapter.js
Normal 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>
|
||||
`;
|
||||
205
web_studio/static/src/legacy/js/approval_component.js
Normal 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;
|
||||
});
|
||||
44
web_studio/static/src/legacy/js/bus.js
Normal 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;
|
||||
|
||||
});
|
||||