Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions mail_attach_existing_attachment/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -101,6 +114,10 @@ Contributors

- Frederic Grall

- `PureKarting <https://www.purekarting.com>`__:

- Cliff Kujala

Maintainers
-----------

Expand Down
10 changes: 9 additions & 1 deletion mail_attach_existing_attachment/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
2 changes: 2 additions & 0 deletions mail_attach_existing_attachment/readme/CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@
- Andrea Stirpe
- [Apik](https://www.aapik.cloud):
- Frederic Grall
- [PureKarting](https://www.purekarting.com):
- Cliff Kujala
13 changes: 13 additions & 0 deletions mail_attach_existing_attachment/readme/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
17 changes: 17 additions & 0 deletions mail_attach_existing_attachment/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,19 @@ <h2><a class="toc-backref" href="#toc-entry-1">Usage</a></h2>
added earlier</li>
</ul>
<p><img alt="image2" src="https://raw.githubusercontent.com/OCA/mail/19.0/mail_attach_existing_attachment/static/description/ex_mail_compose_message.png" /></p>
<p>The composer groups attachments under two headers, each folding away
when its title is clicked:</p>
<ul class="simple">
<li><strong>New Attachments</strong> lists the files added to this email as cards, and
only appears once there is at least one.</li>
<li><strong>Available Attachments</strong> 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.</li>
</ul>
</div>
<div class="section" id="known-issues-roadmap">
<h2><a class="toc-backref" href="#toc-entry-2">Known issues / Roadmap</a></h2>
Expand Down Expand Up @@ -447,6 +460,10 @@ <h3><a class="toc-backref" href="#toc-entry-6">Contributors</a></h3>
<li>Frederic Grall</li>
</ul>
</li>
<li><a class="reference external" href="https://www.purekarting.com">PureKarting</a>:<ul>
<li>Cliff Kujala</li>
</ul>
</li>
</ul>
</div>
<div class="section" id="maintainers">
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
Copyright 2026 PureKarting
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-->
<templates xml:space="preserve">

<t t-name="mail_attach_existing_attachment.AttachmentCheckboxGrid">
<div t-if="items.length" class="o_attachment_checkbox_grid">
<div class="d-flex pt-2 cursor-pointer fw-bolder" t-on-click="toggleFolded">
<hr class="flex-grow-1 fs-3" />
<div class="d-flex align-items-center px-3">
<i
class="fa fa-fw"
t-att-class="state.folded ? 'fa-caret-right' : 'fa-caret-down'"
/>
<span>Available Attachments</span>
<span
class="badge rounded-pill ms-2"
t-att-class="state.folded and selectedCount ? 'text-bg-primary' : 'text-bg-secondary'"
t-out="state.folded and selectedCount ? selectedCount : items.length"
/>
</div>
<hr class="flex-grow-1 fs-3" />
</div>
<div t-if="!state.folded" class="o_attachment_checkbox_grid_items pb-2">
<CheckBox
t-foreach="items"
t-as="attachment"
t-key="attachment.id"
value="isSelected(attachment)"
disabled="props.readonly"
onChange="(checked) => this.onChange(attachment.id, checked)"
>
<span
class="o_attachment_checkbox_grid_name"
data-tooltip-template="mail_attach_existing_attachment.AttachmentTooltip"
t-att-data-tooltip-info="tooltipInfo(attachment)"
t-out="attachment.name"
/>
<span
t-if="sizeLabel(attachment)"
class="o_attachment_checkbox_grid_size text-muted small"
t-out="sizeLabel(attachment)"
/>
</CheckBox>
</div>
</div>
</t>

<!--
Rendered by the tooltip service, which spreads the JSON in
data-tooltip-info into this template's context.
-->
<t t-name="mail_attach_existing_attachment.AttachmentTooltip">
<div class="o_attachment_checkbox_grid_tooltip p-2">
<div class="fw-bold text-break" t-out="name" />
<div t-if="size" class="opacity-75" t-out="size" />
<img
t-if="src"
class="o_attachment_checkbox_grid_thumb mt-2 rounded"
t-att-src="src"
alt=""
/>
</div>
</t>

</templates>
Loading
Loading