From ff122ac7aef1822ef113855255606b5dfcfea4bf Mon Sep 17 00:00:00 2001 From: Zelong Lin Date: Mon, 15 May 2023 09:33:02 +0000 Subject: [PATCH] [IMP] mail: avatar edit in channel and group 1. Display avatar in top bar next to channel name and description. 2. Edit icon appears on hover. 3. Clicking on the Edit icon opens file browser to upload a new avatar. 4. Certain file types are allowed. 5. Avatar can only be changed for group chat by any member of the channel. 6. For channel it can be done with admin rights. 7. Disable uploading text from file uploader in this case. Also show avatar of channel/chat conversation in chat window. Author avatar in message list no longer shown im status. Chat window header color matches new systray color. task-2684679 [FIX] mail: make chat window header match systray color Part-of: odoo/odoo#117357 --- .../static/src/im_status_patch.xml | 5 +- .../static/tests/im_status_patch_tests.js | 54 ++++++-------- .../static/src/discuss_app/discuss_patch.xml | 8 +++ addons/mail/controllers/discuss/binary.py | 28 ++++++-- addons/mail/controllers/discuss/channel.py | 7 ++ addons/mail/models/discuss/discuss_channel.py | 14 ++++ addons/mail/static/src/core/thread_model.js | 10 ++- addons/mail/static/src/core/thread_service.js | 1 + addons/mail/static/src/core_ui/message.js | 2 +- addons/mail/static/src/core_ui/message.xml | 1 - .../src/discuss/web/chat_window_patch.xml | 6 +- .../src/discuss_app/autoresize_input.js | 3 +- .../src/discuss_app/autoresize_input.xml | 4 +- addons/mail/static/src/discuss_app/discuss.js | 17 +++++ .../mail/static/src/discuss_app/discuss.scss | 21 ++++++ .../mail/static/src/discuss_app/discuss.xml | 24 +++++-- .../mail/static/src/discuss_app/im_status.js | 4 +- .../static/src/discuss_app/im_status.scss | 3 - .../mail/static/src/discuss_app/im_status.xml | 11 +-- .../static/src/discuss_app/thread_icon.xml | 4 +- .../src/web/chat_window/chat_window.dark.scss | 3 - .../static/src/web/chat_window/chat_window.js | 2 + .../src/web/chat_window/chat_window.scss | 17 +++-- .../src/web/chat_window/chat_window.xml | 18 +++-- .../src/web/messaging_menu/messaging_menu.xml | 4 +- .../static/src/web/thread_service_patch.js | 7 ++ .../tests/chat_window/chat_window_tests.js | 2 +- .../static/tests/crosstab/crosstab_tests.js | 10 ++- .../static/tests/discuss_app/discuss_tests.js | 71 ++++++++++++------- .../tests/discuss_app/im_status_tests.js | 66 ++++++++--------- .../mock_server/models/discuss_channel.js | 16 +++++ .../static/tests/message/message_tests.js | 30 -------- addons/mail/views/discuss_channel_views.xml | 1 + .../tests/test_performance.py | 12 ++++ .../static/src/views/fields/file_handler.js | 4 ++ .../static/src/views/fields/file_handler.xml | 2 +- 36 files changed, 309 insertions(+), 183 deletions(-) create mode 100644 addons/im_livechat/static/src/discuss_app/discuss_patch.xml delete mode 100644 addons/mail/static/src/web/chat_window/chat_window.dark.scss diff --git a/addons/hr_holidays/static/src/im_status_patch.xml b/addons/hr_holidays/static/src/im_status_patch.xml index 31fa8f87373..afc0998a6b5 100644 --- a/addons/hr_holidays/static/src/im_status_patch.xml +++ b/addons/hr_holidays/static/src/im_status_patch.xml @@ -1,10 +1,11 @@ - + - + $0 + diff --git a/addons/hr_holidays/static/tests/im_status_patch_tests.js b/addons/hr_holidays/static/tests/im_status_patch_tests.js index 1f61b6dab3f..33ea30ed881 100644 --- a/addons/hr_holidays/static/tests/im_status_patch_tests.js +++ b/addons/hr_holidays/static/tests/im_status_patch_tests.js @@ -1,61 +1,51 @@ /** @odoo-module **/ -import { UPDATE_BUS_PRESENCE_DELAY } from "@bus/im_status_service"; - import { start, startServer } from "@mail/../tests/helpers/test_utils"; +import { Command } from "@mail/../tests/helpers/command"; QUnit.module("im_status"); QUnit.test("on leave & online", async (assert) => { const pyEnv = await startServer(); const partnerId = pyEnv["res.partner"].create({ name: "Demo", im_status: "leave_online" }); - const channelId = pyEnv["discuss.channel"].create({ name: "General" }); - pyEnv["mail.message"].create({ - author_id: partnerId, - body: "not empty", - model: "discuss.channel", - res_id: channelId, - }); - const { advanceTime, afterNextRender, openDiscuss } = await start({ - hasTimeControl: true, + const channelId = pyEnv["discuss.channel"].create({ + channel_member_ids: [ + Command.create({ partner_id: pyEnv.currentPartnerId }), + Command.create({ partner_id: partnerId }), + ], + channel_type: "chat", }); + const { openDiscuss } = await start(); await openDiscuss(channelId); - await afterNextRender(() => advanceTime(UPDATE_BUS_PRESENCE_DELAY)); assert.containsOnce($, ".o-mail-ImStatus i.fa-plane[title='Online']"); }); QUnit.test("on leave & away", async (assert) => { const pyEnv = await startServer(); const partnerId = pyEnv["res.partner"].create({ name: "Demo", im_status: "leave_away" }); - const channelId = pyEnv["discuss.channel"].create({ name: "General" }); - pyEnv["mail.message"].create({ - author_id: partnerId, - body: "not empty", - model: "discuss.channel", - res_id: channelId, - }); - const { advanceTime, afterNextRender, openDiscuss } = await start({ - hasTimeControl: true, + const channelId = pyEnv["discuss.channel"].create({ + channel_member_ids: [ + Command.create({ partner_id: pyEnv.currentPartnerId }), + Command.create({ partner_id: partnerId }), + ], + channel_type: "chat", }); + const { openDiscuss } = await start(); await openDiscuss(channelId); - await afterNextRender(() => advanceTime(UPDATE_BUS_PRESENCE_DELAY)); assert.containsOnce($, ".o-mail-ImStatus i.fa-plane[title='Idle']"); }); QUnit.test("on leave & offline", async (assert) => { const pyEnv = await startServer(); const partnerId = pyEnv["res.partner"].create({ name: "Demo", im_status: "leave_offline" }); - const channelId = pyEnv["discuss.channel"].create({ name: "General" }); - pyEnv["mail.message"].create({ - author_id: partnerId, - body: "not empty", - model: "discuss.channel", - res_id: channelId, - }); - const { advanceTime, afterNextRender, openDiscuss } = await start({ - hasTimeControl: true, + const channelId = pyEnv["discuss.channel"].create({ + channel_member_ids: [ + Command.create({ partner_id: pyEnv.currentPartnerId }), + Command.create({ partner_id: partnerId }), + ], + channel_type: "chat", }); + const { openDiscuss } = await start(); await openDiscuss(channelId); - await afterNextRender(() => advanceTime(UPDATE_BUS_PRESENCE_DELAY)); assert.containsOnce($, ".o-mail-ImStatus i.fa-plane[title='Out of office']"); }); diff --git a/addons/im_livechat/static/src/discuss_app/discuss_patch.xml b/addons/im_livechat/static/src/discuss_app/discuss_patch.xml new file mode 100644 index 00000000000..347509862a2 --- /dev/null +++ b/addons/im_livechat/static/src/discuss_app/discuss_patch.xml @@ -0,0 +1,8 @@ + + + + + thread and thread.model === 'discuss.channel' and thread.type !== 'livechat' + + + diff --git a/addons/mail/controllers/discuss/binary.py b/addons/mail/controllers/discuss/binary.py index bd421c7fea2..2220281bbe3 100644 --- a/addons/mail/controllers/discuss/binary.py +++ b/addons/mail/controllers/discuss/binary.py @@ -75,6 +75,26 @@ class BinaryController(http.Controller): raise NotFound() return request.env["ir.binary"]._get_stream_from(attachment_sudo).get_response(as_attachment=download) + @http.route( + "/discuss/channel//avatar_128", + methods=["GET"], + type="http", + auth="public", + ) + def discuss_channel_avatar_128(self, channel_id): + channel_member_sudo = request.env["discuss.channel.member"]._get_as_sudo_from_request_or_raise( + request=request, channel_id=channel_id + ) + domain = [("id", "=", channel_id)] + channel_sudo = channel_member_sudo.env["discuss.channel"].search(domain, limit=1) + if not channel_sudo: + raise NotFound() + return ( + request.env["ir.binary"] + ._get_image_stream_from(channel_sudo, field_name="avatar_128") + .get_response() + ) + @http.route( [ "/discuss/channel//image/", @@ -86,11 +106,11 @@ class BinaryController(http.Controller): ) def fetch_image(self, channel_id, attachment_id, width=0, height=0, **kwargs): channel_member_sudo = request.env["discuss.channel.member"]._get_as_sudo_from_request_or_raise( - request=request, channel_id=int(channel_id) + request=request, channel_id=channel_id ) domain = [ - ("id", "=", int(attachment_id)), - ("res_id", "=", int(channel_id)), + ("id", "=", attachment_id), + ("res_id", "=", channel_id), ("res_model", "=", "discuss.channel"), ] attachment_sudo = channel_member_sudo.env["ir.attachment"].search(domain, limit=1) @@ -98,6 +118,6 @@ class BinaryController(http.Controller): raise NotFound() return ( request.env["ir.binary"] - ._get_image_stream_from(attachment_sudo, width=int(width), height=int(height)) + ._get_image_stream_from(attachment_sudo, width=width, height=height) .get_response(as_attachment=kwargs.get("download")) ) diff --git a/addons/mail/controllers/discuss/channel.py b/addons/mail/controllers/discuss/channel.py index 4c5ea52eff6..5c3442b1692 100644 --- a/addons/mail/controllers/discuss/channel.py +++ b/addons/mail/controllers/discuss/channel.py @@ -38,6 +38,13 @@ class ChannelController(http.Controller): except UserError: raise NotFound() + @http.route("/discuss/channel/update_avatar", methods=["POST"], type="json", auth="public") + def discuss_channel_avatar_update(self, channel_id, data): + channel = request.env["discuss.channel"].browse(int(channel_id)).exists() + if not channel or not data: + raise NotFound() + channel.write({"image_128": data}) + @http.route("/discuss/channel/messages", methods=["POST"], type="json", auth="public") def discuss_channel_messages(self, channel_id, before=None, after=None, limit=30, around=None): channel_member_sudo = request.env["discuss.channel.member"]._get_as_sudo_from_request_or_raise( diff --git a/addons/mail/models/discuss/discuss_channel.py b/addons/mail/models/discuss/discuss_channel.py index eaf88aaede6..fff94698ff2 100644 --- a/addons/mail/models/discuss/discuss_channel.py +++ b/addons/mail/models/discuss/discuss_channel.py @@ -54,6 +54,7 @@ class Channel(models.Model): ('group', 'Group')], string='Channel Type', required=True, default='channel', readonly=True, help="Chat is private and unique between 2 persons. Group is private among invited persons. Channel can be freely joined (depending on its configuration).") is_chat = fields.Boolean(string='Is a chat', compute='_compute_is_chat') + is_editable = fields.Boolean('Is Editable', compute='_compute_is_editable') default_display_mode = fields.Selection(string="Default Display Mode", selection=[('video_full_screen', "Full screen video")], help="Determines how the channel will be displayed by default when opening it from its invitation link. No value means display text (no voice/video).") description = fields.Text('Description') image_128 = fields.Image("Image", max_width=128, max_height=128) @@ -109,6 +110,18 @@ class Channel(models.Model): for record in self: record.is_chat = record.channel_type == 'chat' + @api.depends('channel_type', 'is_member') + def _compute_is_editable(self): + for channel in self: + if channel.channel_type == 'channel': + channel.is_editable = self.sudo(False).env.is_admin() or channel.create_uid.id == self.env.user.id + elif channel.channel_type == 'chat': + channel.is_editable = True + elif channel.channel_type == 'group': + channel.is_editable = channel.is_member and not self.sudo(False).env.user._is_public() + else: + channel.is_editable = False + @api.depends('channel_type', 'image_128', 'uuid') def _compute_avatar_128(self): for record in self: @@ -776,6 +789,7 @@ class Channel(models.Model): 'description': channel.description, 'uuid': channel.uuid, 'state': 'open', + 'is_editable': channel.is_editable, 'is_minimized': False, 'group_based_subscription': bool(channel.group_ids), 'create_uid': channel.create_uid.id, diff --git a/addons/mail/static/src/core/thread_model.js b/addons/mail/static/src/core/thread_model.js index 660d0daab52..f9c482ae90b 100644 --- a/addons/mail/static/src/core/thread_model.js +++ b/addons/mail/static/src/core/thread_model.js @@ -124,6 +124,8 @@ export class Thread { last_interest_dt; /** @type {number} */ lastServerMessageId; + /** @type {Boolean} */ + is_editable; constructor(store, data) { Object.assign(this, { @@ -264,12 +266,8 @@ export class Thread { ); } - get isDescriptionChangeable() { - return !this._store.guest && ["channel", "group"].includes(this.type); - } - - get isRenameable() { - return !this._store.guest && ["chat", "channel", "group"].includes(this.type); + get allowDescription() { + return ["channel", "group"].includes(this.type); } get isTransient() { diff --git a/addons/mail/static/src/core/thread_service.js b/addons/mail/static/src/core/thread_service.js index 069fb535742..226d79dbbe1 100644 --- a/addons/mail/static/src/core/thread_service.js +++ b/addons/mail/static/src/core/thread_service.js @@ -699,6 +699,7 @@ export class ThreadService { "type", "group_based_subscription", "last_interest_dt", + "is_editable", "defaultDisplayMode", ]); if (serverData.channel && "message_unread_counter" in serverData.channel) { diff --git a/addons/mail/static/src/core_ui/message.js b/addons/mail/static/src/core_ui/message.js index ce9bd1a2d27..9b1ab2a4971 100644 --- a/addons/mail/static/src/core_ui/message.js +++ b/addons/mail/static/src/core_ui/message.js @@ -465,7 +465,7 @@ export class Message extends Component { } get imStatusClassName() { - let res = "position-absolute"; + let res = "position-absolute bottom-0 end-0"; if (this.hasOpenChatFeature) { res += " cursor-pointer"; } diff --git a/addons/mail/static/src/core_ui/message.xml b/addons/mail/static/src/core_ui/message.xml index 3e36c683d9a..a618927c850 100644 --- a/addons/mail/static/src/core_ui/message.xml +++ b/addons/mail/static/src/core_ui/message.xml @@ -31,7 +31,6 @@ t-att-title="authorText" t-att-src="authorAvatarUrl" t-att-class="{ 'o_redirect cursor-pointer': hasAuthorClickable }"/> - diff --git a/addons/mail/static/src/discuss/web/chat_window_patch.xml b/addons/mail/static/src/discuss/web/chat_window_patch.xml index 8eed76d6875..c668915e69e 100644 --- a/addons/mail/static/src/discuss/web/chat_window_patch.xml +++ b/addons/mail/static/src/discuss/web/chat_window_patch.xml @@ -2,15 +2,15 @@ -
+
-
+
-
+
diff --git a/addons/mail/static/src/discuss_app/autoresize_input.js b/addons/mail/static/src/discuss_app/autoresize_input.js index 56efa0a2d84..401f76bd98b 100644 --- a/addons/mail/static/src/discuss_app/autoresize_input.js +++ b/addons/mail/static/src/discuss_app/autoresize_input.js @@ -8,13 +8,14 @@ export class AutoresizeInput extends Component { static template = "mail.AutoresizeInput"; static props = { className: { type: String, optional: true }, - disabled: { type: Boolean, optional: true }, + enabled: { optional: true }, onValidate: { type: Function, optional: true }, placeholder: { type: String, optional: true }, value: { type: String }, }; static defaultProps = { className: "", + enabled: true, onValidate: () => {}, placeholder: "", }; diff --git a/addons/mail/static/src/discuss_app/autoresize_input.xml b/addons/mail/static/src/discuss_app/autoresize_input.xml index b619da05bfa..6484a15f4ba 100644 --- a/addons/mail/static/src/discuss_app/autoresize_input.xml +++ b/addons/mail/static/src/discuss_app/autoresize_input.xml @@ -3,10 +3,10 @@ { if ( @@ -100,6 +106,12 @@ export class Discuss extends Component { return this.store.threads[this.store.discuss.threadLocalId]; } + get channelAvatar() { + return this.props.public + ? url(`/discuss/channel/${this.thread.id}/avatar_128`) + : this.thread.imgUrl; + } + togglePinMenu() { this.state.activeMode = this.state.activeMode === this.MODES.PINNED_MESSAGES @@ -107,6 +119,11 @@ export class Discuss extends Component { : this.MODES.PINNED_MESSAGES; } + async onFileUploaded(file) { + await this.threadService.updateAvatar(this.thread.id, file.data); + this.notification.add(_t("The avatar has been updated!"), { type: "success" }); + } + async renameThread({ value: name }) { const newName = name.trim(); if ( diff --git a/addons/mail/static/src/discuss_app/discuss.scss b/addons/mail/static/src/discuss_app/discuss.scss index d77b31c6815..376ccd92f61 100644 --- a/addons/mail/static/src/discuss_app/discuss.scss +++ b/addons/mail/static/src/discuss_app/discuss.scss @@ -28,3 +28,24 @@ background-color: $gray-400; } } + +.o-mail-Discuss-threadAvatar { + img { + height: 36px; + width: 36px; + } + + a { + i { + transform: translate(-50%, -50%); + opacity: 0; + } + + &:hover { + background-color: rgba($black, 0.5); + i { + opacity: 1; + } + } + } +} diff --git a/addons/mail/static/src/discuss_app/discuss.xml b/addons/mail/static/src/discuss_app/discuss.xml index 4798502c675..40ed6520a1e 100644 --- a/addons/mail/static/src/discuss_app/discuss.xml +++ b/addons/mail/static/src/discuss_app/discuss.xml @@ -5,22 +5,36 @@
-
- +
+
+ Thread Image + + + + + + + +
+ + + +
- +
Add a description diff --git a/addons/mail/static/src/discuss_app/im_status.js b/addons/mail/static/src/discuss_app/im_status.js index a451c1855b8..434e33d88c1 100644 --- a/addons/mail/static/src/discuss_app/im_status.js +++ b/addons/mail/static/src/discuss_app/im_status.js @@ -3,7 +3,7 @@ import { Component } from "@odoo/owl"; export class ImStatus extends Component { - static props = ["persona", "className?"]; + static props = ["persona", "className?", "style?"]; static template = "mail.ImStatus"; - static defaultProps = { className: "" }; + static defaultProps = { className: "", style: "" }; } diff --git a/addons/mail/static/src/discuss_app/im_status.scss b/addons/mail/static/src/discuss_app/im_status.scss index 2b25307c894..b7894fa293f 100644 --- a/addons/mail/static/src/discuss_app/im_status.scss +++ b/addons/mail/static/src/discuss_app/im_status.scss @@ -1,8 +1,5 @@ .o-mail-ImStatus { - $-gap: $font-size-lg - $font-size-base; - - @include o-position-absolute($right: -$-gap, $bottom: -$-gap); width: $font-size-lg; height: $font-size-lg; } diff --git a/addons/mail/static/src/discuss_app/im_status.xml b/addons/mail/static/src/discuss_app/im_status.xml index 0e8647d122f..f53af536142 100644 --- a/addons/mail/static/src/discuss_app/im_status.xml +++ b/addons/mail/static/src/discuss_app/im_status.xml @@ -2,12 +2,13 @@ -
- +
+ - - - + + + +
diff --git a/addons/mail/static/src/discuss_app/thread_icon.xml b/addons/mail/static/src/discuss_app/thread_icon.xml index 10ccffff1e4..c5b2897d4c7 100644 --- a/addons/mail/static/src/discuss_app/thread_icon.xml +++ b/addons/mail/static/src/discuss_app/thread_icon.xml @@ -14,9 +14,7 @@
- -
- +
diff --git a/addons/mail/static/src/web/chat_window/chat_window.dark.scss b/addons/mail/static/src/web/chat_window/chat_window.dark.scss deleted file mode 100644 index a86542fcd3c..00000000000 --- a/addons/mail/static/src/web/chat_window/chat_window.dark.scss +++ /dev/null @@ -1,3 +0,0 @@ -.o-mail-ChatWindow { - --o-mail-ChatWindow-header-background-color: #{$o-gray-100}; -} \ No newline at end of file diff --git a/addons/mail/static/src/web/chat_window/chat_window.js b/addons/mail/static/src/web/chat_window/chat_window.js index 181985b3f2c..d09826385da 100644 --- a/addons/mail/static/src/web/chat_window/chat_window.js +++ b/addons/mail/static/src/web/chat_window/chat_window.js @@ -10,6 +10,7 @@ import { Component, useChildSubEnv, useRef, useState } from "@odoo/owl"; import { useService } from "@web/core/utils/hooks"; import { localization } from "@web/core/l10n/localization"; import { ThreadIcon } from "@mail/discuss_app/thread_icon"; +import { ImStatus } from "@mail/discuss_app/im_status"; import { isEventHandled } from "@mail/utils/misc"; import { ChannelSelector } from "@mail/discuss_app/channel_selector"; import { PinnedMessagesPanel } from "@mail/discuss_app/pinned_messages_panel"; @@ -28,6 +29,7 @@ export class ChatWindow extends Component { ChannelSelector, Composer, ThreadIcon, + ImStatus, PinnedMessagesPanel, }; static props = ["chatWindow", "right?"]; diff --git a/addons/mail/static/src/web/chat_window/chat_window.scss b/addons/mail/static/src/web/chat_window/chat_window.scss index 988139f7aa6..70d2eb165e5 100644 --- a/addons/mail/static/src/web/chat_window/chat_window.scss +++ b/addons/mail/static/src/web/chat_window/chat_window.scss @@ -1,21 +1,26 @@ .o-mail-ChatWindow { - height: 460px; + height: 480px; width: $o-mail-ChatWindow-width; z-index: 999; // messaging menu is dropdown (1000) - background-color: var(--o-mail-ChatWindow-header-background-color, #{$o-brand-odoo}); box-shadow: -5px -5px 10px rgba(#000000, 0.09); outline: none; &.o-folded { - height: 36px; + height: 48px; } .o-mail-ChatWindow-command:hover { - background-color: rgba(0, 0, 0, 0.1); + background-color: rgba(0, 0, 0, 0.075); } } .o-mail-ChatWindow-header { - height: 36px; - color: #ffffff; + height: 48px; + + .o-mail-ChatWindow-threadAvatar { + img { + height: 28px; + width: 28px; + } + } } diff --git a/addons/mail/static/src/web/chat_window/chat_window.xml b/addons/mail/static/src/web/chat_window/chat_window.xml index 35057606aba..b970743ed83 100644 --- a/addons/mail/static/src/web/chat_window/chat_window.xml +++ b/addons/mail/static/src/web/chat_window/chat_window.xml @@ -13,11 +13,17 @@ t-on-keydown.stop="onKeydown" tabindex="1" > -
+
- +
+ Thread Image +
+ + + + )
-
+
-
+
-
+
-
+
diff --git a/addons/mail/static/src/web/messaging_menu/messaging_menu.xml b/addons/mail/static/src/web/messaging_menu/messaging_menu.xml index e52c9d28d88..e228c696f24 100644 --- a/addons/mail/static/src/web/messaging_menu/messaging_menu.xml +++ b/addons/mail/static/src/web/messaging_menu/messaging_menu.xml @@ -57,7 +57,7 @@ onClick="() => notification.requestPermission()" > - + @@ -89,7 +89,7 @@ onSwipeLeft="hasTouch() and threadService.canUnpin(thread) ? { action: () => this.threadService.unpin(thread), icon: 'fa-times-circle', bgColor: 'bg-danger' } : undefined" > - + diff --git a/addons/mail/static/src/web/thread_service_patch.js b/addons/mail/static/src/web/thread_service_patch.js index f54cf2c8ec7..10df3d3e0d5 100644 --- a/addons/mail/static/src/web/thread_service_patch.js +++ b/addons/mail/static/src/web/thread_service_patch.js @@ -199,6 +199,13 @@ patch(ThreadService.prototype, "mail/web", { } delete this.store.followers[follower.id]; }, + + async updateAvatar(threadId, data) { + return this.rpc("/discuss/channel/update_avatar", { + channel_id: threadId, + data, + }); + }, }); patch(threadService, "mail/web", { diff --git a/addons/mail/static/tests/chat_window/chat_window_tests.js b/addons/mail/static/tests/chat_window/chat_window_tests.js index 66f778056a0..50134d7fd7c 100644 --- a/addons/mail/static/tests/chat_window/chat_window_tests.js +++ b/addons/mail/static/tests/chat_window/chat_window_tests.js @@ -100,7 +100,7 @@ QUnit.test("chat window: basic rendering", async (assert) => { await click(".o-mail-NotificationItem"); assert.containsOnce($, ".o-mail-ChatWindow"); assert.containsOnce($, ".o-mail-ChatWindow-header"); - assert.containsOnce($(".o-mail-ChatWindow-header"), ".o-mail-ThreadIcon"); + assert.containsOnce($(".o-mail-ChatWindow-header"), ".o-mail-ChatWindow-threadAvatar"); assert.containsOnce($, ".o-mail-ChatWindow-name:contains(General)"); assert.containsN($, ".o-mail-ChatWindow-command", 5); assert.containsOnce($, ".o-mail-ChatWindow-command[title='Start a Call']"); diff --git a/addons/mail/static/tests/crosstab/crosstab_tests.js b/addons/mail/static/tests/crosstab/crosstab_tests.js index e331de1f8f3..3852aed1985 100644 --- a/addons/mail/static/tests/crosstab/crosstab_tests.js +++ b/addons/mail/static/tests/crosstab/crosstab_tests.js @@ -56,7 +56,10 @@ QUnit.test("Delete starred message updates counter", async (assert) => { QUnit.test("Thread rename", async (assert) => { const pyEnv = await startServer(); - const channelId = pyEnv["discuss.channel"].create({ name: "General" }); + const channelId = pyEnv["discuss.channel"].create({ + create_uid: pyEnv.currentPartnerId, + name: "General", + }); const tab1 = await start({ asTab: true }); const tab2 = await start({ asTab: true }); await tab1.openDiscuss(channelId); @@ -69,7 +72,10 @@ QUnit.test("Thread rename", async (assert) => { QUnit.test("Thread description update", async (assert) => { const pyEnv = await startServer(); - const channelId = pyEnv["discuss.channel"].create({ name: "General" }); + const channelId = pyEnv["discuss.channel"].create({ + create_uid: pyEnv.currentPartnerId, + name: "General", + }); const tab1 = await start({ asTab: true }); const tab2 = await start({ asTab: true }); await tab1.openDiscuss(channelId); diff --git a/addons/mail/static/tests/discuss_app/discuss_tests.js b/addons/mail/static/tests/discuss_app/discuss_tests.js index 12443fdd6a8..7e4a5a12b32 100644 --- a/addons/mail/static/tests/discuss_app/discuss_tests.js +++ b/addons/mail/static/tests/discuss_app/discuss_tests.js @@ -827,22 +827,25 @@ QUnit.test("rendering of inbox message", async (assert) => { assert.containsOnce($message, "[title='Mark as Read']"); }); -QUnit.test('Unfollow message', async function (assert) { +QUnit.test("Unfollow message", async function (assert) { assert.expect(12); const pyEnv = await startServer(); const currentPartnerId = pyEnv.currentPartnerId; - const [threadFollowedId, threadNotFollowedId] = pyEnv["res.partner"].create([{ - name: 'Thread followed', - }, { - name: 'Thread not followed', - }]); + const [threadFollowedId, threadNotFollowedId] = pyEnv["res.partner"].create([ + { + name: "Thread followed", + }, + { + name: "Thread not followed", + }, + ]); pyEnv["mail.followers"].create({ partner_id: currentPartnerId, res_id: threadFollowedId, res_model: "res.partner", }); - for (let threadId of [threadFollowedId, threadFollowedId, threadNotFollowedId]) { + for (const threadId of [threadFollowedId, threadFollowedId, threadNotFollowedId]) { const messageId = pyEnv["mail.message"].create({ body: "not empty", model: "res.partner", @@ -861,14 +864,17 @@ QUnit.test('Unfollow message', async function (assert) { await openDiscuss(); // 2 messages about "Thread followed" with unfollow button and 1 message about "Thread not followed" without it assert.containsN($, ".o-mail-Message", 3); - for (let messageN of [0, 1]) { + for (const messageN of [0, 1]) { const $message = $(`.o-mail-Message:eq(${messageN})`); assert.containsOnce($message, ".o-mail-Message-header:contains(on Thread followed)"); await afterNextRender(() => $message.find("[title='Expand']").click()); assert.containsOnce($message, "[title='Unfollow']"); } const $messageNotFollowed = $(".o-mail-Message:eq(2)"); - assert.containsOnce($messageNotFollowed, ".o-mail-Message-header:contains(on Thread not followed)"); + assert.containsOnce( + $messageNotFollowed, + ".o-mail-Message-header:contains(on Thread not followed)" + ); await afterNextRender(() => $messageNotFollowed.find("[title='Expand']").click()); assert.containsNone($messageNotFollowed, "[title='Unfollow']"); @@ -876,14 +882,22 @@ QUnit.test('Unfollow message', async function (assert) { await afterNextRender(() => $message0Followed.find("[title='Expand']").click()); await afterNextRender(() => $message0Followed.find("[title='Unfollow']").click()); - assert.containsN($, ".o-mail-Message", 2, "Unfollowing message 0 marks it as read -> Message removed"); + assert.containsN( + $, + ".o-mail-Message", + 2, + "Unfollowing message 0 marks it as read -> Message removed" + ); assert.containsOnce($, ".o-mail-Message-header:contains(on Thread followed)"); assert.containsOnce($, ".o-mail-Message-header:contains(on Thread not followed)"); - for (let messageN of [0, 1]) { + for (const messageN of [0, 1]) { const $message = $(`.o-mail-Message:eq(${messageN})`); await afterNextRender(() => $message.find("[title='Expand']").click()); - assert.containsNone($, "button[title='Unfollow']", - "Unfollowing message 0 -> unfollowing 'Thread followed' -> no more unfollow action on any message"); + assert.containsNone( + $, + "button[title='Unfollow']", + "Unfollowing message 0 -> unfollowing 'Thread followed' -> no more unfollow action on any message" + ); } }); @@ -1301,7 +1315,7 @@ QUnit.test( ); QUnit.test( - "'Hashtag' thread icon is displayed in top bar of channels of type 'channel' limited to a group", + "Thread avatar image is displayed in top bar of channels of type 'channel' limited to a group", async (assert) => { const pyEnv = await startServer(); const groupId = pyEnv["res.groups"].create({ name: "testGroup" }); @@ -1312,12 +1326,12 @@ QUnit.test( }); const { openDiscuss } = await start(); await openDiscuss(channelId); - assert.containsOnce($, ".o-mail-Discuss-content .fa-hashtag"); + assert.containsOnce($, ".o-mail-Discuss-header .o-mail-Discuss-threadAvatar"); } ); QUnit.test( - "'Globe' thread icon is displayed in top bar of channels of type 'channel' not limited to any group", + "Thread avatar image is displayed in top bar of channels of type 'channel' not limited to any group", async (assert) => { const pyEnv = await startServer(); const channelId = pyEnv["discuss.channel"].create({ @@ -1327,7 +1341,7 @@ QUnit.test( }); const { openDiscuss } = await start(); await openDiscuss(channelId); - assert.containsOnce($, ".o-mail-Discuss-content .fa-globe"); + assert.containsOnce($, ".o-mail-Discuss-header .o-mail-Discuss-threadAvatar"); } ); @@ -1338,7 +1352,7 @@ QUnit.test( const [partnerId_1, partnerId_2, partnerId_3, partnerId_4] = pyEnv["res.partner"].create([ { im_status: "online", name: "Michel Online" }, { im_status: "offline", name: "Jacqueline Offline" }, - { im_status: "away", name: "Nabuchodonosor Away" }, + { im_status: "away", name: "Nabuchodonosor Idle" }, { im_status: "im_partner", name: "Robert Fired" }, ]); pyEnv["discuss.channel"].create([ @@ -1381,29 +1395,29 @@ QUnit.test( const { openDiscuss } = await start(); await openDiscuss(); await click(".o-mail-DiscussCategoryItem:contains('Michel Online')"); - assert.containsOnce($, ".o-mail-Discuss-header .o-mail-ThreadIcon [title='Online']"); + assert.containsOnce($, ".o-mail-Discuss-header .o-mail-ImStatus [title='Online']"); await click(".o-mail-DiscussCategoryItem:contains('Jacqueline Offline')"); - assert.containsOnce($, ".o-mail-Discuss-header .o-mail-ThreadIcon [title='Offline']"); - await click(".o-mail-DiscussCategoryItem:contains('Nabuchodonosor Away')"); - assert.containsOnce($, ".o-mail-Discuss-header .o-mail-ThreadIcon [title='Away']"); + assert.containsOnce($, ".o-mail-Discuss-header .o-mail-ImStatus [title='Offline']"); + await click(".o-mail-DiscussCategoryItem:contains('Nabuchodonosor Idle')"); + assert.containsOnce($, ".o-mail-Discuss-header .o-mail-ImStatus [title='Idle']"); await click(".o-mail-DiscussCategoryItem:contains('Robert Fired')"); assert.containsOnce( $, - ".o-mail-Discuss-header .o-mail-ThreadIcon [title='No IM status available']" + ".o-mail-Discuss-header .o-mail-ImStatus [title='No IM status available']" ); await click(".o-mail-DiscussCategoryItem:contains('OdooBot')"); - assert.containsOnce($, ".o-mail-Discuss-header .o-mail-ThreadIcon [title='Bot']"); + assert.containsOnce($, ".o-mail-Discuss-header .o-mail-ImStatus [title='Bot']"); } ); QUnit.test( - "'Users' thread icon is displayed in top bar of channels of type 'group'", + "Thread avatar image is displayed in top bar of channels of type 'group'", async (assert) => { const pyEnv = await startServer(); const channelId = pyEnv["discuss.channel"].create({ channel_type: "group" }); const { openDiscuss } = await start(); await openDiscuss(channelId); - assert.containsOnce($, ".o-mail-Discuss-header .fa-users[title='Grouped Chat']"); + assert.containsOnce($, ".o-mail-Discuss-header .o-mail-Discuss-threadAvatar"); } ); @@ -1430,7 +1444,10 @@ QUnit.test( "Do not trigger channel description server update when channel has no description and editing to empty description", async (assert) => { const pyEnv = await startServer(); - const channelId = pyEnv["discuss.channel"].create({ name: "General" }); + const channelId = pyEnv["discuss.channel"].create({ + create_uid: pyEnv.currentPartnerId, + name: "General", + }); const { openDiscuss } = await start({ mockRPC(route, args, originalRPC) { if (args.method === "channel_change_description") { diff --git a/addons/mail/static/tests/discuss_app/im_status_tests.js b/addons/mail/static/tests/discuss_app/im_status_tests.js index 856d5c3e15e..332b14e0a9e 100644 --- a/addons/mail/static/tests/discuss_app/im_status_tests.js +++ b/addons/mail/static/tests/discuss_app/im_status_tests.js @@ -11,65 +11,61 @@ QUnit.module("im status"); QUnit.test("initially online", async (assert) => { const pyEnv = await startServer(); - const partnerId = pyEnv["res.partner"].create({ im_status: "online" }); - const channelId = pyEnv["discuss.channel"].create({ name: "TestChanel" }); - pyEnv["mail.message"].create({ - author_id: partnerId, - body: "not empty", - model: "discuss.channel", - res_id: channelId, + const partnerId = pyEnv["res.partner"].create({ name: "Demo", im_status: "online" }); + const channelId = pyEnv["discuss.channel"].create({ + channel_member_ids: [ + Command.create({ partner_id: pyEnv.currentPartnerId }), + Command.create({ partner_id: partnerId }), + ], + channel_type: "chat", }); - const { advanceTime, openDiscuss } = await start({ hasTimeControl: true }); + const { openDiscuss } = await start(); await openDiscuss(channelId); - await afterNextRender(() => advanceTime(UPDATE_BUS_PRESENCE_DELAY)); assert.containsOnce($, ".o-mail-ImStatus i[title='Online']"); }); QUnit.test("initially offline", async (assert) => { const pyEnv = await startServer(); - const partnerId = pyEnv["res.partner"].create({ im_status: "offline" }); - const channelId = pyEnv["discuss.channel"].create({ name: "TestChannel" }); - pyEnv["mail.message"].create({ - author_id: partnerId, - body: "not empty", - model: "discuss.channel", - res_id: channelId, + const partnerId = pyEnv["res.partner"].create({ name: "Demo", im_status: "offline" }); + const channelId = pyEnv["discuss.channel"].create({ + channel_member_ids: [ + Command.create({ partner_id: pyEnv.currentPartnerId }), + Command.create({ partner_id: partnerId }), + ], + channel_type: "chat", }); - const { advanceTime, openDiscuss } = await start({ hasTimeControl: true }); + const { openDiscuss } = await start(); await openDiscuss(channelId); - await afterNextRender(() => advanceTime(UPDATE_BUS_PRESENCE_DELAY)); assert.containsOnce($, ".o-mail-ImStatus i[title='Offline']"); }); QUnit.test("initially away", async (assert) => { const pyEnv = await startServer(); - const partnerId = pyEnv["res.partner"].create({ im_status: "away" }); - const channelId = pyEnv["discuss.channel"].create({ name: "TestChanel" }); - pyEnv["mail.message"].create({ - author_id: partnerId, - body: "not empty", - model: "discuss.channel", - res_id: channelId, + const partnerId = pyEnv["res.partner"].create({ name: "Demo", im_status: "away" }); + const channelId = pyEnv["discuss.channel"].create({ + channel_member_ids: [ + Command.create({ partner_id: pyEnv.currentPartnerId }), + Command.create({ partner_id: partnerId }), + ], + channel_type: "chat", }); - const { advanceTime, openDiscuss } = await start({ hasTimeControl: true }); + const { openDiscuss } = await start(); await openDiscuss(channelId); - await afterNextRender(() => advanceTime(UPDATE_BUS_PRESENCE_DELAY)); assert.containsOnce($, ".o-mail-ImStatus i[title='Idle']"); }); QUnit.test("change icon on change partner im_status", async (assert) => { const pyEnv = await startServer(); - const partnerId = pyEnv["res.partner"].create({ im_status: "online" }); - const channelId = pyEnv["discuss.channel"].create({ name: "TestChannel" }); - pyEnv["mail.message"].create({ - author_id: partnerId, - body: "not empty", - model: "discuss.channel", - res_id: channelId, + const partnerId = pyEnv["res.partner"].create({ name: "Demo", im_status: "online" }); + const channelId = pyEnv["discuss.channel"].create({ + channel_member_ids: [ + Command.create({ partner_id: pyEnv.currentPartnerId }), + Command.create({ partner_id: partnerId }), + ], + channel_type: "chat", }); const { advanceTime, openDiscuss } = await start({ hasTimeControl: true }); await openDiscuss(channelId); - await afterNextRender(() => advanceTime(UPDATE_BUS_PRESENCE_DELAY)); assert.containsOnce($, ".o-mail-ImStatus i[title='Online']"); pyEnv["res.partner"].write([partnerId], { im_status: "offline" }); diff --git a/addons/mail/static/tests/helpers/mock_server/models/discuss_channel.js b/addons/mail/static/tests/helpers/mock_server/models/discuss_channel.js index 2ee5e600f6c..6007c5bcbfa 100644 --- a/addons/mail/static/tests/helpers/mock_server/models/discuss_channel.js +++ b/addons/mail/static/tests/helpers/mock_server/models/discuss_channel.js @@ -628,6 +628,22 @@ patch(MockServer.prototype, "mail/models/discuss_channel", { ], ]; } + let is_editable; + switch (channel.channel_type) { + case "channel": + is_editable = channel.create_uid === this.pyEnv.currentPartnerId; + break; + case "chat": + is_editable = true; + break; + case "group": + is_editable = memberOfCurrentUser; + break; + default: + is_editable = false; + break; + } + res.is_editable = is_editable; res["rtcSessions"] = [ [ "insert", diff --git a/addons/mail/static/tests/message/message_tests.js b/addons/mail/static/tests/message/message_tests.js index 244083337cd..cfba345848f 100644 --- a/addons/mail/static/tests/message/message_tests.js +++ b/addons/mail/static/tests/message/message_tests.js @@ -1134,36 +1134,6 @@ QUnit.test("chat with author should be opened after clicking on their name", asy assert.containsOnce($, ".o-mail-ChatWindow-header:contains(Demo User)"); }); -QUnit.test( - "chat with author should be opened after clicking on their im status icon", - async (assert) => { - const pyEnv = await startServer(); - const [partnerId_1, partnerId_2] = pyEnv["res.partner"].create([ - { name: "Partner_1" }, - { name: "Partner_2", im_status: "online" }, - ]); - pyEnv["res.users"].create({ - im_status: "online", - partner_id: partnerId_2, - }); - pyEnv["mail.message"].create({ - author_id: partnerId_2, - body: "not empty", - model: "res.partner", - res_id: partnerId_1, - }); - const { advanceTime, openFormView } = await start({ hasTimeControl: true }); - await openFormView("res.partner", partnerId_1); - await afterNextRender(() => advanceTime(50 * 1000)); // next fetch of im_status - assert.containsOnce($, ".o-mail-ImStatus"); - assert.hasClass($(".o-mail-ImStatus"), "cursor-pointer"); - - await click(".o-mail-ImStatus"); - assert.containsOnce($, ".o-mail-ChatWindow"); - assert.containsOnce($, ".o-mail-ChatWindow-header:contains(Partner_2)"); - } -); - QUnit.test( "subtype description should be displayed if it is different than body", async (assert) => { diff --git a/addons/mail/views/discuss_channel_views.xml b/addons/mail/views/discuss_channel_views.xml index efb29bd74e5..a7214a89f6e 100644 --- a/addons/mail/views/discuss_channel_views.xml +++ b/addons/mail/views/discuss_channel_views.xml @@ -51,6 +51,7 @@
+
diff --git a/addons/test_discuss_full/tests/test_performance.py b/addons/test_discuss_full/tests/test_performance.py index 2adc05d964e..9e075510df9 100644 --- a/addons/test_discuss_full/tests/test_performance.py +++ b/addons/test_discuss_full/tests/test_performance.py @@ -150,6 +150,7 @@ class TestDiscussFullPerformance(TransactionCase): 'group_based_subscription': True, 'id': self.channel_general.id, 'invitedMembers': [('insert', [])], + 'is_editable': False, 'is_minimized': False, 'is_pinned': True, 'last_interest_dt': self.channel_general.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), @@ -199,6 +200,7 @@ class TestDiscussFullPerformance(TransactionCase): 'group_based_subscription': False, 'id': self.channel_channel_public_1.id, 'invitedMembers': [('insert', [])], + 'is_editable': True, 'is_minimized': False, 'is_pinned': True, 'last_interest_dt': self.channel_channel_public_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), @@ -248,6 +250,7 @@ class TestDiscussFullPerformance(TransactionCase): 'group_based_subscription': False, 'id': self.channel_channel_public_2.id, 'invitedMembers': [('insert', [])], + 'is_editable': True, 'is_minimized': False, 'is_pinned': True, 'last_interest_dt': self.channel_channel_public_2.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), @@ -297,6 +300,7 @@ class TestDiscussFullPerformance(TransactionCase): 'group_based_subscription': False, 'id': self.channel_channel_group_1.id, 'invitedMembers': [('insert', [])], + 'is_editable': True, 'is_minimized': False, 'is_pinned': True, 'last_interest_dt': self.channel_channel_group_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), @@ -346,6 +350,7 @@ class TestDiscussFullPerformance(TransactionCase): 'group_based_subscription': False, 'id': self.channel_channel_group_2.id, 'invitedMembers': [('insert', [])], + 'is_editable': True, 'is_minimized': False, 'is_pinned': True, 'last_interest_dt': self.channel_channel_group_2.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), @@ -417,6 +422,7 @@ class TestDiscussFullPerformance(TransactionCase): 'group_based_subscription': False, 'id': self.channel_group_1.id, 'invitedMembers': [('insert', [])], + 'is_editable': True, 'is_minimized': False, 'is_pinned': True, 'last_interest_dt': self.channel_group_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), @@ -502,6 +508,7 @@ class TestDiscussFullPerformance(TransactionCase): 'group_based_subscription': False, 'id': self.channel_chat_1.id, 'invitedMembers': [('insert', [])], + 'is_editable': True, 'is_minimized': False, 'is_pinned': True, 'last_interest_dt': self.channel_chat_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), @@ -587,6 +594,7 @@ class TestDiscussFullPerformance(TransactionCase): 'group_based_subscription': False, 'id': self.channel_chat_2.id, 'invitedMembers': [('insert', [])], + 'is_editable': True, 'is_minimized': False, 'is_pinned': True, 'last_interest_dt': self.channel_chat_2.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), @@ -672,6 +680,7 @@ class TestDiscussFullPerformance(TransactionCase): 'group_based_subscription': False, 'id': self.channel_chat_3.id, 'invitedMembers': [('insert', [])], + 'is_editable': True, 'is_minimized': False, 'is_pinned': True, 'last_interest_dt': self.channel_chat_3.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), @@ -757,6 +766,7 @@ class TestDiscussFullPerformance(TransactionCase): 'group_based_subscription': False, 'id': self.channel_chat_4.id, 'invitedMembers': [('insert', [])], + 'is_editable': True, 'is_minimized': False, 'is_pinned': True, 'last_interest_dt': self.channel_chat_4.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), @@ -836,6 +846,7 @@ class TestDiscussFullPerformance(TransactionCase): 'group_based_subscription': False, 'id': self.channel_livechat_1.id, 'invitedMembers': [('insert', [])], + 'is_editable': False, 'is_minimized': False, 'is_pinned': True, 'last_interest_dt': self.channel_livechat_1.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), @@ -915,6 +926,7 @@ class TestDiscussFullPerformance(TransactionCase): 'group_based_subscription': False, 'id': self.channel_livechat_2.id, 'invitedMembers': [('insert', [])], + 'is_editable': False, 'is_minimized': False, 'is_pinned': True, 'last_interest_dt': self.channel_livechat_2.channel_member_ids.filtered(lambda m: m.partner_id == self.users[0].partner_id).last_interest_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), diff --git a/addons/web/static/src/views/fields/file_handler.js b/addons/web/static/src/views/fields/file_handler.js index 1d8f64c7c0c..dfd0255c4be 100644 --- a/addons/web/static/src/views/fields/file_handler.js +++ b/addons/web/static/src/views/fields/file_handler.js @@ -92,4 +92,8 @@ FileUploader.props = { fileUploadClass: { type: String, optional: true }, acceptedFileExtensions: { type: String, optional: true }, slots: { type: Object, optional: true }, + showUploadingText: { type: Boolean, optional: true }, +}; +FileUploader.defaultProps = { + showUploadingText: true, }; diff --git a/addons/web/static/src/views/fields/file_handler.xml b/addons/web/static/src/views/fields/file_handler.xml index 6cf817bc9ed..58d9d89e631 100644 --- a/addons/web/static/src/views/fields/file_handler.xml +++ b/addons/web/static/src/views/fields/file_handler.xml @@ -2,7 +2,7 @@ - Uploading... + Uploading...