Skip to content
Draft
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
2 changes: 2 additions & 0 deletions dms/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"dms/static/src/js/components/preview/*.xml",
"dms/static/src/js/views/*.xml",
# SCSS
"dms/static/src/scss/dms_ext_palette.scss",
"dms/static/src/scss/file_kanban.scss",
"dms/static/src/scss/file_preview_pane.scss",
],
"web.assets_frontend": [
Expand Down
12 changes: 0 additions & 12 deletions dms/static/src/js/views/file_kanban_controller.esm.js

This file was deleted.

25 changes: 0 additions & 25 deletions dms/static/src/js/views/file_kanban_controller.xml

This file was deleted.

100 changes: 100 additions & 0 deletions dms/static/src/js/views/file_kanban_name_widget.esm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// /** ********************************************************************************
// Copyright 2026 ledoent — Don Kendall
// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
//
// Inline quick-rename for DMS file kanban cards (F3). Double-click the
// name → editable input → Enter commits, Esc cancels, blur commits-or-
// cancels based on whether the draft changed. macOS Finder / GNOME Files
// convention.
//
// Implemented as a *view widget* rather than inline kanban-arch markup:
// Odoo 19 forbids every owl event/ref directive (t-on-*, t-ref) inside an
// ir.ui.view arch because the arch is DB-editable. Widget templates are
// static assets, so the event wiring is allowed here.
// **********************************************************************************/
import {registry} from "@web/core/registry";
import {standardWidgetProps} from "@web/views/widgets/standard_widget_props";
import {useService} from "@web/core/utils/hooks";
import {Component, useEffect, useRef, useState} from "@odoo/owl";

export class DmsFileNameWidget extends Component {
static template = "dms.FileNameWidget";
static props = {...standardWidgetProps};

setup() {
this.renameState = useState({active: false});
this.input = useRef("input");
this.notification = useService("notification");
// Focus + select the input the moment it mounts so the user types
// over the existing name immediately.
useEffect(
(el) => {
if (el) {
el.focus();
el.select?.();
}
},
() => [this.input.el]
);
}

get name() {
return this.props.record.data.name || "";
}

startRename(ev) {
// Swallow the dblclick so it doesn't bubble to the card's global
// click handler (which would open the side-pane preview on top).
ev?.stopPropagation?.();
ev?.preventDefault?.();
this.renameState.active = true;
}

cancelRename() {
this.renameState.active = false;
}

async commitRename() {
const next = (this.input.el?.value ?? "").trim();
const current = this.name;
if (!next || next === current) {
this.cancelRename();
return;
}
try {
await this.props.record.update({name: next});
await this.props.record.save({noReload: true, savePoint: false});
// The preview pane reads via its own ORM call keyed on recordId;
// a rename leaves recordId unchanged, so nudge it to re-fetch if
// this record is the one on screen.
this.env.dmsKanbanPreview?.notifyChanged?.(this.props.record.resId);
} catch (e) {
this.notification.add(e.message || "Rename failed", {type: "danger"});
}
this.cancelRename();
}

onKeydown(ev) {
if (ev.key === "Enter") {
ev.preventDefault();
this.commitRename();
} else if (ev.key === "Escape") {
ev.preventDefault();
this.cancelRename();
}
// Don't let the renderer-level Escape (which closes the preview pane)
// also fire from inside the rename input.
ev.stopPropagation();
}

stopEvent(ev) {
ev.stopPropagation();
}
}

export const dmsFileNameWidget = {
component: DmsFileNameWidget,
extractProps: () => ({}),
};

registry.category("view_widgets").add("dms_file_name", dmsFileNameWidget);
25 changes: 25 additions & 0 deletions dms/static/src/js/views/file_kanban_name_widget.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="dms.FileNameWidget">
<t t-if="renameState.active">
<input
type="text"
class="o_kanban_dms_card__name o_kanban_dms_card__name_input form-control form-control-sm"
t-ref="input"
t-att-value="name"
t-on-keydown="this.onKeydown"
t-on-blur="() => this.commitRename()"
t-on-click.stop="this.stopEvent"
t-on-dblclick.stop="this.stopEvent"
/>
</t>
<t t-else="">
<div
class="o_kanban_dms_card__name fw-bold text-truncate"
t-att-title="name"
t-out="name"
t-on-dblclick="this.startRename"
/>
</t>
</t>
</templates>
73 changes: 31 additions & 42 deletions dms/static/src/js/views/file_kanban_record.esm.js
Original file line number Diff line number Diff line change
@@ -1,57 +1,46 @@
// /** ********************************************************************************
// Copyright 2024 Subteno - Timothée Vannier (https://www.subteno.com).
// Copyright 2026 ledoent — Don Kendall
// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
// **********************************************************************************/
import {KanbanRecord} from "@web/views/kanban/kanban_record";
import {useFileViewer} from "@web/core/file_viewer/file_viewer_hook";
import {useService} from "@web/core/utils/hooks";

const videoReadableTypes = ["x-matroska", "mp4", "webm"];
const audioReadableTypes = ["mp3", "ogg", "wav", "aac", "mpa", "flac", "m4a"];

export class FileKanbanRecord extends KanbanRecord {
setup() {
super.setup();
this.store = useService("mail.store");
this.fileViewer = useFileViewer();
}

isVideo(mimetype) {
return videoReadableTypes.includes(mimetype);
}

isAudio(mimetype) {
return audioReadableTypes.includes(mimetype);
}

/**
* @override
*
* Override to open the preview upon clicking the image, if compatible.
* Every kanban click — including the file icon — routes through the
* renderer's side-pane preview state. Previously the icon had its own
* branch that opened Odoo's built-in modal `fileViewer`; that detour
* was inconsistent with the rest of the card (which already selected
* for the side-pane) and meant the registered handler chain in
* `dms.preview_handlers` never saw the click.
*
* Inline rename (double-click the name) is owned by the dms_file_name
* view widget; it stops propagation on its own clicks so they never
* reach this handler.
*/
onGlobalClick(ev) {
const self = this;

if (ev.target.closest(".o_kanban_dms_file_preview")) {
const file_type = self.props.record.data.name.split(".")[1];
let mimetype = "";

if (self.isVideo(file_type)) {
mimetype = `video/${file_type}`;
} else if (self.isAudio(file_type)) {
mimetype = "audio/mpeg";
} else {
mimetype = self.props.record.data.mimetype;
}
/**
* @override
* Mark the card active while it's the one shown in the side-pane
* preview, mirroring the list view's selected-row accent. Reads the
* reactive selectedId during render so the class updates when the
* selection moves to another card.
*/
getRecordClasses() {
let classes = super.getRecordClasses();
const selectedId = this.env.dmsKanbanPreview?.selectedId?.();
if (selectedId && selectedId === this.props.record.resId) {
classes += " o_dms_preview_selected_card";
}
return classes;
}

const attachment = this.store["ir.attachment"].insert({
id: self.props.record.data.id,
filename: self.props.record.data.name,
name: self.props.record.data.name,
mimetype: mimetype,
model_name: self.props.record.resModel,
});
this.fileViewer.open(attachment);
onGlobalClick(ev) {
if (this.env.dmsKanbanPreview && this.props.record.resId) {
this.env.dmsKanbanPreview.select(this.props.record.resId);
ev.preventDefault?.();
ev.stopPropagation?.();
return;
}
return super.onGlobalClick(ev);
Expand Down
79 changes: 74 additions & 5 deletions dms/static/src/js/views/file_kanban_renderer.esm.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,86 @@
// /** ********************************************************************************
// Copyright 2020 Creu Blanca
// Copyright 2026 ledoent — Don Kendall
// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
// **********************************************************************************/
import {useExternalListener, useSubEnv} from "@odoo/owl";
import {FileKanbanRecord} from "./file_kanban_record.esm";
import {FilePreviewPane} from "../components/preview/file_preview_pane.esm";
import {KanbanRenderer} from "@web/views/kanban/kanban_renderer";
import {useDmsPreviewState, useStoredState} from "../utils/use_stored_state.esm";

// Density tiers: "comfortable" (default), "compact", "list".
export const DMS_KANBAN_DEFAULT_DENSITY = "comfortable";
const DMS_KANBAN_DENSITY_KEY = "dms_kanban_density";
const DMS_KANBAN_PREVIEW_KEY = "dms_kanban_preview_pane";
const DMS_KANBAN_DENSITY_OPTIONS = [
{value: "comfortable", label: "Comfortable", icon: "fa-th-large"},
{value: "compact", label: "Compact", icon: "fa-th"},
{value: "list", label: "List", icon: "fa-bars"},
];
const DMS_DENSITY_VALUES = new Set(DMS_KANBAN_DENSITY_OPTIONS.map((o) => o.value));

export class FileKanbanRenderer extends KanbanRenderer {
static template = "dms.KanbanRenderer";
static components = {
...KanbanRenderer.components,
KanbanRecord: FileKanbanRecord,
FilePreviewPane,
};

setup() {
super.setup();
// Density: persisted string with tampered-value defence.
this._density = useStoredState(
DMS_KANBAN_DENSITY_KEY,
DMS_KANBAN_DEFAULT_DENSITY,
{
deserializer: (v) =>
DMS_DENSITY_VALUES.has(v) ? v : DMS_KANBAN_DEFAULT_DENSITY,
}
);
// Side-pane: shared with the list renderer via useDmsPreviewState.
this.previewState = useDmsPreviewState(DMS_KANBAN_PREVIEW_KEY);
// Expose select callback to descendant FileKanbanRecord instances via
// env so card clicks route into the renderer's preview state without
// the records needing a direct reference up the tree.
useSubEnv({
dmsKanbanPreview: {
select: (resId) => this.previewState.select(resId),
isOpen: () => this.previewState.open,
// The currently-previewed record id (reactive read) so a card
// can mark itself active — mirrors the list's selected-row rail.
selectedId: () =>
this.previewState.open ? this.previewState.recordId : null,
// A card renamed its record in place — re-fetch the pane if
// that record is the one being previewed.
notifyChanged: (resId) => this.previewState.notifyChanged(resId),
},
});
useExternalListener(window, "keydown", (ev) => {
if (ev.key === "Escape" && this.previewState.open) {
this.closePreview();
}
});
}

get density() {
return this._density.value;
}

get densityOptions() {
return DMS_KANBAN_DENSITY_OPTIONS;
}
}

FileKanbanRenderer.components = {
...KanbanRenderer.components,
KanbanRecord: FileKanbanRecord,
};
setDensity(value) {
this._density.value = value;
}

togglePreview() {
this.previewState.toggle();
}

closePreview() {
this.previewState.close();
}
}
Loading
Loading