[FW][MERGE] project: refactor the list and form views in project sharing in OWL

Purpose
=======

Convert the remaining legacy views in OWL. Before this PR, the list and form views are still in legacy in Project Sharing feature. The goal of this PR is to convert those views to have all views in OWL in Project Sharing.

Details implementation
----------------------

- Convert the list view in OWL
- Convert the chatter portal in OWL to be able to convert the form view in OWL
- Convert the form view in OWL and integrate the chatter into it.
- Review the assets used in Project Sharing to reduce the size of the assets since all components and views existing in the backend are not used (only the list, kanban and form views are used)
- Review the rows attribute set on `textarea` HTML element in`TextField` to be able to altered it with a props.
- Change the `FileInput` component to be able to override it to be able to use it when the route is different than the one set by default.
- Fix the `useInputField` hook to be able to use the `TextField` component without any `record` set on its props (since this prop could be optional).

task-2947516

closes odoo/odoo#102932

Forward-port-of: odoo/odoo#102130
Signed-off-by: Laurent Stukkens (ltu) <ltu@odoo.com>
Signed-off-by: Xavier <xbo@odoo.com>
This commit is contained in:
Odoo's Mergebot
2022-10-10 18:08:53 +02:00
committed by GitHub
43 changed files with 1160 additions and 249 deletions
+3
View File
@@ -53,6 +53,9 @@ up a management by affair.
'web.qunit_suite_tests': [
'hr_timesheet/static/tests/**/*',
],
'project.webclient': [
'hr_timesheet/static/src/components/**/*',
],
},
'license': 'LGPL-3',
}
+123 -13
View File
@@ -79,6 +79,7 @@
],
'web.assets_frontend': [
'project/static/src/scss/portal_rating.scss',
'project/static/src/scss/project_sharing_frontend.scss',
'project/static/src/js/portal_rating.js',
],
'web.qunit_suite_tests': [
@@ -88,25 +89,134 @@
'project/static/tests/tours/**/*',
],
'project.webclient': [
('include', 'web.assets_backend'),
('include', 'web._assets_helpers'),
('include', 'web._assets_backend_helpers'),
# Remove Longpolling bus and packages needed this bus
('remove', 'mail/static/src/services/messaging_service.js'),
'web/static/src/scss/pre_variables.scss',
'web/static/lib/bootstrap/scss/_variables.scss',
('remove', 'mail/static/src/components/dialog_manager/dialog_manager.js'),
('remove', 'mail/static/src/components/chat_window_manager/chat_window_manager.js'),
('include', 'web._assets_bootstrap'),
'base/static/src/css/modules.css',
'web/static/src/core/utils/transitions.scss',
'web/static/src/core/**/*',
'web/static/src/search/**/*',
'web/static/src/webclient/icons.scss', # variables required in list_controller.scss
'web/static/src/views/*.js',
'web/static/src/views/*.xml',
'web/static/src/views/*.scss',
'web/static/src/views/fields/**/*',
'web/static/src/views/form/**/*',
'web/static/src/views/kanban/**/*',
'web/static/src/views/list/**/*',
'web/static/src/views/view_button/**/*',
'web/static/src/views/view_dialogs/**/*',
'web/static/src/views/widgets/**/*',
'web/static/src/webclient/**/*',
('remove', 'web/static/src/webclient/navbar/navbar.scss'), # already in assets_common
('remove', 'web/static/src/webclient/clickbot/clickbot.js'), # lazy loaded
('remove', 'web/static/src/views/form/button_box/*.scss'),
# remove the report code and whitelist only what's needed
('remove', 'web/static/src/webclient/actions/reports/**/*'),
'web/static/src/webclient/actions/reports/*.js',
'web/static/src/webclient/actions/reports/*.xml',
'web/static/src/env.js',
'web/static/lib/jquery.scrollTo/jquery.scrollTo.js',
'web/static/lib/py.js/lib/py.js',
'web/static/lib/py.js/lib/py_extras.js',
'web/static/lib/jquery.ba-bbq/jquery.ba-bbq.js',
'web/static/src/legacy/scss/fields.scss',
'web/static/src/legacy/scss/views.scss',
'web/static/src/legacy/scss/form_view.scss',
'web/static/src/legacy/scss/list_view.scss',
'web/static/src/legacy/scss/kanban_dashboard.scss',
'web/static/src/legacy/scss/kanban_examples_dialog.scss',
'web/static/src/legacy/scss/kanban_column_progressbar.scss',
'web/static/src/legacy/scss/kanban_view.scss',
'base/static/src/scss/res_partner.scss',
# Form style should be computed before
'web/static/src/views/form/button_box/*.scss',
'web/static/src/legacy/action_adapters.js',
'web/static/src/legacy/debug_manager.js',
'web/static/src/legacy/legacy_service_provider.js',
'web/static/src/legacy/legacy_client_actions.js',
'web/static/src/legacy/legacy_dialog.js',
'web/static/src/legacy/legacy_load_views.js',
'web/static/src/legacy/legacy_promise_error_handler.js',
'web/static/src/legacy/legacy_rpc_error_handler.js',
'web/static/src/legacy/root_widget.js',
'web/static/src/legacy/legacy_setup.js',
'web/static/src/legacy/root_widget.js',
'web/static/src/legacy/backend_utils.js',
'web/static/src/legacy/utils.js',
'web/static/src/legacy/web_client.js',
'web/static/src/legacy/js/_deprecated/data.js',
'web/static/src/legacy/js/chrome/*',
'web/static/src/legacy/js/components/*',
'web/static/src/legacy/js/control_panel/*',
'web/static/src/legacy/js/core/domain.js',
'web/static/src/legacy/js/core/mvc.js',
'web/static/src/legacy/js/core/py_utils.js',
'web/static/src/legacy/js/core/context.js',
'web/static/src/legacy/js/core/misc.js',
'web/static/src/legacy/js/fields/abstract_field.js',
'web/static/src/legacy/js/fields/abstract_field_owl.js',
'web/static/src/legacy/js/_deprecated/basic_fields.js',
'web/static/src/legacy/js/fields/basic_fields.js',
'web/static/src/legacy/js/fields/basic_fields_owl.js',
'web/static/src/legacy/js/fields/field_utils.js',
'web/static/src/legacy/js/fields/relational_fields.js',
'web/static/src/legacy/js/fields/special_fields.js',
'web/static/src/legacy/js/fields/field_registry.js',
'web/static/src/legacy/js/fields/field_registry_owl.js',
'web/static/src/legacy/js/fields/field_utils.js',
'web/static/src/legacy/js/fields/field_wrapper.js',
'web/static/src/legacy/js/views/sample_server.js',
'web/static/src/legacy/js/views/abstract_model.js',
'web/static/src/legacy/js/views/basic/basic_model.js',
'web/static/src/legacy/js/views/action_model.js',
'web/static/src/legacy/js/views/view_utils.js',
'web/static/src/legacy/js/services/data_manager.js',
'web/static/src/legacy/js/services/session.js',
'web/static/src/legacy/js/tools/tools.js',
'web/static/src/legacy/js/views/**/*',
'web/static/src/legacy/js/widgets/data_export.js',
'web/static/src/legacy/js/widgets/date_picker.js',
'web/static/src/legacy/js/widgets/domain_selector_dialog.js',
'web/static/src/legacy/js/widgets/domain_selector.js',
'web/static/src/legacy/js/widgets/model_field_selector.js',
'web/static/src/legacy/js/widgets/model_field_selector_popover.js',
'web/static/src/legacy/js/env.js',
'web/static/src/legacy/js/model.js',
'web/static/src/legacy/js/owl_compatibility.js',
'web_editor/static/src/components/**/*',
'web_editor/static/src/scss/web_editor.common.scss',
'web_editor/static/src/scss/web_editor.backend.scss',
'web_editor/static/src/js/wysiwyg/dialog.js',
'web_editor/static/src/js/frontend/loader.js',
'web_editor/static/src/js/backend/**/*',
'web_editor/static/src/xml/backend.xml',
'mail/static/src/scss/variables/*.scss',
'mail/static/src/widgets/**/*.scss',
'project/static/src/components/project_task_name_with_subtask_count_char_field/*',
'project/static/src/views/project_task_form/*.scss',
'web/static/src/legacy/js/public/public_widget.js',
'portal/static/src/js/portal_chatter.js',
'portal/static/src/js/portal_composer.js',
'project/static/src/project_sharing/search/favorite_menu/custom_favorite_item.xml',
'project/static/src/project_sharing/**/*.js',
'project/static/src/scss/project_sharing/*',
'project/static/src/project_sharing/**/*',
'web/static/src/start.js',
'web/static/src/legacy/legacy_setup.js',
'project/static/src/project_sharing/**/*.xml',
'portal/static/src/xml/portal_chatter.xml',
],
},
'license': 'LGPL-3',
@@ -1,34 +0,0 @@
/** @odoo-module **/
import { PortalChatter } from 'portal.chatter';
import Composer from './composer';
export default PortalChatter.extend({
messageFetch(domain) {
if (!this.options.res_id) {
return Promise.resolve();
}
return this._super.apply(this, arguments);
},
_chatterInit() {
if (!this.options.res_id) {
this.result = { messages: [] };
return Promise.resolve();
}
return this._super.apply(this, arguments);
},
_messageFetchPrepareParams() {
const data = this._super.apply(this, arguments);
if (this.options.project_sharing_id) {
data.project_sharing_id = this.options.project_sharing_id;
}
return data;
},
_createComposerWidget() {
return new Composer(this, this.options);
},
update(props) {
this._setOptions(props);
this._reloadChatterContent();
},
});
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-extend="portal.chatter_messages">
<t t-jquery="t[t-as='message']" t-operation="attributes">
<attribute name="t-foreach">!!widget.options.res_id &amp;&amp; widget.get('messages') || []</attribute>
</t>
</t>
</templates>
@@ -19,11 +19,12 @@
.o_portal_chatter_attachments {
margin-bottom: 1rem;
.o_portal_chatter_attachment {
.o_portal_chatter_attachment_delete {
> button {
@include o-position-absolute($top: 0, $right: 0);
opacity: 0;
}
&:hover .o_portal_chatter_attachment_delete {
&:hover > button {
opacity: 1;
}
}
@@ -55,7 +56,7 @@
.o_portal_chatter {
.o_portal_chatter_header {
padding-top: 1rem;
padding-bottom: 2px;
padding-bottom: 1px;
}
.o_portal_chatter_composer, .o_portal_chatter_messages {
@@ -0,0 +1,15 @@
/** @odoo-module */
const { Component } = owl;
export class ChatterAttachmentsViewer extends Component {}
ChatterAttachmentsViewer.template = 'project.ChatterAttachmentsViewer';
ChatterAttachmentsViewer.props = {
attachments: Array,
canDelete: { type: Boolean, optional: true },
delete: { type: Function, optional: true },
};
ChatterAttachmentsViewer.defaultProps = {
delete: async () => {},
};
@@ -0,0 +1,28 @@
<templates id="template" xml:space="preserve">
<t t-name="project.ChatterAttachmentsViewer" owl="1">
<div class="o_portal_chatter_attachments mt-3">
<div t-if="props.attachments.length" class="row">
<div t-foreach="props.attachments" t-as="attachment" t-key="attachment.id" class="col-lg-3 col-md-4 col-sm-6">
<div class="o_portal_chatter_attachment mb-2 position-relative text-center">
<button
t-if="props.canDelete and attachment.state == 'pending'"
class="btn btn-sm btn-outline-danger"
title="Delete"
t-on-click="() => props.delete(attachment)"
>
<i class="fa fa-times"/>
</button>
<a t-attf-href="/web/content/#{attachment.id}?download=true&amp;access_token=#{attachment.access_token}" target="_blank">
<div class='oe_attachment_embedded o_image' t-att-title="attachment.name" t-att-data-mimetype="attachment.mimetype"/>
<div class='o_portal_chatter_attachment_name'>
<t t-out='attachment.filename'/>
</div>
</a>
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,134 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { TextField } from '@web/views/fields/text/text_field';
import { PortalAttachDocument } from '../portal_attach_document/portal_attach_document';
import { ChatterAttachmentsViewer } from './chatter_attachments_viewer';
const { Component, useState, onWillUpdateProps } = owl;
export class ChatterComposer extends Component {
setup() {
this.rpc = useService('rpc');
this.state = useState({
displayError: false,
attachments: this.props.attachments.map(file => file.state === 'done'),
message: '',
loading: false,
});
onWillUpdateProps(this.onWillUpdateProps);
}
onWillUpdateProps(nextProps) {
this.clearErrors();
this.state.message = '';
this.state.attachments = nextProps.attachments.map(file => file.state === 'done');
}
get discussionUrl() {
return `${window.location.href.split('#')[0]}#discussion`;
}
update(change) {
this.clearErrors();
this.state.message = change;
}
prepareMessageData() {
const attachment_ids = [];
const attachment_tokens = [];
for (const attachment of this.state.attachments) {
attachment_ids.push(attachment.id);
attachment_tokens.push(attachment.access_token);
}
return {
message: this.state.message,
attachment_ids,
attachment_tokens,
res_model: this.props.resModel,
res_id: this.props.resId,
project_sharing_id: this.props.projectSharingId,
};
}
async sendMessage() {
this.clearErrors();
if (!this.state.message && !this.state.attachments.length) {
this.state.displayError = true;
return;
}
await this.rpc(
"/mail/chatter_post",
this.prepareMessageData(),
);
this.props.postProcessMessageSent();
this.state.message = "";
this.state.attachments = [];
}
clearErrors() {
this.state.displayError = false;
}
async beforeUploadFile() {
this.state.loading = true;
return true;
}
onFileUpload(files) {
this.state.loading = false;
this.clearErrors();
for (const file of files) {
file.state = 'pending';
this.state.attachments.push(file);
}
}
async deleteAttachment(attachment) {
this.clearErrors();
try {
await this.rpc(
'/portal/attachment/remove',
{
attachment_id: attachment.id,
access_token: attachment.access_token,
},
);
} catch (err) {
console.error(err);
this.state.displayError = true;
}
this.state.attachments = this.state.attachments.filter(a => a.id !== attachment.id);
}
}
ChatterComposer.components = {
ChatterAttachmentsViewer,
PortalAttachDocument,
TextField,
};
ChatterComposer.props = {
resModel: String,
projectSharingId: Number,
resId: { type: Number, optional: true },
allowComposer: { type: Boolean, optional: true },
displayComposer: { type: Boolean, optional: true },
token: { type: String, optional: true },
messageCount: { type: Number, optional: true },
isUserPublic: { type: Boolean, optional: true },
partnerId: { type: Number, optional: true },
postProcessMessageSent: { type: Function, optional: true },
attachments: { type: Array, optional: true },
};
ChatterComposer.defaultProps = {
allowComposer: true,
displayComposer: false,
isUserPublic: true,
token: '',
attachments: [],
};
ChatterComposer.template = 'project.ChatterComposer';
@@ -0,0 +1,61 @@
<templates id="template" xml:space="preserve">
<!-- Widget PortalComposer (standalone)
required many options: token, res_model, res_id, ...
-->
<t t-name="project.ChatterComposer" owl="1">
<div t-if="props.allowComposer" class="o_portal_chatter_composer">
<t t-if="props.displayComposer">
<div t-if="state.displayError" class="alert alert-danger mb8 o_portal_chatter_composer_error" role="alert">
Oops! Something went wrong. Try to reload the page and log in.
</div>
<div class="d-flex">
<img t-if="!props.isUserPublic or props.token"
alt="Avatar"
class="o_portal_chatter_avatar o_object_fit_cover align-self-start"
t-attf-src="/web/image/res.partner/{{ props.partnerId }}/avatar_128"
/>
<div class="flex-grow-1">
<div class="o_portal_chatter_composer_input">
<div class="o_portal_chatter_composer_body mb32">
<TextField
rowCount="4"
placeholder="'Write a message...'"
value="state.message"
update.bind="update"
/>
<ChatterAttachmentsViewer
attachments="state.attachments"
canDelete="true"
delete.bind="deleteAttachment"
/>
<div class="mt8">
<button name="send_message" t-on-click="sendMessage" class="btn btn-primary me-1" type="submit" t-att-disabled="state.loading">
Send
</button>
<PortalAttachDocument
resModel="props.resModel"
resId="props.resId"
token="props.token"
multiUpload="true"
onUpload.bind="onFileUpload"
beforeOpen.bind="beforeUploadFile"
>
<i class="fa fa-paperclip"/>
</PortalAttachDocument>
</div>
</div>
</div>
</div>
</div>
</t>
<t t-else="">
<h4>Leave a comment</h4>
<p>You must be <a t-attf-href="/web/login?redirect={{ discussionUrl }}">logged in</a> to post a comment.</p>
</t>
</div>
</t>
</templates>
@@ -0,0 +1,172 @@
/** @odoo-module */
import { formatDateTime, parseDateTime } from "@web/core/l10n/dates";
import { useService } from "@web/core/utils/hooks";
import { sprintf } from '@web/core/utils/strings';
import { ChatterComposer } from "./chatter_composer";
import { ChatterMessageCounter } from "./chatter_message_counter";
import { ChatterMessages } from "./chatter_messages";
import { ChatterPager } from "./chatter_pager";
const { Component, markup, onWillStart, useState, onWillUpdateProps } = owl;
export class ChatterContainer extends Component {
setup() {
this.rpc = useService('rpc');
this.state = useState({
currentPage: this.props.pagerStart,
messages: [],
options: this.defaultOptions,
});
onWillStart(this.onWillStart);
onWillUpdateProps(this.onWillUpdateProps);
}
get defaultOptions() {
return {
message_count: 0,
is_user_public: true,
is_user_employee: false,
is_user_published: false,
display_composer: Boolean(this.props.resId),
partner_id: null,
pager_scope: 4,
pager_step: 10,
};
}
get options() {
return this.state.options;
}
set options(options) {
this.state.options = {
...this.defaultOptions,
...options,
display_composer: !!options.display_composer,
access_token: typeof options.display_composer === 'string' ? options.display_composer : '',
};
}
get composerProps() {
return {
allowComposer: Boolean(this.props.resId),
displayComposer: this.state.options.display_composer,
partnerId: this.state.options.partner_id || undefined,
token: this.state.options.access_token,
resModel: this.props.resModel,
resId: this.props.resId,
projectSharingId: this.props.projectSharingId,
postProcessMessageSent: async () => {
this.state.currentPage = 1;
await this.fetchMessages();
},
attachments: this.state.options.default_attachment_ids,
};
}
onWillStart() {
this.initChatter(this.messagesParams(this.props));
}
onWillUpdateProps(nextProps) {
if (nextProps.resId) {
this.initChatter(this.messagesParams(nextProps));
} else {
this.state.messages = [];
this.options = {};
}
}
async onChangePage(page) {
this.state.currentPage = page;
await this.fetchMessages();
}
async initChatter(params) {
const chatterData = await this.rpc(
'/mail/chatter_init',
params,
);
this.state.messages = this.preprocessMessages(chatterData.messages);
this.options = chatterData.options;
}
async fetchMessages() {
const result = await this.rpc(
'/mail/chatter_fetch',
this.messagesParams(this.props),
);
this.state.messages = this.preprocessMessages(result.messages);
this.state.options.message_count = result.message_count;
return result;
}
messagesParams(props) {
const params = {
res_model: props.resModel,
res_id: props.resId,
limit: this.state.options.pager_step,
offset: (this.state.currentPage - 1) * this.state.options.pager_step,
allow_composer: Boolean(props.resId),
project_sharing_id: props.projectSharingId,
};
if (props.token) {
params.token = props.token;
}
if (props.domain) {
params.domain = props.domain;
}
return params;
}
preprocessMessages(messages) {
return messages.map(m => ({
...m,
author_avatar_url: sprintf('/web/image/mail.message/%s/author_avatar/50x50', m.id),
published_date_str: sprintf(
this.env._t('Published on %s'),
formatDateTime(
parseDateTime(
m.date,
{ format: 'MM-dd-yyy HH:mm:ss' },
),
)
),
body: markup(m.body),
}));
}
updateMessage(message_id, changes) {
Object.assign(
this.state.messages.find(m => m.id === message_id),
changes,
);
}
}
ChatterContainer.components = {
ChatterComposer,
ChatterMessageCounter,
ChatterMessages,
ChatterPager,
};
ChatterContainer.props = {
token: { type: String, optional: true },
resModel: String,
resId: { type: Number, optional: true },
pid: { type: String, optional: true },
hash: { type: String, optional: true },
pagerStart: { type: Number, optional: true },
twoColumns: { type: Boolean, optional: true },
projectSharingId: Number,
};
ChatterContainer.defaultProps = {
token: '',
pid: '',
hash: '',
pagerStart: 1,
};
ChatterContainer.template = 'project.ChatterContainer';
@@ -0,0 +1,27 @@
<templates id="template" xml:space="preserve">
<t t-name="project.ChatterContainer" owl="1">
<div t-attf-class="o_portal_chatter p-0 {{props.twoColumns ? 'row' : ''}}">
<div t-attf-class="{{props.twoColumns ? 'col-lg-5' : props.resId ? 'border-bottom' : ''}}">
<div class="o_portal_chatter_header">
<ChatterMessageCounter count="state.options.message_count"/>
</div>
<hr/>
<ChatterComposer t-props="composerProps"/>
</div>
<div t-attf-class="{{props.twoColumns ? 'offset-lg-1 col-lg-6' : 'pt-4'}}">
<ChatterMessages messages="props.resId ? state.messages : []" isUserEmployee="state.options.is_user_employee" update.bind="updateMessage" />
<div class="o_portal_chatter_footer">
<ChatterPager
page="this.state.currentPage || 1"
messageCount="this.state.options.message_count"
pagerScope="this.state.options.pager_scope"
pagerStep="this.state.options.pager_step"
changePage.bind="onChangePage"
/>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,10 @@
/** @odoo-module */
const { Component } = owl;
export class ChatterMessageCounter extends Component { }
ChatterMessageCounter.props = {
count: Number,
};
ChatterMessageCounter.template = 'project.ChatterMessageCounter';
@@ -0,0 +1,16 @@
<templates id="template" xml:space="preserve">
<t t-name="project.ChatterMessageCounter" owl="1">
<div class="o_message_counter">
<t t-if="props.count">
<span class="fa fa-comments" />
<span class="o_message_count"> <t t-esc="props.count"/> </span>
comments
</t>
<t t-else="">
There are no comments for now.
</t>
</div>
</t>
</templates>
@@ -0,0 +1,37 @@
/** @odoo-module */
import { useService } from "@web/core/utils/hooks";
import { ChatterAttachmentsViewer } from "./chatter_attachments_viewer";
const { Component } = owl;
export class ChatterMessages extends Component {
setup() {
this.rpc = useService('rpc');
}
/**
* Toggle the visibility of the message.
*
* @param {Object} message message to change the visibility
*/
async toggleMessageVisibility(message) {
const result = await this.rpc(
'/mail/update_is_internal',
{ message_id: message.id, is_internal: !message.is_internal },
);
this.props.update(message.id, { is_internal: result });
}
}
ChatterMessages.template = 'project.ChatterMessages';
ChatterMessages.props = {
messages: Array,
isUserEmployee: { type: Boolean, optional: true },
update: { type: Function, optional: true },
};
ChatterMessages.defaultProps = {
update: (message_id, changes) => {},
};
ChatterMessages.components = { ChatterAttachmentsViewer };
@@ -0,0 +1,43 @@
<templates id="template" xml:space="preserve">
<t t-name="project.ChatterMessages" owl="1">
<div class="o_portal_chatter_messages">
<t t-foreach="props.messages" t-as="message" t-key="message.id">
<div class="d-flex o_portal_chatter_message">
<img class="o_portal_chatter_avatar" t-att-src="message.author_avatar_url" alt="avatar"/>
<div class="flex-grow-1">
<t t-if="props.isUserEmployee">
<div t-if="message.is_message_subtype_note" class="float-end">
<button class="btn btn-secondary" title="Internal notes are only displayed to internal users." disabled="true">Internal Note</button>
</div>
<div t-else=""
t-attf-class="float-end {{message.is_internal ? 'o_portal_message_internal_on' : 'o_portal_message_internal_off'}}"
t-on-click="() => this.toggleMessageVisibility(message)"
>
<button class="btn btn-danger"
title="Currently restricted to internal employees, click to make it available to everyone viewing this document."
>
Employees Only
</button>
<button class="btn btn-success"
title="Currently available to everyone viewing this document, click to restrict to internal employees."
>
Visible
</button>
</div>
</t>
<div class="o_portal_chatter_message_title">
<h5 class='mb-1'><t t-out="message.author_id[1]"/></h5>
<p class="o_portal_chatter_puslished_date"><t t-out="message.published_date_str"/></p>
</div>
<t t-out="message.body"/>
<div class="o_portal_chatter_attachments">
<ChatterAttachmentsViewer attachments="message.attachment_ids"/>
</div>
</div>
</div>
</t>
</div>
</t>
</templates>
@@ -0,0 +1,64 @@
/** @odoo-module */
const { Component, useState, onWillUpdateProps } = owl;
export class ChatterPager extends Component {
setup() {
this.state = useState({
disabledButtons: false,
pageCount: 1,
pageStart: 1,
pageEnd: 1,
pagePrevious: 1,
pageNext: 1,
pages: [1],
offset: 0,
});
this.computePagerState(this.props);
onWillUpdateProps(this.onWillUpdateProps);
}
computePagerState(props) {
let page = props.page || 1;
let scope = props.pagerScope;
const step = props.pagerStep;
// Compute Pager
this.state.messageCount = Math.ceil(parseFloat(props.messageCount) / step);
page = Math.max(1, Math.min(page, this.state.messageCount));
const pageStart = Math.max(page - parseInt(Math.floor(scope / 2)), 1);
this.state.pageEnd = Math.min(pageStart + scope, this.state.messageCount);
this.state.pageStart = Math.max(this.state.pageEnd - scope, 1);
this.state.pages = Array.from(
{length: this.state.pageEnd - this.state.pageStart + 1},
(_, i) => i + this.state.pageStart,
);
this.state.pagePrevious = Math.max(this.state.pageStart, page - 1);
this.state.pageNext = Math.min(this.state.pageEnd, page + 1);
}
onWillUpdateProps(nextProps) {
this.computePagerState(nextProps);
}
async onPageChanged(page) {
this.state.disabledButtons = true;
await this.props.changePage(page);
this.state.disabledButtons = false;
}
}
ChatterPager.props = {
pagerScope: Number,
pagerStep: Number,
page: Number,
messageCount: Number,
changePage: Function,
};
ChatterPager.template = 'project.ChatterPager';
@@ -0,0 +1,21 @@
<templates id="template" xml:space="preserve">
<t t-name="project.ChatterPager" owl="1">
<div class="d-flex justify-content-center">
<ul class="pagination mb-0 pb-4" t-if="state.pages.length &gt; 1">
<li t-if="props.page != props.page_previous" t-att-data-page="state.pagePrevious" class="page-item o_portal_chatter_pager_btn">
<a t-on-click="() => this.onPageChanged(state.pagePrevious)" class="page-link"><i class="fa fa-chevron-left" role="img" aria-label="Previous" title="Previous"/></a>
</li>
<t t-foreach="state.pages" t-as="page" t-key="page_index">
<li t-att-data-page="page" t-attf-class="page-item #{page == props.page ? 'o_portal_chatter_pager_btn active' : 'o_portal_chatter_pager_btn'}">
<a t-on-click="() => this.onPageChanged(page)" t-att-disabled="page == props.page" class="page-link"><t t-esc="page"/></a>
</li>
</t>
<li t-if="props.page != state.pageNext" t-att-data-page="state.pageNext" class="page-item o_portal_chatter_pager_btn">
<a t-on-click="() => this.onPageChanged(state.pageNext)" class="page-link"><i class="fa fa-chevron-right" role="img" aria-label="Next" title="Next"/></a>
</li>
</ul>
</div>
</t>
</templates>
@@ -1,17 +0,0 @@
/** @odoo-module **/
import { PortalComposer } from 'portal.composer';
export default PortalComposer.extend({
_prepareAttachmentData() {
const data = this._super.apply(this, arguments);
const newData = {};
if (this.options.display_composer && typeof this.options.display_composer == 'string') {
// then we should have the access_token of the task
newData.access_token = this.options.display_composer;
} else {
newData.project_sharing_id = this.options.project_sharing_id;
}
return Object.assign(data, newData);
},
});
@@ -0,0 +1,33 @@
/** @odoo-module */
import { PortalFileInput } from '../portal_file_input/portal_file_input';
const { Component } = owl;
export class PortalAttachDocument extends Component {}
PortalAttachDocument.template = 'project.PortalAttachDocument';
PortalAttachDocument.components = { PortalFileInput };
PortalAttachDocument.props = {
highlight: { type: Boolean, optional: true },
onUpload: { type: Function, optional: true },
beforeOpen: { type: Function, optional: true },
slots: {
type: Object,
shape: {
default: Object,
},
},
resId: { type: Number, optional: true },
resModel: { type: String, optional: true },
multiUpload: { type: Boolean, optional: true },
hidden: { type: Boolean, optional: true },
acceptedFileExtensions: { type: String, optional: true },
token: { type: String, optional: true },
};
PortalAttachDocument.defaultProps = {
acceptedFileExtensions: "*",
onUpload: () => {},
route: "/portal/attachment/add",
beforeOpen: async () => true,
};
@@ -0,0 +1,21 @@
<templates id="template" xml:space="preserve">
<t t-name="project.PortalAttachDocument" owl="1">
<button t-attf-class="btn o_attachment_button #{props.highlight ? 'btn-primary' : 'btn-secondary'}">
<PortalFileInput
onUpload="props.onUpload"
beforeOpen="props.beforeOpen"
multiUpload="props.multiUpload"
resModel="props.resModel"
resId="props.resId"
route="props.route"
accessToken="props.token"
>
<t t-set-slot="default">
<i class="fa fa-paperclip"/>
</t>
</PortalFileInput>
</button>
</t>
</templates>
@@ -0,0 +1,46 @@
/** @odoo-module */
import { FileInput } from '@web/core/file_input/file_input';
export class PortalFileInput extends FileInput {
/**
* @override
*/
get httpParams() {
const {
model: res_model,
id: res_id,
...otherParams
} = super.httpParams;
return {
res_model,
res_id,
access_token: this.props.accessToken,
...otherParams,
}
}
async uploadFiles(params) {
const { ufile: files, ...otherParams } = params;
const filesData = await Promise.all(
files.map(
(file) =>
super.uploadFiles({
file,
name: file.name,
...otherParams,
})
)
);
return filesData;
}
}
PortalFileInput.props = {
...FileInput.props,
accessToken: { type: String, optional: true },
};
PortalFileInput.defaultProps = {
...FileInput.defaultProps,
accessToken: '',
};
@@ -1,8 +0,0 @@
/** @odoo-module **/
import FormController from 'web.FormController';
import BasicController from 'web.BasicController';
export default FormController.extend({
_getActionMenuItems: BasicController.prototype._getActionMenuItems,
});
@@ -0,0 +1,105 @@
/** @odoo-module */
import { append, createElement, setAttributes } from "@web/core/utils/xml";
import { registry } from "@web/core/registry";
import { SIZES } from "@web/core/ui/ui_service";
import { getModifier, ViewCompiler } from "@web/views/view_compiler";
import { patch } from "@web/core/utils/patch";
import { FormCompiler } from "@web/views/form/form_compiler";
/**
* Compiler the portal chatter in project sharing.
*
* @param {HTMLElement} node
* @param {Object} params
* @returns
*/
function compileChatter(node, params) {
const chatterContainerXml = createElement('ChatterContainer');
const parentURLQuery = new URLSearchParams(window.parent.location.search);
setAttributes(chatterContainerXml, {
token: parentURLQuery.get('access_token') || '',
resModel: params.resModel,
resId: params.resId,
projectSharingId: params.projectSharingId,
});
const chatterContainerHookXml = createElement('div');
chatterContainerHookXml.classList.add('o_FormRenderer_chatterContainer');
append(chatterContainerHookXml, chatterContainerXml);
return chatterContainerHookXml;
}
export class ProjectSharingChatterCompiler extends ViewCompiler {
setup() {
this.compilers.push({ selector: "t", fn: this.compileT });
this.compilers.push({ selector: 'div.oe_chatter', fn: this.compileChatter });
}
compile(node, params) {
const res = super.compile(node, params).children[0];
const chatterContainerHookXml = res.querySelector(".o_FormRenderer_chatterContainer");
if (chatterContainerHookXml) {
setAttributes(chatterContainerHookXml, {
"t-if": `uiService.size >= ${SIZES.XXL}`,
});
chatterContainerHookXml.classList.add('overflow-x-hidden', 'overflow-y-auto', 'o-aside', 'h-100');
}
return res;
}
compileT(node, params) {
const compiledRoot = createElement("t");
for (const child of node.childNodes) {
const invisible = getModifier(child, "invisible");
let compiledChild = this.compileNode(child, params, false);
compiledChild = this.applyInvisible(invisible, compiledChild, {
...params,
recordExpr: "model.root",
});
append(compiledRoot, compiledChild);
}
return compiledRoot;
}
compileChatter(node) {
return compileChatter(node, {
resId: 'model.root.resId or undefined',
resModel: 'model.root.resModel',
projectSharingId: 'model.root.context.active_id',
});
}
}
registry.category("form_compilers").add("portal_chatter_compiler", {
selector: "div.oe_chatter",
fn: (node) =>
compileChatter(node, {
resId: "props.record.resId or undefined",
resModel: "props.record.resModel",
projectSharingId: "props.record.context.active_id",
}),
});
patch(FormCompiler.prototype, 'project_sharing_chatter', {
compile(node, params) {
const res = this._super(node, params);
const chatterContainerHookXml = res.querySelector('.o_FormRenderer_chatterContainer');
if (!chatterContainerHookXml) {
return res; // no chatter, keep the result as it is
}
if (chatterContainerHookXml.parentNode.classList.contains('o_form_sheet')) {
return res; // if chatter is inside sheet, keep it there
}
const formSheetBgXml = res.querySelector('.o_form_sheet_bg');
const parentXml = formSheetBgXml && formSheetBgXml.parentNode;
if (!parentXml) {
return res; // miss-config: a sheet-bg is required for the rest
}
// after sheet bg (standard position, below form)
setAttributes(chatterContainerHookXml, {
't-if': `uiService.size < ${SIZES.XXL}`,
});
append(parentXml, chatterContainerHookXml);
return res;
}
});
@@ -0,0 +1,31 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { FormController } from '@web/views/form/form_controller';
import { useViewCompiler } from '@web/views/view_compiler';
import { ProjectSharingChatterCompiler } from './project_sharing_form_compiler';
import { ChatterContainer } from '../../components/chatter/chatter_container';
export class ProjectSharingFormController extends FormController {
setup() {
super.setup();
this.uiService = useService('ui');
const { arch, xmlDoc } = this.archInfo;
const template = document.createElement('t');
const xmlDocChatter = xmlDoc.querySelector("div.oe_chatter");
if (xmlDocChatter && xmlDocChatter.parentNode.nodeName === "form") {
template.appendChild(xmlDocChatter.cloneNode(true));
}
const mailTemplates = useViewCompiler(ProjectSharingChatterCompiler, arch, { Mail: template }, {});
this.mailTemplate = mailTemplates.Mail;
}
getActionMenuItems() {
return {};
}
}
ProjectSharingFormController.components = {
...FormController.components,
ChatterContainer,
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="web.FormView" t-inherit-mode="extension">
<xpath expr="//div[hasclass('o_form_view_container')]" position="after">
<t t-if="mailTemplate">
<t t-call="{{ mailTemplate }}" />
</t>
</xpath>
</t>
</templates>
@@ -0,0 +1,10 @@
/** @odoo-module */
import { ChatterContainer } from '../../components/chatter/chatter_container';
import { FormRenderer } from '@web/views/form/form_renderer';
export class ProjectSharingFormRenderer extends FormRenderer { }
ProjectSharingFormRenderer.components = {
...FormRenderer.components,
ChatterContainer,
};
@@ -0,0 +1,8 @@
/** @odoo-module */
import { formView } from '@web/views/form/form_view';
import { ProjectSharingFormController } from './project_sharing_form_controller';
import { ProjectSharingFormRenderer } from './project_sharing_form_renderer';
formView.Controller = ProjectSharingFormController;
formView.Renderer = ProjectSharingFormRenderer;
@@ -1,52 +0,0 @@
/** @odoo-module **/
import '@mail/widgets/form_renderer/form_renderer'; // ensure mail overrides are applied first
import FormRenderer from 'web.FormRenderer';
import Chatter from '@project/project_sharing/components/chatter';
import { session } from '@web/session';
export default FormRenderer.extend({
/**
* Creates the portal chatter instead of the standard chatter.
*
* This overridden method is expected to be called from proper lifecycle
* method of mail form renderer, when `this._chatterContainerTarget` is set.
*
* @override
*/
initChatter() {
const options = this.makePortalChatterOptions();
this.portalChatter = new Chatter(this, options);
this.portalChatter.appendTo(this._chatterContainerTarget);
},
/**
* Updates the options of the portal chatter instead of the props of the
* standard chatter.
*
* This overridden method is expected to be called from proper lifecycle
* method of mail form renderer.
*
* @override
*/
_updateChatterContainerComponent() {
const options = this.makePortalChatterOptions();
this.portalChatter.update(options);
},
makePortalChatterOptions() {
// FIXME: perhaps check if we have a parent in the window ?
// Call the parent window to get the url displayed in the browser since we are in an iframe
const query = new URLSearchParams(window.parent.location.search);
return {
token: query.get('access_token') || '',
res_model: 'project.task',
pid: '',
hash: '',
res_id: this.state.res_id,
pager_step: 10,
allow_composer: !!this.state.res_id,
two_columns: false,
project_sharing_id: session.project_id,
};
},
});
@@ -1,12 +0,0 @@
/** @odoo-module **/
import FormView from 'web.FormView';
import Controller from './controller';
import Renderer from './renderer';
export default FormView.extend({
config: _.extend({}, FormView.prototype.config, {
Controller,
Renderer,
}),
});
@@ -1,8 +0,0 @@
/** @odoo-module **/
import ListController from 'web.ListController';
import BasicController from 'web.BasicController';
export default ListController.extend({
_getActionMenuItems: BasicController.prototype._getActionMenuItems,
});
@@ -0,0 +1,41 @@
/** @odoo-module */
import { ListRenderer } from "@web/views/list/list_renderer";
import { evalDomain } from "@web/views/utils";
const { onWillUpdateProps } = owl;
export class ProjectSharingListRenderer extends ListRenderer {
setup() {
super.setup(...arguments);
this.setColumns(this.allColumns);
onWillUpdateProps((nextProps) => {
this.setColumns(nextProps.archInfo.columns);
});
}
setColumns(columns) {
if (this.props.list.records.length) {
const allColumns = [];
const firstRecord = this.props.list.records[0];
for (const column of columns) {
if (
column.modifiers.column_invisible &&
column.modifiers.column_invisible instanceof Array
) {
const result = evalDomain(column.modifiers.column_invisible, firstRecord.evalContext);
if (result) {
continue;
}
}
allColumns.push(column);
}
this.allColumns = allColumns;
} else {
this.allColumns = columns;
}
this.state.columns = this.allColumns.filter(
(col) => !col.optional || this.optionalActiveFields[col.name]
);
}
}
@@ -0,0 +1,15 @@
/** @odoo-module */
import { listView } from "@web/views/list/list_view";
import { ProjectSharingListRenderer } from "./list_renderer";
const props = listView.props;
listView.props = function (genericProps, view) {
const result = props(genericProps, view);
return {
...result,
allowSelectors: false,
};
};
listView.Renderer = ProjectSharingListRenderer;
@@ -1,46 +0,0 @@
/** @odoo-module **/
import ListView from 'web.ListView';
import Controller from './controller';
export default ListView.extend({
config: Object.assign({}, ListView.prototype.config, {
Controller,
}),
init: function (viewInfo, params) {
return this._super(viewInfo, {...params, hasSelectors: false});
},
getRenderer (parent, state) {
const columnInvisibleFields = {};
for (const child of this.arch.children) {
if (child.attrs && child.attrs.modifiers && child.attrs.modifiers.column_invisible) {
columnInvisibleFields[child.attrs.name] = child.attrs.modifiers.column_invisible;
}
}
this.rendererParams.columnInvisibleFields = Object.entries(columnInvisibleFields).reduce((acc, entry) => {
const [fieldName, domains] = entry;
if (domains instanceof Array && state.data.length) {
const record = state.data[0];
const data = Object.entries(record.data).reduce((acc, entry) => {
const [fieldName, value] = entry;
const field = record.fields[fieldName];
if (field.type === 'one2many' || field.type === 'many2many') {
if (value instanceof Object && value.type === 'list') {
acc[fieldName] = value.id;
}
}
return acc;
}, record.data);
if (Object.keys(data).length) {
// We assume the field in this domain is a related to the project shared.
acc[fieldName] = this.model._evalModifiers({ ...record, data }, { column_invisible: domains }).column_invisible;
}
} else {
acc[fieldName] = domains;
}
return acc;
}, {});
return this._super(parent, state);
},
});
@@ -1,19 +0,0 @@
/** @odoo-module **/
import ViewRegistry from 'web.view_registry';
import { registry } from '@web/core/registry';
// FIXME: make sure the form and list views are define before we remove them below
import "@web/views/form/form_view";
import "@web/views/list/list_view";
import FormView from './form/view';
import ListView from './list/view';
// FIXME: remove new views to force the use of the overriden legacy views when
// project sharing form and list are converted to new views.
registry.category("views").remove("form");
registry.category("views").remove("list");
ViewRegistry
.add('form', FormView)
.add('list', ListView);
@@ -0,0 +1,3 @@
.o_project_sharing_container > main {
display: flex;
}
@@ -49,14 +49,14 @@ const projectSharingSteps = [...tour.stepUtils.goToAppSteps("project.menu_main_p
trigger: '.o_kanban_quick_create .o_kanban_edit',
content: 'Go to the form view of this new task',
}, {
trigger: 'div.o_statusbar_status[name="stage_id"] button[aria-checked="false"]:contains(Done)',
trigger: 'div[name="stage_id"] div.o_statusbar_status button[aria-checked="false"]:contains(Done)',
content: 'Change the stage of the task.',
}, {
trigger: '.o_portal_chatter_composer_input .o_portal_chatter_composer_body textarea[name="message"]',
trigger: '.o_portal_chatter_composer_input .o_portal_chatter_composer_body textarea',
content: 'Write a message in the chatter of the task',
run: 'text I create a new task for testing purpose.',
}, {
trigger: '.o_portal_chatter_composer_input .o_portal_chatter_composer_body button[data-action="/mail/chatter_post"]',
trigger: '.o_portal_chatter_composer_input .o_portal_chatter_composer_body button[name="send_message"]',
content: 'Send the message',
}, {
trigger: 'ol.breadcrumb > li.o_back_button > a:contains(Project Sharing)',
@@ -39,7 +39,7 @@ class TestProjectSharingUi(HttpCase):
def test_01_project_sharing(self):
""" Test Project Sharing UI with an internal user """
self.start_tour("/web?debug=assets", 'project_sharing_tour', login="admin")
self.start_tour("/web", 'project_sharing_tour', login="admin")
def test_02_project_sharing(self):
""" Test project sharing ui with a portal user.
@@ -3,6 +3,8 @@
<template id="project_sharing_portal" name="Project Sharing View in Portal">
<t t-call="portal.frontend_layout">
<!-- To add the class on div#wrapwrap to remove the overflow -->
<t t-set="pageName" t-value="'o_project_sharing_container'"/>
<t t-set="no_footer" t-value="true"/>
<t t-call="project.project_sharing"/>
</t>
@@ -10,7 +12,7 @@
<template id="project_sharing" name="Project Sharing View">
<!-- We need to forward the request lang to ensure that the lang set on the portal match the lang delivered -->
<iframe width="100%" height="100%" frameborder="0" t-attf-src="/{{ request.context['lang'] }}/my/projects/{{ str(project_id) }}/project_sharing{{ '?task_id=' + task_id if task_id else '' }}"/>
<iframe class="flex-grow-1" frameborder="0" t-attf-src="/{{ request.context['lang'] }}/my/projects/{{ str(project_id) }}/project_sharing{{ '?task_id=' + task_id if task_id else '' }}"/>
</template>
<template id="project_sharing_embed" name="Project Sharing View Embed">
@@ -85,7 +85,7 @@
</div>
</div>
<div class="o_kanban_record_body">
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/>
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}" context="{'project_id': project_id}"/>
<div t-if="record.date_deadline.raw_value" name="date_deadline" attrs="{'invisible': [('is_closed', '=', True)]}">
<field name="date_deadline" widget="remaining_days"/>
</div>
@@ -34,6 +34,30 @@ export class FileInput extends Component {
});
}
get httpParams() {
const { resId, resModel } = this.props;
const params = {
csrf_token: odoo.csrf_token,
ufile: [...this.fileInputRef.el.files],
};
if (resModel) {
params.model = resModel;
}
if (resId !== undefined) {
params.id = resId;
}
return params;
}
async uploadFiles(params) {
const fileData = await this.http.post(this.props.route, params, "text");
const parsedFileData = JSON.parse(fileData);
if (parsedFileData.error) {
throw new Error(parsedFileData.error);
}
return parsedFileData;
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
@@ -46,22 +70,7 @@ export class FileInput extends Component {
* - resId: the id of the resModel target instance
*/
async onFileInputChange() {
const { resId, resModel, route } = this.props;
const params = {
csrf_token: odoo.csrf_token,
ufile: [...this.fileInputRef.el.files],
};
if (resModel) {
params.model = resModel;
}
if (resId !== undefined) {
params.id = resId;
}
const fileData = await this.http.post(route, params, "text");
const parsedFileData = JSON.parse(fileData);
if (parsedFileData.error) {
throw new Error(parsedFileData.error);
}
const parsedFileData = await this.uploadFiles(this.httpParams);
this.props.onUpload(parsedFileData);
}
@@ -56,7 +56,9 @@ export function useInputField(params) {
try {
val = params.parse(val);
} catch (_e) {
component.props.record.setInvalidField(component.props.name);
if (component.props.record) {
component.props.record.setInvalidField(component.props.name);
}
isInvalid = true;
}
}
@@ -101,7 +103,7 @@ export function useInputField(params) {
* If it is not such a case, we update the field with the new value.
*/
useEffect(() => {
const isInvalid = component.props.record.isInvalid(component.props.name);
const isInvalid = component.props.record ? component.props.record.isInvalid(component.props.name) : false;
if (inputRef.el && !isDirty && !isInvalid) {
inputRef.el.value = params.getValue();
lastSetValue = inputRef.el.value;
@@ -133,7 +135,7 @@ export function useInputField(params) {
isInvalid = true;
if (urgent) {
return;
} else {
} else if (component.props.record) {
component.props.record.setInvalidField(component.props.name);
}
}
@@ -6,6 +6,7 @@ import { useInputField } from "../input_field_hook";
import { standardFieldProps } from "../standard_field_props";
import { TranslationButton } from "../translation_button";
import { useDynamicPlaceholder } from "../dynamicplaceholder_hook";
import { parseInteger } from '../parsers';
const { Component, useEffect, onMounted, onWillUnmount, useRef } = owl;
@@ -67,7 +68,7 @@ export class TextField extends Component {
return 50;
}
get rowCount() {
return 2;
return this.props.rowCount;
}
resize() {
@@ -105,23 +106,31 @@ TextField.template = "web.TextField";
TextField.components = {
TranslationButton,
};
TextField.defaultProps = {dynamicPlaceholder: false};
TextField.defaultProps = {
dynamicPlaceholder: false,
rowCount: 2,
};
TextField.props = {
...standardFieldProps,
isTranslatable: { type: Boolean, optional: true },
placeholder: { type: String, optional: true },
dynamicPlaceholder: { type: Boolean, optional: true},
rowCount: { type: Number, optional: true },
};
TextField.displayName = _lt("Multiline Text");
TextField.supportedTypes = ["html", "text"];
TextField.extractProps = ({ attrs, field }) => {
return {
const props = {
isTranslatable: field.translate,
placeholder: attrs.placeholder,
dynamicPlaceholder: attrs.options.dynamic_placeholder,
};
if (attrs.rows) {
props.rowCount = parseInteger(attrs.rows);
}
return props;
};
registry.category("fields").add("text", TextField);
@@ -131,8 +140,12 @@ export class ListTextField extends TextField {
return 0;
}
get rowCount() {
return 1;
return this.props.rowCount;
}
}
ListTextField.defaultProps = {
...TextField.defaultProps,
rowCount: 1,
};
registry.category("fields").add("list.text", ListTextField);
@@ -240,6 +240,28 @@ QUnit.module("Fields", (hooks) => {
);
});
QUnit.test("set row on text fields", async function (assert) {
serverData.models.partner.fields.foo.type = "text";
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="foo" rows="4"/>
</form>`,
});
const textarea = target.querySelector("textarea");
assert.strictEqual(
textarea.rows,
4,
"rowCount should be the one set on the field",
);
});
QUnit.test(
"autoresize of text fields is done when switching to edit mode",
async function (assert) {