diff --git a/mail_attach_existing_attachment/README.rst b/mail_attach_existing_attachment/README.rst index 957a6d3b8..92c67bad6 100644 --- a/mail_attach_existing_attachment/README.rst +++ b/mail_attach_existing_attachment/README.rst @@ -55,6 +55,19 @@ To use this module, you need to: |image2| +The composer groups attachments under two headers, each folding away +when its title is clicked: + +- **New Attachments** lists the files added to this email as cards, and + only appears once there is at least one. +- **Available Attachments** is the picker for the files already on the + record. It starts folded, and its header shows how many files are on + offer, or how many are ticked once you fold it back. Unfolded, it lays + the checkboxes out in as many columns as the composer is wide enough + for. Filenames are truncated to keep one file per line; hovering one + shows its full name, its size, and a preview of the file when there is + one to show. + .. |image1| image:: https://raw.githubusercontent.com/OCA/mail/19.0/mail_attach_existing_attachment/static/description/attachment.png .. |image2| image:: https://raw.githubusercontent.com/OCA/mail/19.0/mail_attach_existing_attachment/static/description/ex_mail_compose_message.png @@ -101,6 +114,10 @@ Contributors - Frederic Grall +- `PureKarting `__: + + - Cliff Kujala + Maintainers ----------- diff --git a/mail_attach_existing_attachment/__manifest__.py b/mail_attach_existing_attachment/__manifest__.py index 8066499ba..e2c8cc8e8 100644 --- a/mail_attach_existing_attachment/__manifest__.py +++ b/mail_attach_existing_attachment/__manifest__.py @@ -7,9 +7,17 @@ "author": "ACSONE SA/NV, Tecnativa, Odoo Community Association (OCA)", "website": "https://github.com/OCA/mail", "category": "Social Network", - "version": "19.0.1.0.0", + "version": "19.0.2.0.0", "license": "AGPL-3", "depends": ["mail"], "data": ["wizard/mail_compose_message_view.xml"], + "assets": { + "web.assets_backend": [ + "mail_attach_existing_attachment/static/src/**/*", + ], + "web.assets_unit_tests": [ + "mail_attach_existing_attachment/static/tests/**/*", + ], + }, "installable": True, } diff --git a/mail_attach_existing_attachment/readme/CONTRIBUTORS.md b/mail_attach_existing_attachment/readme/CONTRIBUTORS.md index 74bed8a74..cec13eb75 100644 --- a/mail_attach_existing_attachment/readme/CONTRIBUTORS.md +++ b/mail_attach_existing_attachment/readme/CONTRIBUTORS.md @@ -7,3 +7,5 @@ - Andrea Stirpe - [Apik](https://www.aapik.cloud): - Frederic Grall +- [PureKarting](https://www.purekarting.com): + - Cliff Kujala diff --git a/mail_attach_existing_attachment/readme/USAGE.md b/mail_attach_existing_attachment/readme/USAGE.md index b1bdedd95..67b1749ce 100644 --- a/mail_attach_existing_attachment/readme/USAGE.md +++ b/mail_attach_existing_attachment/readme/USAGE.md @@ -8,3 +8,16 @@ To use this module, you need to: added earlier ![](../static/description/ex_mail_compose_message.png) + +The composer groups attachments under two headers, each folding away when +its title is clicked: + +- **New Attachments** lists the files added to this email as cards, and + only appears once there is at least one. +- **Available Attachments** is the picker for the files already on the + record. It starts folded, and its header shows how many files are on + offer, or how many are ticked once you fold it back. Unfolded, it lays + the checkboxes out in as many columns as the composer is wide enough + for. Filenames are truncated to keep one file per line; hovering one + shows its full name, its size, and a preview of the file when there is + one to show. diff --git a/mail_attach_existing_attachment/static/description/index.html b/mail_attach_existing_attachment/static/description/index.html index e31bb8f92..50f9f6924 100644 --- a/mail_attach_existing_attachment/static/description/index.html +++ b/mail_attach_existing_attachment/static/description/index.html @@ -404,6 +404,19 @@

Usage

added earlier

image2

+

The composer groups attachments under two headers, each folding away +when its title is clicked:

+

Known issues / Roadmap

@@ -447,6 +460,10 @@

Contributors

  • Frederic Grall
  • +
  • PureKarting:
      +
    • Cliff Kujala
    • +
    +
  • diff --git a/mail_attach_existing_attachment/static/src/attachment_checkbox_grid.esm.js b/mail_attach_existing_attachment/static/src/attachment_checkbox_grid.esm.js new file mode 100644 index 000000000..1d3329c4d --- /dev/null +++ b/mail_attach_existing_attachment/static/src/attachment_checkbox_grid.esm.js @@ -0,0 +1,134 @@ +import { + Many2ManyCheckboxesField, + many2ManyCheckboxesField, +} from "@web/views/fields/many2many_checkboxes/many2many_checkboxes_field"; +import {onWillUnmount, useState} from "@odoo/owl"; +import {CheckBox} from "@web/core/checkbox/checkbox"; +import {_t} from "@web/core/l10n/translation"; +import {debounce} from "@web/core/utils/timing"; +import {getFieldDomain} from "@web/model/relational_model/utils"; +import {humanSize} from "@web/core/utils/binary"; +import {imageUrl} from "@web/core/utils/urls"; +import {registry} from "@web/core/registry"; +import {useBus} from "@web/core/utils/hooks"; +import {useSpecialData} from "@web/views/fields/relational_utils"; + +/** + * Picker for the attachments already linked to the record being sent. + * + * Same selection semantics as core's ``many2many_checkboxes``, but laid out as + * a responsive grid behind a foldable header. A record with a long attachment + * history would otherwise push the composer's Send button off screen. + */ +export class AttachmentCheckboxGrid extends Many2ManyCheckboxesField { + static template = "mail_attach_existing_attachment.AttachmentCheckboxGrid"; + static components = {CheckBox}; + + setup() { + // Core's setup is deliberately not called. It loads the choices with + // ``name_search``, whose default limit of 100 silently drops the oldest + // attachments of a busy record, and it only gets the display name back + // - too little to tell apart the "image001.png" a mail thread + // accumulates. Everything else it does is the change bookkeeping + // replicated below. + this.state = useState({folded: true}); + this.specialData = useSpecialData((orm, props) => { + const {relation} = props.record.fields[props.name]; + const domain = getFieldDomain(props.record, props.name, props.domain); + return orm.call(relation, "search_read", [], { + context: props.context || {}, + domain, + fields: ["checksum", "file_size", "has_thumbnail", "mimetype", "name"], + order: "id desc", + }); + }); + this.idsToAdd = new Set(); + this.idsToRemove = new Set(); + this.debouncedCommitChanges = debounce(this.commitChanges.bind(this), 500); + useBus( + this.props.record.model.bus, + "NEED_LOCAL_CHANGES", + this.commitChanges.bind(this) + ); + onWillUnmount(this.commitChanges.bind(this)); + } + + /** + * @returns {Object[]} the attachments linked to the record, newest first. + */ + get items() { + return this.specialData.data || []; + } + + /** + * @param {Object} attachment + * @returns {Boolean} + */ + isSelected(attachment) { + return this.props.record.data[this.props.name].currentIds.includes( + attachment.id + ); + } + + /** + * @returns {Number} how many attachments are ticked, for the folded header. + */ + get selectedCount() { + return this.props.record.data[this.props.name].currentIds.length; + } + + /** + * @param {Object} attachment + * @returns {String} the file size, human readable, or an empty string when + * the size is unknown. + */ + sizeLabel(attachment) { + return attachment.file_size ? humanSize(attachment.file_size) : ""; + } + + /** + * @param {Object} attachment + * @returns {String|false} a picture to preview in the tooltip: the image + * itself, or the thumbnail Odoo generated for a PDF. Other file types + * have nothing worth showing, and asking for one would only serve back a + * generic mimetype placeholder. + */ + previewUrl(attachment) { + const params = {height: 200, unique: attachment.checksum, width: 300}; + if (attachment.mimetype && attachment.mimetype.startsWith("image/")) { + return imageUrl("ir.attachment", attachment.id, "datas", params); + } + if (attachment.has_thumbnail) { + return imageUrl("ir.attachment", attachment.id, "thumbnail", params); + } + return false; + } + + /** + * @param {Object} attachment + * @returns {String} the tooltip payload, which the tooltip service reads + * back out of the DOM as JSON. + */ + tooltipInfo(attachment) { + return JSON.stringify({ + name: attachment.name, + size: this.sizeLabel(attachment), + src: this.previewUrl(attachment), + }); + } + + toggleFolded() { + // Ticking a box only reaches the record after a debounce, so flush + // before folding: the count in the header has to be the real one. + this.commitChanges(); + this.state.folded = !this.state.folded; + } +} + +export const attachmentCheckboxGrid = { + ...many2ManyCheckboxesField, + component: AttachmentCheckboxGrid, + displayName: _t("Attachment checkbox grid"), +}; + +registry.category("fields").add("attachment_checkbox_grid", attachmentCheckboxGrid); diff --git a/mail_attach_existing_attachment/static/src/attachment_checkbox_grid.scss b/mail_attach_existing_attachment/static/src/attachment_checkbox_grid.scss new file mode 100644 index 000000000..9b4cd167f --- /dev/null +++ b/mail_attach_existing_attachment/static/src/attachment_checkbox_grid.scss @@ -0,0 +1,62 @@ +// Copyright 2026 PureKarting +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +.o_field_attachment_checkbox_grid { + // A field widget is inline-block by default, so the picker would be only as + // wide as its longest filename: the header's rules would have nothing to + // grow into and the grid below would never get past one column. + --fieldWidget-display: block; +} + +.o_attachment_checkbox_grid_items { + // auto-fill against a min column width makes the number of columns follow + // the composer's own width, without a media query: the composer is a + // dialog, so the viewport says nothing useful about how wide it is. The + // min() keeps a narrow composer down to one column instead of overflowing, + // and is interpolated because libsass would otherwise try to evaluate it + // as its own function and choke on comparing rem with %. + display: grid; + grid-template-columns: repeat(auto-fill, minmax(#{"min(15rem, 100%)"}, 1fr)); + column-gap: 1.5rem; + row-gap: 0.125rem; + + > .o-checkbox { + // Odoo sizes .o-checkbox with `width: fit-content`. Because the name + // below is `nowrap`, its min-content width is the whole filename, so + // fit-content resolves to that and the row spills over the next + // column. Stretching to the track is what lets the name truncate. + width: auto; + min-width: 0; + + .form-check-label { + display: flex; + align-items: baseline; + gap: 0.375rem; + min-width: 0; + max-width: 100%; + } + } +} + +// The name is the only part allowed to give up space. Let the size shrink too +// and a squeezed column wraps it ("12.67" over "Kb"), which makes that one row +// two lines tall and drags the whole grid row out of line with its neighbours. +.o_attachment_checkbox_grid_name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.o_attachment_checkbox_grid_size { + flex: 0 0 auto; + white-space: nowrap; +} + +.o_attachment_checkbox_grid_thumb { + display: block; + max-width: 100%; + max-height: 12rem; + object-fit: contain; +} diff --git a/mail_attach_existing_attachment/static/src/attachment_checkbox_grid.xml b/mail_attach_existing_attachment/static/src/attachment_checkbox_grid.xml new file mode 100644 index 000000000..ccdaa2d66 --- /dev/null +++ b/mail_attach_existing_attachment/static/src/attachment_checkbox_grid.xml @@ -0,0 +1,68 @@ + + + + + +
    +
    +
    +
    + + Available Attachments + +
    +
    +
    +
    + + + + +
    +
    +
    + + + +
    +
    +
    + +
    + + + diff --git a/mail_attach_existing_attachment/static/src/mail_composer_attachment_thumbnails.esm.js b/mail_attach_existing_attachment/static/src/mail_composer_attachment_thumbnails.esm.js new file mode 100644 index 000000000..42b7e60d1 --- /dev/null +++ b/mail_attach_existing_attachment/static/src/mail_composer_attachment_thumbnails.esm.js @@ -0,0 +1,63 @@ +import { + MailComposerAttachmentList, + mailComposerAttachmentList, +} from "@mail/core/web/mail_composer_attachment_list"; +import {onWillRender, useState, useSubEnv} from "@odoo/owl"; +import {AttachmentList} from "@mail/core/common/attachment_list"; +import {_t} from "@web/core/l10n/translation"; +import {registry} from "@web/core/registry"; + +/** + * The files the user just added, as thumbnail cards behind a foldable header. + * + * Core renders them as a plain list of links. Cards make an image or a PDF + * recognisable at a glance, which matters once the composer also offers the + * attachments already on the record: the two sections have to be told apart. + * Rendering is delegated to the very component the chatter uses, so the cards + * look and behave exactly like the ones next to a posted message. + */ +export class MailComposerAttachmentThumbnails extends MailComposerAttachmentList { + static template = + "mail_attach_existing_attachment.MailComposerAttachmentThumbnails"; + static components = {AttachmentList}; + + setup() { + super.setup(); + // ``AttachmentList`` reads this to offer Remove rather than Download, + // and to unlink without a confirmation dialog. + useSubEnv({inComposer: true}); + this.state = useState({folded: false}); + /** @type {import("models").Attachment[]} */ + this.attachments = []; + onWillRender(() => { + this.attachments = this.files.map((file) => + this.mailStore["ir.attachment"].insert({ + id: file.id, + mimetype: file.mimetype, + name: file.name, + }) + ); + }); + } + + /** + * @param {import("models").Attachment} attachment + */ + unlinkAttachment(attachment) { + return this.onFileRemove(attachment.id); + } + + toggleFolded() { + this.state.folded = !this.state.folded; + } +} + +export const mailComposerAttachmentThumbnails = { + ...mailComposerAttachmentList, + component: MailComposerAttachmentThumbnails, + displayName: _t("Composer attachment thumbnails"), +}; + +registry + .category("fields") + .add("mail_composer_attachment_thumbnails", mailComposerAttachmentThumbnails); diff --git a/mail_attach_existing_attachment/static/src/mail_composer_attachment_thumbnails.scss b/mail_attach_existing_attachment/static/src/mail_composer_attachment_thumbnails.scss new file mode 100644 index 000000000..8e5226f07 --- /dev/null +++ b/mail_attach_existing_attachment/static/src/mail_composer_attachment_thumbnails.scss @@ -0,0 +1,8 @@ +// Copyright 2026 PureKarting +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +.o_field_mail_composer_attachment_thumbnails { + // Same override core applies to the link list it replaces: the field has + // to span the composer rather than sit in an inline field cell. + --fieldWidget-display: block; +} diff --git a/mail_attach_existing_attachment/static/src/mail_composer_attachment_thumbnails.xml b/mail_attach_existing_attachment/static/src/mail_composer_attachment_thumbnails.xml new file mode 100644 index 000000000..a2a433e63 --- /dev/null +++ b/mail_attach_existing_attachment/static/src/mail_composer_attachment_thumbnails.xml @@ -0,0 +1,33 @@ + + + + + +
    +
    +
    +
    + + New Attachments + +
    +
    +
    + +
    +
    + +
    diff --git a/mail_attach_existing_attachment/static/tests/attachment_sections.test.js b/mail_attach_existing_attachment/static/tests/attachment_sections.test.js new file mode 100644 index 000000000..b4e3257a5 --- /dev/null +++ b/mail_attach_existing_attachment/static/tests/attachment_sections.test.js @@ -0,0 +1,128 @@ +import { + click, + contains, + defineMailModels, + startServer, +} from "@mail/../tests/mail_test_helpers"; +import {describe, test} from "@odoo/hoot"; +import {mountView} from "@web/../tests/web_test_helpers"; +import {runAllTimers} from "@odoo/hoot-mock"; + +describe.current.tags("desktop"); +defineMailModels(); + +const GRID = ".o_attachment_checkbox_grid"; +const NEW = ".o_mail_composer_attachment_thumbnails"; + +/* + * ``display_object_attachment_ids`` is computed, which the mock server does not + * do, so the arch below filters on ``res_model`` directly. What the widgets do + * with the choices they are handed is what these tests are about; the compute + * itself is covered by the Python tests. + */ +const ARCH = ` +
    + + + `; + +/** + * @param {Object} pyEnv + * @param {String[]} names + * @returns {Number[]} the ids of attachments linked to a fresh partner + */ +function attachRecordFiles(pyEnv, names) { + const partnerId = pyEnv["res.partner"].create({name: "Jean Neige"}); + return names.map((name) => + pyEnv["ir.attachment"].create({ + file_size: 2048, + mimetype: "application/pdf", + name, + res_id: partnerId, + res_model: "res.partner", + }) + ); +} + +test("available attachments start folded and unfold into a grid", async () => { + const pyEnv = await startServer(); + attachRecordFiles(pyEnv, ["quote.pdf", "specs.pdf", "photo.pdf"]); + const composerId = pyEnv["mail.compose.message"].create({}); + await mountView({ + arch: ARCH, + resId: composerId, + resModel: "mail.compose.message", + type: "form", + }); + await contains(`${GRID} .fa-caret-right`); + await contains(`${GRID} .badge`, {text: "3"}); + await contains(`${GRID} .o-checkbox`, {count: 0}); + await click(`${GRID} > .cursor-pointer`); + await contains(`${GRID} .fa-caret-down`); + await contains(`${GRID}_items .o-checkbox`, {count: 3}); + await contains(`${GRID}_items`, {text: "quote.pdf"}); +}); + +test("folded header counts the ticked attachments", async () => { + const pyEnv = await startServer(); + attachRecordFiles(pyEnv, ["quote.pdf", "specs.pdf", "photo.pdf"]); + const composerId = pyEnv["mail.compose.message"].create({}); + await mountView({ + arch: ARCH, + resId: composerId, + resModel: "mail.compose.message", + type: "form", + }); + await click(`${GRID} > .cursor-pointer`); + await click(`${GRID}_items .o-checkbox input`); + await runAllTimers(); + await click(`${GRID} > .cursor-pointer`); + await contains(`${GRID} .badge`, {text: "1"}); +}); + +test("no available attachments section when the record has none", async () => { + const pyEnv = await startServer(); + const composerId = pyEnv["mail.compose.message"].create({}); + await mountView({ + arch: ARCH, + resId: composerId, + resModel: "mail.compose.message", + type: "form", + }); + await contains(".o_form_view"); + await contains(GRID, {count: 0}); +}); + +test("new attachments show as cards, and only once there is one", async () => { + const pyEnv = await startServer(); + const emptyId = pyEnv["mail.compose.message"].create({}); + await mountView({ + arch: ARCH, + resId: emptyId, + resModel: "mail.compose.message", + type: "form", + }); + await contains(".o_form_view"); + await contains(NEW, {count: 0}); + + const attachmentId = pyEnv["ir.attachment"].create({ + mimetype: "application/pdf", + name: "draft.pdf", + res_id: 0, + res_model: "mail.compose.message", + }); + const composerId = pyEnv["mail.compose.message"].create({ + attachment_ids: [attachmentId], + }); + await mountView({ + arch: ARCH, + resId: composerId, + resModel: "mail.compose.message", + type: "form", + }); + await contains(`${NEW} .o-mail-AttachmentCard`, {text: "draft.pdf"}); + await contains(`${NEW} .badge`, {text: "1"}); + await click(`${NEW} > .cursor-pointer`); + await contains(`${NEW} .o-mail-AttachmentCard`, {count: 0}); +}); diff --git a/mail_attach_existing_attachment/wizard/mail_compose_message_view.xml b/mail_attach_existing_attachment/wizard/mail_compose_message_view.xml index e0b816258..bc4b005c1 100644 --- a/mail_attach_existing_attachment/wizard/mail_compose_message_view.xml +++ b/mail_attach_existing_attachment/wizard/mail_compose_message_view.xml @@ -4,12 +4,22 @@ mail.compose.message + + + mail_composer_attachment_thumbnails +