Skip to content

Commit 7fec59a

Browse files
committed
[IMP] dms: UX modernisation (kanban density + dashboard + side-pane preview + form heroes + portal grid + test coverage)
Comprehensive UX upgrade for OCA dms on 19.0, stacked on the in-flight MIG. Fork-only PR for runboat preview + stakeholder review. See PR description for the full breakdown; this is a single squash of 50 iteration commits. Phases shipped: • Phase 1 — kanban density tiers (comfortable / compact / list) with toggle. • Phase 2 — directories landing dashboard: stat bar + live 30-day daily sparklines (line for files, bar for storage) + 24-hour line for "new today" + week-deltas. All data via _read_group over indexed create_date. • Phase 3 — side-pane file preview with extensible handler registry (dms.preview_handlers) + Download / Share / Open form toolbar. Built-in handlers for image, PDF, audio, video; office-format fallback with download + Google Viewer affordance. • Phase 8 — visual identity: per-extension accent palette (PDF crimson, code teal, docs blue, etc.) extracted to a shared SCSS partial used by both backend kanban and frontend portal; animated dropzone with marching dashed border; hash-bucketed initials chip on directory kanban cards; condensed form heroes for directory + file (icon + title + tag chips + path + stats grid + 2-tab notebook replacing the upstream loose-fields layout); portal card grid mirroring backend accents. • Phase 11 — late mockup gaps: kanban directory subtitle + extension pill; search facet pill styling scoped to dms surfaces. • Phase 12 — test coverage: 61 Hoot + Python test cases. Hoot suites for DmsStatBar, FilePreviewPane, kanban + list renderer routing, preview handler URL builders + dispatch; mount-view regression tests pinning the two browser-only Owl quirks (regex-literal tokenizer crash, boolean attr serialization). Python tests for get_dashboard_stats + _compute_path. • Phase 13 — preview routing consolidation: kanban file-icon click routes through the side-pane registry (dropping a redundant detour into Odoo's built-in `fileViewer` modal that bypassed the handler chain). New TextPreview handler covers text/*, application/json, application/xml, application/javascript via the same `/web/content` iframe pattern PdfPreview uses — browsers render the source natively. Form binary-field readable_types extended so the preview button shows for text mimetypes too. • CI — workflow_dispatch added to tests workflow so we can manually trigger when GitHub's anti-abuse heuristic throttles fork PR runs. Architecture notes: • Preview-handler registry (dms.preview_handlers) is the extension point for downstream modules; the always-last DownloadFallback at score=-100 means every mimetype gets some handler. • path_names compute uses direct field assignment (OCA idiom). • Sparkline geometry is pure inline-SVG in dms_stat_bar.esm.js; no chart library dependency. Geometry pinned by Hoot tests. • Owl quirks documented + pinned by mount-view tests: QWeb t-* expressions can't tokenize regex literals; t-att-boolean serializes as empty string unless coerced to "true"/"false". Signed-off-by: Don Kendall <dkendall@ledoweb.com>
1 parent 634325e commit 7fec59a

40 files changed

Lines changed: 4254 additions & 406 deletions

.github/workflows/test.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ on:
88
branches:
99
- "19.0"
1010
- "19.0-ocabot-*"
11+
# ledoent fork only: allow manual re-trigger when GitHub silently throttles
12+
# fork-PR workflow runs after a burst of pushes. Strip before opening an
13+
# upstream OCA PR (oca-addons-repo-template owns this file).
14+
workflow_dispatch:
1115

1216
jobs:
1317
unreleased-deps:

dms/__manifest__.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
{
66
"name": "Document Management System",
77
"summary": """Document Management System for Odoo""",
8-
"version": "19.0.1.0.0",
8+
"version": "19.0.1.4.0",
99
"category": "Document Management",
1010
"license": "LGPL-3",
1111
"website": "https://github.com/OCA/dms",
@@ -47,18 +47,33 @@
4747
"dms/static/src/models/*.js",
4848
"dms/static/src/js/fields/path_json/path_owl.esm.js",
4949
"dms/static/src/js/fields/preview_binary/preview_record.esm.js",
50+
"dms/static/src/js/components/*.esm.js",
51+
"dms/static/src/js/components/preview/*.esm.js",
5052
"dms/static/src/js/views/*.esm.js",
5153
# XML
5254
"dms/static/src/js/fields/path_json/path_owl.xml",
5355
"dms/static/src/js/fields/preview_binary/preview_record.xml",
56+
"dms/static/src/js/components/*.xml",
57+
"dms/static/src/js/components/preview/*.xml",
5458
"dms/static/src/js/views/*.xml",
59+
# SCSS
60+
"dms/static/src/scss/dms_ext_palette.scss",
61+
"dms/static/src/scss/file_kanban.scss",
62+
"dms/static/src/scss/dms_directory.scss",
63+
"dms/static/src/scss/dms_form_hero.scss",
64+
"dms/static/src/scss/file_preview_pane.scss",
65+
"dms/static/src/scss/dms_search_facets.scss",
5566
],
5667
"web.assets_frontend": [
68+
"dms/static/src/scss/dms_ext_palette.scss",
5769
"dms/static/src/scss/portal.scss",
5870
],
5971
"web.assets_tests": [
6072
"dms/static/tests/tours/**/*",
6173
],
74+
"web.assets_unit_tests": [
75+
"dms/static/tests/**/*.test.js",
76+
],
6277
},
6378
"demo": [
6479
"demo/res_users.xml",

dms/models/directory.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import os
1111
from ast import literal_eval
1212
from collections import defaultdict
13+
from datetime import timedelta
1314
from typing import Literal # noqa # pylint: disable=unused-import
1415

1516
from odoo import api, fields, models, tools
@@ -786,3 +787,86 @@ def action_dms_files_all_directory(self):
786787
searchpanel_default_directory_id=self.id,
787788
)
788789
return action
790+
791+
@api.model
792+
def get_dashboard_stats(self):
793+
# Global file stats scoped by the current user's ir.rule access.
794+
# Stats are global across all readable files; directory-domain
795+
# translation is deliberately not applied in this iteration.
796+
#
797+
# Sparklines + deltas are computed live via _read_group over
798+
# create_date (always indexed by Odoo) — no snapshot table required.
799+
# The arrays describe *activity* (creations), not state-over-time;
800+
# storage_sparkline shows daily bytes-added, not the running total
801+
# (which would require a snapshot to be faithful under deletions).
802+
File = self.env["dms.file"]
803+
now = fields.Datetime.now()
804+
files_total = File.search_count([])
805+
storage_groups = File._read_group(
806+
domain=[], groupby=[], aggregates=["size:sum"]
807+
)
808+
storage_bytes = int(storage_groups[0][0] or 0) if storage_groups else 0
809+
new_today = File.search_count([("create_date", ">=", now - timedelta(days=1))])
810+
811+
# 30-day daily buckets: (created_count, size_sum) per day.
812+
day_start = (now - timedelta(days=29)).replace(
813+
hour=0, minute=0, second=0, microsecond=0
814+
)
815+
daily_rows = File._read_group(
816+
domain=[("create_date", ">=", day_start)],
817+
groupby=["create_date:day"],
818+
aggregates=["__count", "size:sum"],
819+
)
820+
daily_by_key = {}
821+
for day_value, count, size_sum in daily_rows:
822+
if not day_value:
823+
continue
824+
key = day_value.date().isoformat()
825+
daily_by_key[key] = (int(count or 0), int(size_sum or 0))
826+
files_sparkline = []
827+
storage_sparkline = []
828+
for offset in range(29, -1, -1):
829+
day = (now - timedelta(days=offset)).date().isoformat()
830+
count, size_sum = daily_by_key.get(day, (0, 0))
831+
files_sparkline.append(count)
832+
storage_sparkline.append(size_sum)
833+
834+
# 24 hourly buckets across the past day for the "new today" tile.
835+
hour_start = (now - timedelta(hours=23)).replace(
836+
minute=0, second=0, microsecond=0
837+
)
838+
hourly_rows = File._read_group(
839+
domain=[("create_date", ">=", hour_start)],
840+
groupby=["create_date:hour"],
841+
aggregates=["__count"],
842+
)
843+
hourly_by_key = {}
844+
for hour_value, count in hourly_rows:
845+
if not hour_value:
846+
continue
847+
hourly_by_key[hour_value.replace(minute=0, second=0, microsecond=0)] = int(
848+
count or 0
849+
)
850+
new_today_sparkline = []
851+
for offset in range(23, -1, -1):
852+
slot = (now - timedelta(hours=offset)).replace(
853+
minute=0, second=0, microsecond=0
854+
)
855+
new_today_sparkline.append(hourly_by_key.get(slot, 0))
856+
857+
files_last_week = sum(files_sparkline[-7:])
858+
storage_last_week = sum(storage_sparkline[-7:])
859+
avg_per_day = round(sum(files_sparkline[-7:]) / 7.0, 1)
860+
861+
return {
862+
"files_total": files_total,
863+
"storage_total_bytes": storage_bytes,
864+
"storage_total_human": human_size(storage_bytes),
865+
"new_today": new_today,
866+
"files_sparkline": files_sparkline,
867+
"storage_sparkline": storage_sparkline,
868+
"new_today_sparkline": new_today_sparkline,
869+
"files_delta_week": files_last_week,
870+
"storage_delta_week_human": human_size(storage_last_week),
871+
"new_today_avg_per_day": avg_per_day,
872+
}

dms/models/dms_file.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -425,12 +425,8 @@ def _compute_path(self):
425425
},
426426
)
427427
current_dir = current_dir.parent_id
428-
record.update(
429-
{
430-
"path_names": "/".join(path_names) if all(path_names) else "",
431-
"path_json": json.dumps(path_json),
432-
}
433-
)
428+
record.path_names = "/".join(path_names) if all(path_names) else ""
429+
record.path_json = json.dumps(path_json)
434430

435431
@api.depends("name", "mimetype", "content")
436432
def _compute_extension(self):
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Copyright 2026 ledoent — Don Kendall
2+
// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
3+
4+
import {Component} from "@odoo/owl";
5+
6+
// Reusable stat bar. Driven by a `stats` prop shaped like:
7+
// {files_total, storage_total_human, new_today,
8+
// files_sparkline, storage_sparkline, new_today_sparkline,
9+
// files_delta_week, storage_delta_week_human, new_today_avg_per_day}
10+
// Tile config is declared inline so future dashboards (Phase 3+) can extend
11+
// or remix the same component by passing a different `tiles` prop. Each tile
12+
// names the value key, sparkline key, delta key, and a `chart` hint that
13+
// picks the SVG renderer (line vs bar).
14+
const SPARK_WIDTH = 80;
15+
const SPARK_HEIGHT = 26;
16+
17+
const DEFAULT_TILES = [
18+
{
19+
key: "files_total",
20+
label: "Files",
21+
icon: "fa-file-text-o",
22+
tint: "files",
23+
sparklineKey: "files_sparkline",
24+
chart: "line",
25+
deltaKey: "files_delta_week",
26+
deltaSuffix: " this week",
27+
deltaTrend: "up",
28+
},
29+
{
30+
key: "storage_total_human",
31+
label: "Storage",
32+
icon: "fa-database",
33+
tint: "storage",
34+
sparklineKey: "storage_sparkline",
35+
chart: "bar",
36+
deltaKey: "storage_delta_week_human",
37+
deltaSuffix: " added this week",
38+
deltaTrend: "neutral",
39+
},
40+
{
41+
key: "new_today",
42+
label: "New today",
43+
icon: "fa-clock-o",
44+
tint: "fresh",
45+
sparklineKey: "new_today_sparkline",
46+
chart: "line",
47+
deltaKey: "new_today_avg_per_day",
48+
deltaPrefix: "vs avg ",
49+
deltaSuffix: "/day",
50+
deltaTrend: "neutral",
51+
},
52+
];
53+
54+
export class DmsStatBar extends Component {
55+
static template = "dms.StatBar";
56+
static props = {
57+
stats: {type: [Object, {value: null}], optional: true},
58+
tiles: {type: Array, optional: true},
59+
};
60+
static defaultProps = {
61+
tiles: DEFAULT_TILES,
62+
};
63+
64+
get isLoading() {
65+
return !this.props.stats;
66+
}
67+
68+
valueFor(tile) {
69+
if (this.isLoading) {
70+
return "—";
71+
}
72+
const raw = this.props.stats[tile.key];
73+
return raw === undefined || raw === null ? "—" : raw;
74+
}
75+
76+
// Returns {points, polygon, max, min, hasData} for the tile's series.
77+
// Empty / all-zero series → hasData=false so the template can skip the
78+
// chart and still keep the tile's vertical rhythm.
79+
sparkPath(tile) {
80+
if (this.isLoading || !tile.sparklineKey) {
81+
return {hasData: false};
82+
}
83+
const series = this.props.stats[tile.sparklineKey];
84+
if (!Array.isArray(series) || series.length === 0) {
85+
return {hasData: false};
86+
}
87+
const max = Math.max(...series, 0);
88+
const min = Math.min(...series, 0);
89+
const range = max - min || 1;
90+
const stepX = series.length > 1 ? SPARK_WIDTH / (series.length - 1) : 0;
91+
const points = series.map((v, i) => {
92+
const x = +(i * stepX).toFixed(2);
93+
const y = +(SPARK_HEIGHT - ((v - min) / range) * SPARK_HEIGHT).toFixed(2);
94+
return {x, y, value: v};
95+
});
96+
const linePath = points.map((p) => `${p.x},${p.y}`).join(" ");
97+
const areaPath = `0,${SPARK_HEIGHT} ${linePath} ${SPARK_WIDTH},${SPARK_HEIGHT}`;
98+
const barWidth = series.length ? (SPARK_WIDTH / series.length) * 0.7 : 0;
99+
const bars = points.map((p, i) => ({
100+
x: +(i * (SPARK_WIDTH / series.length)).toFixed(2),
101+
y: p.y,
102+
width: barWidth,
103+
height: +(SPARK_HEIGHT - p.y).toFixed(2),
104+
}));
105+
return {
106+
hasData: max > 0,
107+
points,
108+
linePath,
109+
areaPath,
110+
bars,
111+
last: points[points.length - 1],
112+
};
113+
}
114+
115+
deltaText(tile) {
116+
if (this.isLoading || !tile.deltaKey) {
117+
return "";
118+
}
119+
const raw = this.props.stats[tile.deltaKey];
120+
if (raw === undefined || raw === null) {
121+
return "";
122+
}
123+
const prefix = tile.deltaPrefix || "";
124+
const suffix = tile.deltaSuffix || "";
125+
return `${prefix}${raw}${suffix}`;
126+
}
127+
128+
sparkWidth() {
129+
return SPARK_WIDTH;
130+
}
131+
132+
sparkHeight() {
133+
return SPARK_HEIGHT;
134+
}
135+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?xml version="1.0" encoding="UTF-8" ?>
2+
<!--
3+
Copyright 2026 ledoent — Don Kendall
4+
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
5+
-->
6+
<templates xml:space="preserve">
7+
<t t-name="dms.StatBar">
8+
<div class="o_dms_stat_bar" t-att-data-loading="isLoading">
9+
<div
10+
class="o_dms_stat_bar__tile"
11+
t-foreach="props.tiles"
12+
t-as="tile"
13+
t-key="tile.key"
14+
t-att-data-tint="tile.tint"
15+
>
16+
<i t-attf-class="fa #{tile.icon} o_dms_stat_bar__icon" />
17+
<div class="o_dms_stat_bar__body">
18+
<div class="o_dms_stat_bar__value">
19+
<t t-esc="valueFor(tile)" />
20+
</div>
21+
<div class="o_dms_stat_bar__label">
22+
<t t-esc="tile.label" />
23+
</div>
24+
<div t-if="deltaText(tile)" class="o_dms_stat_bar__delta">
25+
<t t-esc="deltaText(tile)" />
26+
</div>
27+
</div>
28+
<t t-set="spark" t-value="sparkPath(tile)" />
29+
<svg
30+
t-if="spark.hasData"
31+
class="o_dms_stat_bar__spark"
32+
t-att-viewBox="'0 0 ' + sparkWidth() + ' ' + sparkHeight()"
33+
preserveAspectRatio="none"
34+
aria-hidden="true"
35+
>
36+
<t t-if="tile.chart === 'bar'">
37+
<rect
38+
t-foreach="spark.bars"
39+
t-as="bar"
40+
t-key="bar_index"
41+
t-att-x="bar.x"
42+
t-att-y="bar.y"
43+
t-att-width="bar.width"
44+
t-att-height="bar.height"
45+
class="o_dms_stat_bar__spark_bar"
46+
/>
47+
</t>
48+
<t t-else="">
49+
<polygon
50+
t-att-points="spark.areaPath"
51+
class="o_dms_stat_bar__spark_area"
52+
/>
53+
<polyline
54+
t-att-points="spark.linePath"
55+
class="o_dms_stat_bar__spark_line"
56+
fill="none"
57+
/>
58+
<circle
59+
t-att-cx="spark.last.x"
60+
t-att-cy="spark.last.y"
61+
r="1.8"
62+
class="o_dms_stat_bar__spark_dot"
63+
/>
64+
</t>
65+
</svg>
66+
</div>
67+
</div>
68+
</t>
69+
</templates>

0 commit comments

Comments
 (0)