` at the top of the sheet. Rules
+// live here (rather than in dms_directory.scss or file_kanban.scss) so a
+// future maintainer extending the hero on either model finds them in one
+// obvious place.
+
+.o_dms_form .o_dms_form_hero {
+ display: flex;
+ align-items: flex-start;
+ gap: 1.25rem;
+ padding: 0 0 1rem;
+ margin-bottom: 1rem;
+ border-bottom: 1px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
+
+ &__media {
+ flex: 0 0 auto;
+
+ .o_dms_form_hero__image {
+ width: 72px;
+ height: 72px;
+ border-radius: 12px;
+ object-fit: cover;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ }
+
+ // Style the image widget's empty-state placeholder.
+ .o_field_image {
+ margin: 0;
+ }
+ }
+
+ &__body {
+ flex: 1 1 auto;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
+ &__title {
+ display: flex;
+ align-items: baseline;
+ gap: 0.75rem;
+
+ h1 {
+ margin: 0;
+ font-size: 1.5rem;
+ font-weight: 600;
+ line-height: 1.2;
+ }
+ }
+
+ &__path {
+ font-size: 0.85rem;
+ }
+
+ &__tags {
+ margin-top: 0.25rem;
+ }
+
+ &__stats {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 1rem;
+ margin-top: 0.5rem;
+ max-width: 720px;
+ }
+
+ &__stat_value {
+ font-size: 1.4rem;
+ font-weight: 600;
+ line-height: 1.1;
+
+ // Strip the field widget's default chrome (margins, borders) so the
+ // number reads as a hero stat, not a labelled field.
+ .o_field_widget {
+ margin: 0;
+ padding: 0;
+ border: none;
+ background: transparent;
+ }
+
+ // Type stat shows the raw extension ("svg", "pdf") — render as the
+ // uppercase pill convention used in the kanban cards.
+ &--uppercase {
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+ }
+ }
+
+ &__stat_label {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--bs-secondary-color, #6c757d);
+ }
+}
diff --git a/dms/static/src/scss/dms_search_facets.scss b/dms/static/src/scss/dms_search_facets.scss
new file mode 100644
index 000000000..ef8459cc9
--- /dev/null
+++ b/dms/static/src/scss/dms_search_facets.scss
@@ -0,0 +1,50 @@
+// Copyright 2026 ledoent — Don Kendall
+// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+
+// Pill-styled active filter facets — applied scope-locally to DMS surfaces.
+// Odoo 19 already renders facets on the search bar (see core template
+// `web.SearchBar.Facets` in addons/web/static/src/search/search_bar/
+// search_bar.xml). The DOM is:
+//
+// .o_searchview_facet outer chip (label half + values half)
+// .o_searchview_facet_label primary-coloured icon / title
+// .o_facet_values comma-separated value list
+// .o_facet_remove × close button
+//
+// The default chrome uses `rounded-2` corners + `bg-200` background. The
+// mockup §7 calls for a fully pill-shaped chip — single visual signature
+// that makes "applied filter" read at a glance. We scope via `:has()` of
+// our DMS view classes so other backends keep their stock facet shape.
+
+.o_action_manager:has(.mk_file_kanban_view),
+.o_action_manager:has(.mk_directory_kanban_view),
+.o_action_manager:has(.o_dms_list_split) {
+ .o_searchview_facet {
+ border-radius: 999px !important;
+ overflow: hidden;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
+ margin-right: 4px;
+
+ .o_searchview_facet_label {
+ border-radius: 999px 0 0 999px;
+ padding-left: 10px !important;
+ padding-right: 6px !important;
+ }
+
+ .o_facet_values {
+ padding-right: 4px;
+ padding-left: 10px;
+ font-weight: 500;
+ }
+
+ .o_facet_remove {
+ padding: 0 8px !important;
+ opacity: 0.7;
+ transition: opacity 120ms ease;
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+ }
+}
diff --git a/dms/static/src/scss/file_kanban.scss b/dms/static/src/scss/file_kanban.scss
new file mode 100644
index 000000000..6d9f06131
--- /dev/null
+++ b/dms/static/src/scss/file_kanban.scss
@@ -0,0 +1,399 @@
+// Copyright 2026 ledoent — Don Kendall
+// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+
+// Denser, mime-aware file kanban card.
+//
+// Density tiers are applied via [data-density] on the kanban view root.
+// Default = "comfortable"; "compact" and "list" are toggled via the bar in
+// the top-right of the kanban area. Selection persists in localStorage.
+
+.mk_file_kanban_view {
+ position: relative;
+
+ // ---- Card chrome (applies to every density) ----
+ .o_kanban_dms_card {
+ // Default accent for unknown extensions / no mimetype info.
+ --dms-accent: var(--bs-secondary-color, #6c757d);
+
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ border-left: 3px solid var(--dms-accent);
+ transition:
+ transform 140ms ease,
+ box-shadow 140ms ease,
+ border-left-width 140ms ease;
+ }
+
+ // Subtle hover lift — cards feel tactile, the accent thickens to draw
+ // the eye to the hovered card without disturbing density.
+ .o_kanban_record:hover .o_kanban_dms_card {
+ transform: translateY(-1px);
+ box-shadow:
+ 0 2px 4px rgba(0, 0, 0, 0.04),
+ 0 4px 12px rgba(0, 0, 0, 0.06);
+ border-left-width: 4px;
+ }
+
+ // Empty many2many tags widget renders a wrapper div with no content;
+ // hide it so cards without tags don't carry empty vertical space.
+ .o_kanban_dms_card__body .o_field_many2many_tags:empty,
+ .o_kanban_dms_card__body .o_field_many2many_tags:has(.o_input_dropdown:only-child) {
+ display: none;
+ }
+
+ // The actual card chrome (thumb / body / meta sub-styles below).
+ .o_kanban_dms_card {
+ &__thumb {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--bs-tertiary-bg, #f4f5f7);
+ border-radius: 4px;
+ overflow: hidden;
+
+ img {
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: cover;
+ }
+ }
+
+ &__lock {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ padding: 2px 5px;
+ background-color: rgba(255, 255, 255, 0.92);
+ border-radius: 3px;
+ font-size: 0.75rem;
+ color: var(--bs-warning-text-emphasis, #997404);
+ }
+
+ // Extension pill — bottom-right of the thumb. Tinted using the
+ // per-extension accent so the pill reinforces the spine without a
+ // second source of truth for colour. Hidden in compact/list densities.
+ &__ext_pill {
+ position: absolute;
+ bottom: 3px;
+ right: 3px;
+ padding: 0 5px;
+ font-family: var(
+ --bs-font-monospace,
+ ui-monospace,
+ "SFMono-Regular",
+ Menlo,
+ monospace
+ );
+ font-size: 0.62rem;
+ font-weight: 600;
+ line-height: 1.5;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #fff;
+ background-color: var(--dms-accent, var(--bs-secondary, #6c757d));
+ border-radius: 3px;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.12);
+ pointer-events: none;
+ }
+
+ &__body {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0; // allow text-truncate on the name
+ }
+
+ &__name {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ // Inline rename input (F3). Sits in the same flow slot as the name
+ // span so the card geometry doesn't shift on dblclick. Bootstrap's
+ // form-control-sm sets the height; we strip the rounded chrome to
+ // keep it visually close to the read-mode label.
+ &__name_input {
+ display: block;
+ width: 100%;
+ font-weight: 600;
+ font-size: inherit;
+ padding: 2px 6px;
+ border: 1px solid var(--bs-primary, #714b67);
+ border-radius: 3px;
+ background: var(--bs-body-bg, #fff);
+
+ &:focus {
+ outline: none;
+ box-shadow: 0 0 0 2px rgba(113, 75, 103, 0.2);
+ }
+ }
+
+ // Directory path subtitle — sits directly under the filename so
+ // duplicate filenames across directories are disambiguated at a
+ // glance. Hidden in compact/list densities.
+ &__subtitle {
+ display: block;
+ font-size: 0.72rem;
+ line-height: 1.2;
+ margin-top: -2px;
+ }
+
+ &__meta {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: auto;
+ padding-top: 4px;
+ border-top: 1px solid
+ var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
+ font-size: 0.75rem;
+ color: var(--bs-secondary-color, #6c757d);
+ }
+
+ &__avatar {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ object-fit: cover;
+ }
+ }
+
+ // Density-aware suppression: the subtitle + ext pill belong to the
+ // comfortable density's "scannable card" composition. Compact + list
+ // densities prioritise row count, so we hide them there.
+ .o_kanban_renderer[data-density="compact"],
+ .o_kanban_renderer[data-density="list"] {
+ .o_kanban_dms_card__subtitle,
+ .o_kanban_dms_card__ext_pill {
+ display: none;
+ }
+ }
+
+ // ---- Comfortable (default): vertical card, denser than Odoo's stock ----
+ // Odoo's `.o_kanban_record` has its own min-height which would let the
+ // thumb stretch to fill (flex-column distributes free space). Override
+ // both the record min-height and pin the thumb to its natural size so
+ // the card sits as tall as the content requires, no more.
+ .o_kanban_renderer[data-density="comfortable"],
+ .o_kanban_renderer:not([data-density]) {
+ .o_kanban_record {
+ min-height: 0;
+ }
+
+ .o_kanban_dms_card {
+ padding: 10px 12px;
+ min-height: 0;
+ gap: 10px;
+
+ // Thumb sized to its content (mime icon or image_128).
+ // Loses the placeholder grey rectangle that dominated the
+ // previous layout and made small icons look stranded.
+ &__thumb {
+ aspect-ratio: auto;
+ flex: 0 0 auto;
+ min-height: 0;
+ padding: 4px 0;
+ background-color: transparent;
+ align-self: flex-start;
+ }
+
+ &__thumb img {
+ width: 56px;
+ height: 56px;
+ }
+
+ // For image files, icon_url returns /web/image/.../image_128 —
+ // those benefit from a wider thumbnail. The dms.mixins.thumbnail
+ // cropping is 128×128 so we can show it square at a larger size.
+ &__thumb img[src*="image_128"] {
+ width: 88px;
+ height: 88px;
+ border-radius: 4px;
+ }
+ }
+ }
+
+ // ---- Compact: row-style with icon + name on a single line ----
+ // Replaces the comfortable card's stacked thumb+body+meta with a
+ // horizontal row inside each card, halving vertical footprint.
+ .o_kanban_renderer[data-density="compact"] {
+ .o_kanban_record {
+ min-height: unset;
+ }
+
+ .o_kanban_dms_card {
+ flex-direction: row;
+ align-items: center;
+ padding: 4px 8px;
+ gap: 10px;
+ min-height: 0;
+
+ &__thumb {
+ flex: 0 0 32px;
+ width: 32px;
+ height: 32px;
+ aspect-ratio: 1;
+ min-height: 0;
+ padding: 0;
+ background-color: transparent;
+ }
+
+ &__thumb img {
+ width: 24px;
+ height: 24px;
+ }
+
+ &__lock {
+ top: 0;
+ right: 0;
+ font-size: 0.65rem;
+ }
+
+ &__body {
+ flex: 1 1 auto;
+ flex-direction: row;
+ align-items: center;
+ gap: 10px;
+ }
+
+ &__name {
+ flex: 1 1 auto;
+ font-size: 0.85rem;
+ }
+
+ &__meta {
+ margin-top: 0;
+ padding-top: 0;
+ border-top: none;
+ flex: 0 0 auto;
+ font-size: 0.7rem;
+ }
+ }
+ }
+
+ // ---- List: horizontal row, icon + meta inline ----
+ .o_kanban_renderer[data-density="list"] {
+ // The renderer IS the flex container — no nested descendant needed.
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+
+ .o_kanban_record {
+ width: 100% !important;
+ max-width: 100% !important;
+ min-height: unset;
+ margin: 0;
+ }
+
+ .o_kanban_dms_card {
+ flex-direction: row;
+ align-items: center;
+ padding: 6px 10px;
+ gap: 12px;
+
+ &__thumb {
+ flex: 0 0 32px;
+ width: 32px;
+ height: 32px;
+ aspect-ratio: 1;
+ min-height: 32px;
+ }
+
+ &__thumb img {
+ width: 24px;
+ height: 24px;
+ }
+
+ &__body {
+ flex: 1 1 auto;
+ flex-direction: row;
+ align-items: center;
+ gap: 12px;
+ }
+
+ &__meta {
+ margin-top: 0;
+ padding-top: 0;
+ border-top: none;
+ flex: 0 0 auto;
+ }
+ }
+ }
+
+ // ---- Animated dropzone (drag-over overlay) ----
+ //
+ // Replaces the default static cloud icon with a marching-dashed-border
+ // overlay + pulsing icon + title + hint. Triggered by the renderer's
+ // existing `dragState.showDragZone` state — no JS change.
+ .o_dms_dropzone {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+ background:
+ linear-gradient(rgba(113, 75, 103, 0.06), rgba(113, 75, 103, 0.1)),
+ repeating-linear-gradient(
+ 45deg,
+ transparent,
+ transparent 8px,
+ rgba(113, 75, 103, 0.06) 8px,
+ rgba(113, 75, 103, 0.06) 16px
+ );
+ border-radius: 8px;
+ outline: 3px dashed var(--bs-primary, #714b67);
+ outline-offset: -8px;
+ animation: o_dms_dropzone_march 1s linear infinite;
+ z-index: 10;
+
+ &__icon {
+ width: 96px;
+ height: 96px;
+ border-radius: 50%;
+ background: rgba(113, 75, 103, 0.12);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ animation: o_dms_dropzone_pulse 1.5s ease-in-out infinite;
+
+ i.fa {
+ font-size: 3rem;
+ color: var(--bs-primary, #714b67);
+ }
+ }
+
+ &__title {
+ font-size: 1.2rem;
+ font-weight: 600;
+ color: var(--bs-primary, #714b67);
+ }
+
+ &__hint {
+ font-size: 0.85rem;
+ color: var(--bs-secondary-color, #6c757d);
+ }
+ }
+}
+
+@keyframes o_dms_dropzone_march {
+ from {
+ outline-offset: -8px;
+ }
+ to {
+ outline-offset: -16px;
+ }
+}
+
+@keyframes o_dms_dropzone_pulse {
+ 0%,
+ 100% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(1.08);
+ }
+}
diff --git a/dms/static/src/scss/file_preview_pane.scss b/dms/static/src/scss/file_preview_pane.scss
new file mode 100644
index 000000000..f026355b3
--- /dev/null
+++ b/dms/static/src/scss/file_preview_pane.scss
@@ -0,0 +1,399 @@
+// Copyright 2026 ledoent — Don Kendall
+// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+
+// Side-pane preview layout for the dms file list + kanban views.
+//
+// The list/kanban view is wrapped in `.o_dms_list_split` / `.o_dms_kanban_split`.
+// When the preview pane is open, the wrapper becomes a horizontal flex
+// container with the list on the left and the preview pane (~40% of the
+// width) on the right. The pane animates in from the right; the selected
+// row gets a primary-color accent rail.
+//
+// View-level toggles (preview, density) live inside `.o_dms_view_toolbar`,
+// a normal in-flow strip rendered above the grid/list — not an overlay.
+// Earlier iterations stacked four absolute-positioned floaters in the
+// top-right corner, which sat on top of the list column headers and forced
+// a `right: calc(40% + 12px)` shift + density-hide hack when the pane
+// opened. In-flow placement removes the entire cascade.
+
+// ---- View toolbar: shared chrome for list + kanban ----
+//
+// One in-flow row above the renderer body. Kanban populates it with the
+// density btn-group on the left + the preview-pane pill on the right; the
+// `__spacer` between them carries `flex: 1` so the pill always anchors
+// right regardless of how many controls join on the left in future. List
+// view only has the pill, also right-anchored via the spacer.
+.o_dms_view_toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ flex: 0 0 auto;
+ border-bottom: 1px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.06));
+ background-color: var(--bs-body-bg, #fff);
+
+ &__spacer {
+ flex: 1 1 auto;
+ }
+
+ &__toggle {
+ padding: 4px 10px;
+ font-size: 0.8rem;
+ font-weight: 500;
+ letter-spacing: 0.02em;
+ border-radius: 999px;
+ // Animate background/border/shadow/transform together so the hover
+ // state reads as a deliberate response, not just a 1px nudge.
+ transition:
+ background-color 160ms ease,
+ border-color 160ms ease,
+ color 160ms ease,
+ box-shadow 160ms ease,
+ transform 160ms ease;
+
+ &:hover {
+ transform: translateY(-1px);
+ }
+ &:active {
+ transform: translateY(0);
+ }
+
+ &--off {
+ background-color: var(--bs-body-bg, #fff);
+ color: var(--bs-emphasis-color, #212529);
+ border: 1px solid var(--bs-border-color, rgba(0, 0, 0, 0.15));
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
+
+ // Hover on the "Preview pane" (off) state: deepen the tint
+ // toward the primary so the affordance pre-figures the on state.
+ &:hover {
+ background-color: rgba(113, 75, 103, 0.06);
+ border-color: rgba(113, 75, 103, 0.35);
+ color: var(--bs-primary, #714b67);
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
+ }
+ }
+
+ &--on {
+ background-color: var(--bs-primary, #714b67);
+ color: #fff;
+ border: 1px solid var(--bs-primary, #714b67);
+ box-shadow: 0 2px 6px rgba(113, 75, 103, 0.3);
+
+ // Hover on the "Hide preview" (on) state: darken via filter
+ // (works against any primary token value) + lift the shadow so
+ // the button feels tactile when about to dismiss the pane.
+ &:hover {
+ filter: brightness(0.92);
+ box-shadow: 0 3px 10px rgba(113, 75, 103, 0.45);
+ }
+ }
+ }
+}
+
+.o_dms_kanban_split {
+ position: relative;
+ display: flex;
+ flex-direction: row;
+ height: 100%;
+ min-height: 0;
+ width: 100%;
+
+ &__main {
+ flex: 1 1 auto;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+}
+
+// Responsive collapse: at narrow viewports (Bootstrap `md` and below — the
+// stock backend collapses the searchpanel below this breakpoint too), the
+// 40%/min-320px side-pane squeezes the grid to one cramped column AND
+// overlaps the in-flow toolbar. Hide the pane wholesale below `lg` and
+// suppress the toggle pill so it doesn't promise a feature the viewport
+// can't host. Re-appears at ≥992px, where there's room for both halves.
+@media (max-width: 991.98px) {
+ .o_dms_kanban_split,
+ .o_dms_list_split {
+ .o_dms_preview_pane {
+ display: none;
+ }
+ }
+ .o_dms_view_toolbar__toggle {
+ display: none;
+ }
+}
+
+.o_dms_list_split {
+ position: relative;
+ display: flex;
+ flex-direction: row;
+ height: 100%;
+ min-height: 0;
+
+ &__list {
+ flex: 1 1 auto;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ transition: flex-basis 240ms ease-out;
+
+ // The list renderer body scrolls inside the column; the toolbar
+ // above stays pinned.
+ > .o_list_renderer,
+ > .o_renderer {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: auto;
+ }
+ }
+}
+
+// ---- Selected-row accent: vertical primary rail + tint + subtle weight ----
+// All rows get a smooth tint transition so toggling selection feels intentional.
+.o_dms_list_split[data-preview-open="true"] .o_data_row > td {
+ transition: background-color 120ms ease-out;
+}
+
+.o_dms_preview_selected_row {
+ position: relative;
+
+ & > td:first-child::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 4px;
+ background-color: var(--bs-primary, #714b67);
+ box-shadow: 0 0 0 1px var(--bs-primary, #714b67);
+ }
+
+ & > td {
+ background-color: rgba(113, 75, 103, 0.08);
+ font-weight: 500;
+ }
+
+ // The first cell after the checkbox needs left padding to avoid the rail.
+ & > td:nth-child(2) {
+ padding-left: 12px;
+ }
+}
+
+// ---- Preview pane ----
+.o_dms_preview_pane {
+ flex: 0 0 40%;
+ max-width: 40%;
+ min-width: 320px;
+
+ // Empty state — collapse to a compact rail so the listing reclaims most
+ // of the screen until the user actually picks a file. The full pane
+ // returns the moment state.file is populated. Matches the macOS Finder
+ // / GNOME Files pattern: invisible until needed.
+ &--empty {
+ flex: 0 0 240px;
+ max-width: 240px;
+ min-width: 240px;
+ }
+ display: flex;
+ flex-direction: column;
+ border-left: 1px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
+ background-color: var(--bs-body-bg, #fff);
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+ box-shadow: -4px 0 16px rgba(0, 0, 0, 0.04);
+ animation: dms-preview-slide-in 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
+
+ &__header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 12px 16px;
+ border-bottom: 1px solid
+ var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
+ background-color: rgba(113, 75, 103, 0.02);
+ }
+
+ &__title {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ flex: 1 1 auto;
+ gap: 2px;
+ }
+
+ // Action toolbar — three primary actions grouped as a btn-group so the
+ // visual weight reads as a single control. The pane itself is 40% of
+ // viewport so even on a wide desktop the inner width is narrow; force
+ // icon-only buttons here (the per-button d-none d-md-inline rule uses
+ // VIEWPORT width, not container width — wrong axis for our case).
+ &__actions {
+ flex: 0 0 auto;
+
+ .btn {
+ padding: 0.25rem 0.55rem;
+ font-size: 0.78rem;
+ line-height: 1.2;
+
+ i.fa {
+ font-size: 0.85rem;
+ }
+
+ // Override the per-button d-md-inline label classes — labels
+ // never fit when the pane is open at any viewport size.
+ span {
+ display: none !important;
+ }
+ }
+ }
+
+ &__body {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: auto;
+ padding: 16px;
+ }
+
+ &__empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ gap: 16px;
+ color: var(--bs-secondary-color, #6c757d);
+ text-align: center;
+
+ // The mouse-pointer icon in the empty state — large + soft.
+ i.fa {
+ font-size: 2.5rem;
+ opacity: 0.35;
+ }
+
+ &--error {
+ color: var(--bs-danger, #dc3545);
+
+ i.fa {
+ opacity: 1;
+ }
+ }
+ }
+}
+
+@keyframes dms-preview-slide-in {
+ from {
+ transform: translateX(20px);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+// ---- Per-handler styles ----
+.o_dms_preview__media {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ width: 100%;
+
+ img,
+ video {
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: contain;
+ border-radius: 4px;
+ }
+
+ &--audio {
+ flex-direction: column;
+ gap: 12px;
+ padding: 24px;
+
+ audio {
+ width: 100%;
+ max-width: 480px;
+ }
+ }
+}
+
+.o_dms_preview__iframe {
+ width: 100%;
+ height: 100%;
+ border: 0;
+ min-height: 480px;
+ border-radius: 4px;
+ // White surface for the PDF/Office preview loading paint. Chromium's
+ // built-in PDF viewer shows a dark grey backdrop before render, which
+ // photographs and screen-shares badly. Force a clean white start.
+ background: #fff;
+}
+
+// ---- Fallback card (office / download) ----
+.o_dms_preview__fallback {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ gap: 16px;
+ padding: 32px 24px;
+ height: 100%;
+
+ // The fallback "card" sits inside the pane body — give it a soft visual
+ // boundary so it reads as a distinct block, not raw empty space.
+ background-color: var(--bs-tertiary-bg, #f4f5f7);
+ border-radius: 6px;
+ border: 1px dashed var(--bs-border-color, rgba(0, 0, 0, 0.15));
+}
+
+.o_dms_preview__fallback_icon {
+ font-size: 3rem;
+ color: var(--bs-primary, #714b67);
+ opacity: 0.6;
+}
+
+.o_dms_preview__fallback_msg {
+ margin: 0;
+ color: var(--bs-emphasis-color, #212529);
+ max-width: 360px;
+ line-height: 1.4;
+
+ code {
+ background-color: var(--bs-body-bg, #fff);
+ padding: 1px 5px;
+ border-radius: 3px;
+ font-size: 0.85em;
+ color: var(--bs-primary, #714b67);
+ }
+}
+
+.o_dms_preview__fallback_actions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.o_dms_preview__fallback_note {
+ margin: 0;
+ max-width: 360px;
+ line-height: 1.35;
+}
+
+// ---- List row interactions when preview is open ----
+.o_dms_list_split[data-preview-open="true"] {
+ // Communicate that row click = preview (not navigate).
+ .o_list_renderer .o_data_row:hover {
+ cursor: zoom-in;
+ }
+}
diff --git a/dms/static/src/scss/portal.scss b/dms/static/src/scss/portal.scss
index 870da4be3..7fe4d8693 100644
--- a/dms/static/src/scss/portal.scss
+++ b/dms/static/src/scss/portal.scss
@@ -1,12 +1,105 @@
/**********************************************************************************
*
* 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).
*
**********************************************************************************/
+// Legacy contact-list image style — kept for backwards compatibility with
+// any third-party portal extensions that still reference it.
.o_portal_contact_img {
width: 2.3em;
height: 2.3em;
object-fit: cover;
}
+
+// ---- Portal card grid ----
+//
+// Customer-facing mirror of the backend card design. Same per-extension
+// accent vocabulary so the visual identity carries from backend to portal.
+// Per-extension `--dms-accent` rules are duplicated here rather than
+// extracted to a shared Sass partial — Odoo's bundler treats backend and
+// frontend bundles separately, and a small duplication keeps the change
+// self-contained. Extract to `_dms_tokens.scss` later if a third surface
+// needs the same palette.
+
+.o_dms_portal_grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+ gap: 12px;
+ margin-top: 1rem;
+}
+
+.o_dms_portal_card {
+ --dms-accent: var(--bs-secondary-color, #6c757d);
+
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 10px;
+ background-color: var(--bs-body-bg, #fff);
+ border: 1px solid var(--bs-border-color, rgba(0, 0, 0, 0.125));
+ border-left: 3px solid var(--dms-accent);
+ border-radius: 4px;
+ text-decoration: none;
+ color: inherit;
+ transition:
+ transform 120ms ease,
+ box-shadow 120ms ease,
+ border-color 120ms ease;
+
+ &:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ color: inherit;
+ text-decoration: none;
+ }
+
+ &__thumb {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--bs-tertiary-bg, #f4f5f7);
+ border-radius: 3px;
+ padding: 12px 0;
+ min-height: 56px;
+
+ img {
+ width: 40px;
+ height: 40px;
+ object-fit: contain;
+ }
+ }
+
+ &__body {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+ }
+
+ &__name {
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &__meta {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 0.75rem;
+ color: var(--bs-secondary-color, #6c757d);
+ }
+
+ &__dot {
+ opacity: 0.5;
+ }
+
+ // Directories use the primary accent — they navigate, not download.
+ &--directory {
+ --dms-accent: var(--bs-primary, #714b67);
+ }
+}
diff --git a/dms/static/tests/components/dms_stat_bar.test.js b/dms/static/tests/components/dms_stat_bar.test.js
new file mode 100644
index 000000000..fd5107b63
--- /dev/null
+++ b/dms/static/tests/components/dms_stat_bar.test.js
@@ -0,0 +1,129 @@
+// /** ********************************************************************************
+// Copyright 2026 ledoent — Don Kendall
+// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+//
+// Verifies the DmsStatBar pure-logic getters (isLoading, valueFor,
+// sparkPath, deltaText). The component is mounted-tested implicitly
+// via the directory kanban renderer test (separate file); here we lock
+// down the pure-function contract for the sparkline geometry + delta
+// interpolation so a regression won't silently make sparklines invisible
+// or flat.
+// **********************************************************************************/
+import {describe, expect, test} from "@odoo/hoot";
+import {DmsStatBar} from "@dms/js/components/dms_stat_bar.esm";
+
+// Instantiate without mounting — we only test pure-function logic on the
+// prototype. Components in OWL 2 still resolve `this.props` through Object
+// access, so a plain object stand-in is enough for the assertions below.
+function _instance(stats, tiles = undefined) {
+ const inst = Object.create(DmsStatBar.prototype);
+ inst.props = {
+ stats: stats,
+ tiles: tiles ?? DmsStatBar.defaultProps.tiles,
+ };
+ return inst;
+}
+
+describe("isLoading", () => {
+ test("true when stats is null", () => {
+ expect(_instance(null).isLoading).toBe(true);
+ });
+
+ test("false when stats is present", () => {
+ expect(_instance({files_total: 5}).isLoading).toBe(false);
+ });
+});
+
+describe("valueFor", () => {
+ test("returns em-dash when loading", () => {
+ const inst = _instance(null);
+ const tile = {key: "files_total"};
+ expect(inst.valueFor(tile)).toBe("—");
+ });
+
+ test("returns the raw stats value by tile.key", () => {
+ const inst = _instance({files_total: 1247});
+ expect(inst.valueFor({key: "files_total"})).toBe(1247);
+ });
+
+ test("returns em-dash for missing key", () => {
+ const inst = _instance({});
+ expect(inst.valueFor({key: "absent"})).toBe("—");
+ });
+});
+
+describe("sparkPath — line chart", () => {
+ test("returns hasData=false when series is missing", () => {
+ const inst = _instance({files_total: 5});
+ const tile = {sparklineKey: "absent_series"};
+ expect(inst.sparkPath(tile).hasData).toBe(false);
+ });
+
+ test("returns hasData=false for an all-zero series", () => {
+ const inst = _instance({zeros: [0, 0, 0, 0]});
+ const tile = {sparklineKey: "zeros"};
+ // Max=0 → hasData reads as false (the template skips the chart).
+ expect(inst.sparkPath(tile).hasData).toBe(false);
+ });
+
+ test("returns a points array + line path for a non-empty series", () => {
+ const inst = _instance({s: [0, 5, 10]});
+ const tile = {sparklineKey: "s", chart: "line"};
+ const result = inst.sparkPath(tile);
+ expect(result.hasData).toBe(true);
+ expect(result.points.length).toBe(3);
+ // First point sits at the left edge.
+ expect(result.points[0].x).toBe(0);
+ // Last point sits at the right edge (80px stat-bar width).
+ expect(result.points[2].x).toBe(80);
+ // Series max maps to the top of the chart (y=0).
+ expect(result.points[2].y).toBe(0);
+ // `linePath` is a space-separated set of "x,y" pairs.
+ expect(result.linePath.split(" ").length).toBe(3);
+ });
+
+ test("areaPath closes the polygon at the baseline", () => {
+ const inst = _instance({s: [1, 2, 3]});
+ const tile = {sparklineKey: "s"};
+ const result = inst.sparkPath(tile);
+ // Area path starts at 0,26 (bottom-left) and ends at 80,26 (bottom-
+ // right) so the SVG polygon fills downward to the baseline.
+ expect(result.areaPath.startsWith("0,26 ")).toBe(true);
+ expect(result.areaPath.endsWith(" 80,26")).toBe(true);
+ });
+});
+
+describe("sparkPath — bar chart", () => {
+ test("returns bar rectangles with width = 70% of slot", () => {
+ const inst = _instance({s: [1, 2, 3]});
+ const tile = {sparklineKey: "s", chart: "bar"};
+ const result = inst.sparkPath(tile);
+ expect(result.bars.length).toBe(3);
+ // Each slot is 80/3 ≈ 26.67px; bar fills 70% → ~18.67.
+ expect(result.bars[0].width).toBeCloseTo(18.67, 1);
+ });
+});
+
+describe("deltaText", () => {
+ test("returns empty string when loading", () => {
+ const inst = _instance(null);
+ const tile = {deltaKey: "files_delta_week", deltaSuffix: " this week"};
+ expect(inst.deltaText(tile)).toBe("");
+ });
+
+ test("interpolates prefix + value + suffix", () => {
+ const inst = _instance({new_today_avg_per_day: 14.2});
+ const tile = {
+ deltaKey: "new_today_avg_per_day",
+ deltaPrefix: "vs avg ",
+ deltaSuffix: "/day",
+ };
+ expect(inst.deltaText(tile)).toBe("vs avg 14.2/day");
+ });
+
+ test("returns empty string when delta value is undefined", () => {
+ const inst = _instance({});
+ const tile = {deltaKey: "missing"};
+ expect(inst.deltaText(tile)).toBe("");
+ });
+});
diff --git a/dms/static/tests/components/file_preview_pane.test.js b/dms/static/tests/components/file_preview_pane.test.js
new file mode 100644
index 000000000..a24562eaa
--- /dev/null
+++ b/dms/static/tests/components/file_preview_pane.test.js
@@ -0,0 +1,370 @@
+// /** ********************************************************************************
+// Copyright 2026 ledoent — Don Kendall
+// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+//
+// Verifies the FilePreviewPane component's behavior contract:
+// - State machine (empty, loading, loaded, error)
+// - ORM read shape (correct model + fields)
+// - Handler dispatch via the preview registry
+// - Action toolbar actions (Download URL, Share + Open form dispatch)
+//
+// Mounted-component coverage is left to the directory kanban + list
+// renderer integration tests; this file asserts on the pure-logic
+// methods using a stand-in instance.
+// **********************************************************************************/
+import {describe, expect, test} from "@odoo/hoot";
+import {
+ getPreviewHandler,
+ previewRegistry,
+} from "@dms/js/components/preview/preview_registry.esm";
+import {previewActionRegistry} from "@dms/js/components/preview/preview_action_registry.esm";
+import {FilePreviewPane} from "@dms/js/components/preview/file_preview_pane.esm";
+
+// Stand-in: bypass setup() so we don't need a mounted env. Plain reactive-
+// shaped state object is enough for the assertions below.
+function _instance({
+ state = {},
+ orm = null,
+ action = null,
+ notification = null,
+ onClose = null,
+} = {}) {
+ const inst = Object.create(FilePreviewPane.prototype);
+ inst.state = {loading: false, file: null, error: null, ...state};
+ inst.orm = orm;
+ inst.action = action;
+ inst.notification = notification;
+ inst.props = {recordId: null, onClose};
+ return inst;
+}
+
+describe("_load — ORM contract", () => {
+ test("calls orm.read with the correct model + field list", async () => {
+ const calls = [];
+ const orm = {
+ read: async (model, ids, fields) => {
+ calls.push({model, ids, fields});
+ return [{id: 42, name: "f.pdf", mimetype: "application/pdf"}];
+ },
+ };
+ const inst = _instance({orm});
+ await inst._load(42);
+ expect(calls.length).toBe(1);
+ expect(calls[0].model).toBe("dms.file");
+ expect(calls[0].ids).toEqual([42]);
+ // Read fields must include the keys the template depends on; adding
+ // a column later is fine, removing one breaks the empty-state copy.
+ expect(calls[0].fields).toEqual([
+ "id",
+ "name",
+ "mimetype",
+ "write_date",
+ "human_size",
+ ]);
+ });
+
+ test("populates state.file on success + clears loading", async () => {
+ const orm = {
+ read: async () => [{id: 7, name: "doc.pdf", mimetype: "application/pdf"}],
+ };
+ const inst = _instance({orm});
+ await inst._load(7);
+ expect(inst.state.loading).toBe(false);
+ expect(inst.state.file.id).toBe(7);
+ expect(inst.state.error).toBe(null);
+ });
+
+ test("populates state.error on failure + clears file", async () => {
+ const orm = {
+ read: async () => {
+ throw new Error("AccessError: not allowed");
+ },
+ };
+ const inst = _instance({state: {file: {id: 1}}, orm});
+ await inst._load(99);
+ expect(inst.state.loading).toBe(false);
+ expect(inst.state.file).toBe(null);
+ expect(inst.state.error).toContain("AccessError");
+ });
+});
+
+describe("handler dispatch via registry", () => {
+ test("returns null when no file is loaded", () => {
+ const inst = _instance();
+ expect(inst.handler).toBe(null);
+ expect(inst.HandlerComponent).toBe(null);
+ });
+
+ test("delegates to getPreviewHandler when file is loaded", () => {
+ // Register a stub handler scoped to a synthetic mimetype so we
+ // never collide with the built-in dispatcher set.
+ class StubPreviewComponent {}
+ const stubComponent = StubPreviewComponent;
+ const reg = previewRegistry();
+ reg.add("test_pane_stub", {
+ component: stubComponent,
+ match: (mt) => mt === "application/x-dms-test",
+ score: 100,
+ });
+ try {
+ const inst = _instance({
+ state: {file: {id: 1, mimetype: "application/x-dms-test"}},
+ });
+ const handler = inst.handler;
+ expect(handler?.key).toBe("test_pane_stub");
+ expect(inst.HandlerComponent).toBe(stubComponent);
+ // The registry returned the same handler the pane resolved to —
+ // proves the pane delegates rather than caching its own copy.
+ expect(getPreviewHandler("application/x-dms-test")?.key).toBe(
+ "test_pane_stub"
+ );
+ } finally {
+ reg.remove("test_pane_stub");
+ }
+ });
+});
+
+describe("toolbar actions", () => {
+ test("onDownloadClick is a no-op when no file loaded", () => {
+ const inst = _instance();
+ let opened = null;
+ const origOpen = window.open;
+ window.open = (url) => {
+ opened = url;
+ };
+ try {
+ inst.onDownloadClick();
+ expect(opened).toBe(null);
+ } finally {
+ window.open = origOpen;
+ }
+ });
+
+ test("onDownloadClick opens the /web/content URL with download=true", () => {
+ const inst = _instance({state: {file: {id: 42, name: "f.pdf"}}});
+ let openedUrl = null;
+ let openedTarget = null;
+ const origOpen = window.open;
+ window.open = (url, target) => {
+ openedUrl = url;
+ openedTarget = target;
+ };
+ try {
+ inst.onDownloadClick();
+ expect(openedUrl).toContain("/web/content?model=dms.file&id=42");
+ expect(openedUrl).toContain("download=true");
+ expect(openedUrl).toContain("filename_field=name");
+ expect(openedTarget).toBe("_blank");
+ } finally {
+ window.open = origOpen;
+ }
+ });
+
+ test("onShareClick dispatches the share action with active_* context", async () => {
+ const dispatched = [];
+ const action = {
+ doAction: async (xmlid, opts) => dispatched.push({xmlid, opts}),
+ };
+ const inst = _instance({state: {file: {id: 7}}, action});
+ await inst.onShareClick();
+ expect(dispatched.length).toBe(1);
+ expect(dispatched[0].xmlid).toBe("dms.wizard_dms_file_share_action");
+ // Wizard inherits portal.share — reads active_model + active_ids from
+ // context to seed res_model + res_id. Don't lose this contract.
+ expect(dispatched[0].opts.additionalContext.active_model).toBe("dms.file");
+ expect(dispatched[0].opts.additionalContext.active_ids).toEqual([7]);
+ expect(dispatched[0].opts.additionalContext.active_id).toBe(7);
+ });
+
+ test("onOpenFormClick dispatches the file action in form viewType", async () => {
+ const dispatched = [];
+ const action = {
+ doAction: async (xmlid, opts) => dispatched.push({xmlid, opts}),
+ };
+ const inst = _instance({state: {file: {id: 33}}, action});
+ await inst.onOpenFormClick();
+ expect(dispatched.length).toBe(1);
+ expect(dispatched[0].xmlid).toBe("dms.action_dms_file");
+ expect(dispatched[0].opts.viewType).toBe("form");
+ // Plain action descriptor lost the action context — must pass via
+ // props.resId for breadcrumbs to render correctly. Regression-prone.
+ expect(dispatched[0].opts.props.resId).toBe(33);
+ });
+
+ test("toolbar actions are no-ops when no file loaded", async () => {
+ const dispatched = [];
+ const action = {doAction: async (...args) => dispatched.push(args)};
+ const inst = _instance({action});
+ await inst.onShareClick();
+ await inst.onOpenFormClick();
+ expect(dispatched.length).toBe(0);
+ });
+});
+
+describe("close callback", () => {
+ test("onCloseClick invokes the onClose prop callback", () => {
+ let called = false;
+ const inst = _instance({onClose: () => (called = true)});
+ inst.onCloseClick();
+ expect(called).toBe(true);
+ });
+
+ test("onCloseClick is safe when no onClose prop provided", () => {
+ const inst = _instance();
+ // Should not throw.
+ inst.onCloseClick();
+ });
+});
+
+describe("handler dispatch with effective-mimetype fallback", () => {
+ // The pane's `handler` getter feeds the file through `_effectiveMimetype`
+ // before asking the registry — falling back on the filename extension
+ // when the stored mimetype is generic (libmagic returns
+ // application/octet-stream for several MP4 container variants).
+ test("application/octet-stream + .mp4 name → routes via video/mp4", () => {
+ const inst = _instance({
+ state: {
+ file: {
+ id: 1,
+ name: "Movie.mp4",
+ mimetype: "application/octet-stream",
+ },
+ },
+ });
+ // Resolves to the VideoPreview handler registered for video/* —
+ // the extension fallback rescued it from DownloadFallbackPreview.
+ expect(inst.handler).not.toBe(null);
+ expect(inst.handler.key).toBe("video/*");
+ });
+
+ test("crisp mimetype is preferred over extension fallback", () => {
+ // Application/pdf takes precedence even if the filename has a
+ // misleading extension — the stored mimetype is authoritative
+ // unless it's generic.
+ const inst = _instance({
+ state: {
+ file: {id: 2, name: "weird.mp4", mimetype: "application/pdf"},
+ },
+ });
+ expect(inst.handler.key).toBe("application/pdf");
+ });
+
+ test("application/octet-stream with no extension → download fallback", () => {
+ const inst = _instance({
+ state: {
+ file: {
+ id: 3,
+ name: "unknown",
+ mimetype: "application/octet-stream",
+ },
+ },
+ });
+ // No `.` in name → split-on-dot returns the whole name, no mapping
+ // hit → fall through to the score=-100 generic fallback.
+ expect(inst.handler.score).toBe(-100);
+ });
+
+ test(".md name with octet-stream mimetype routes to text/markdown", () => {
+ const inst = _instance({
+ state: {
+ file: {
+ id: 4,
+ name: "Notes.md",
+ mimetype: "application/octet-stream",
+ },
+ },
+ });
+ expect(inst.handler.key).toBe("text/markdown");
+ });
+});
+
+describe("extra actions", () => {
+ test("extraActions returns [] when no file is loaded", () => {
+ const inst = _instance();
+ expect(inst.extraActions).toEqual([]);
+ });
+
+ test("extraActions returns [] when registry is empty and file is loaded", () => {
+ const inst = _instance({state: {file: {id: 1, mimetype: "application/pdf"}}});
+ // By default no module has registered actions scoped to application/pdf
+ // in this test environment — the built-in toolbar has its own methods.
+ expect(Array.isArray(inst.extraActions)).toBe(true);
+ });
+
+ test("extraActions includes registered actions sorted by score", () => {
+ const reg = previewActionRegistry();
+ reg.add("test_action_hi", {
+ label: "High",
+ icon: "fa-star",
+ score: 20,
+ onClick: (_f) => _f,
+ });
+ reg.add("test_action_lo", {
+ label: "Low",
+ icon: "fa-tag",
+ score: 5,
+ onClick: (_f) => _f,
+ });
+ try {
+ const inst = _instance({
+ state: {file: {id: 1, mimetype: "application/pdf"}},
+ });
+ const keys = inst.extraActions.map((a) => a.key);
+ expect(
+ keys.indexOf("test_action_hi") < keys.indexOf("test_action_lo")
+ ).toBe(true);
+ } finally {
+ reg.remove("test_action_hi");
+ reg.remove("test_action_lo");
+ }
+ });
+
+ test("onExtraActionClick passes file and services to onClick", () => {
+ const received = [];
+ const reg = previewActionRegistry();
+ reg.add("test_action_cb", {
+ label: "CB",
+ icon: "fa-check",
+ onClick: (file, services) => received.push({file, services}),
+ });
+ try {
+ const file = {id: 42, mimetype: "application/pdf"};
+ const orm = {};
+ const action = {};
+ const notification = {};
+ const inst = _instance({state: {file}, orm, action, notification});
+ const entry = inst.extraActions.find((a) => a.key === "test_action_cb");
+ expect(entry).not.toBe(null);
+ inst.onExtraActionClick(entry);
+ expect(received.length).toBe(1);
+ expect(received[0].file).toBe(file);
+ expect(received[0].services.orm).toBe(orm);
+ expect(received[0].services.action).toBe(action);
+ expect(received[0].services.notification).toBe(notification);
+ } finally {
+ reg.remove("test_action_cb");
+ }
+ });
+
+ test("match() predicate filters actions to relevant file types", () => {
+ const reg = previewActionRegistry();
+ reg.add("test_action_pdf_only", {
+ label: "PDF Only",
+ icon: "fa-file-pdf-o",
+ match: (file) => file.mimetype === "application/pdf",
+ onClick: (_f) => _f,
+ });
+ try {
+ const pdfInst = _instance({
+ state: {file: {id: 1, mimetype: "application/pdf"}},
+ });
+ const imgInst = _instance({state: {file: {id: 2, mimetype: "image/png"}}});
+ const pdfKeys = pdfInst.extraActions.map((a) => a.key);
+ const imgKeys = imgInst.extraActions.map((a) => a.key);
+ expect(pdfKeys).toInclude("test_action_pdf_only");
+ expect(imgKeys).not.toInclude("test_action_pdf_only");
+ } finally {
+ reg.remove("test_action_pdf_only");
+ }
+ });
+});
diff --git a/dms/static/tests/components/preview_action_registry.test.js b/dms/static/tests/components/preview_action_registry.test.js
new file mode 100644
index 000000000..a2b729ade
--- /dev/null
+++ b/dms/static/tests/components/preview_action_registry.test.js
@@ -0,0 +1,77 @@
+// Copyright 2026 ledoent — Don Kendall
+// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+//
+// Locks down the dms.preview_actions registry contract: score ordering,
+// match() predicate filtering, and the null-file guard. Regressions here
+// silently break dms_auto_classification and future preview action modules.
+
+import {expect, test} from "@odoo/hoot";
+import {
+ getPreviewActions,
+ previewActionRegistry,
+} from "@dms/js/components/preview/preview_action_registry.esm";
+
+const _file = (id = 1, extra = {}) => ({
+ id,
+ name: "test.pdf",
+ mimetype: "application/pdf",
+ ...extra,
+});
+
+test("returns empty array for null file", () => {
+ expect(getPreviewActions(null)).toEqual([]);
+ expect(getPreviewActions(undefined)).toEqual([]);
+});
+
+test("returns empty array when no actions registered", () => {
+ // Fresh registry — no side-effect imports in this module.
+ expect(getPreviewActions(_file())).toEqual([]);
+});
+
+test("score ordering — higher score comes first", () => {
+ const reg = previewActionRegistry();
+ const onClick = () => undefined;
+ reg.add("action_low", {label: "Low", icon: "fa-arrow-down", score: 1, onClick});
+ reg.add("action_high", {label: "High", icon: "fa-arrow-up", score: 10, onClick});
+ try {
+ const actions = getPreviewActions(_file());
+ expect(actions[0].key).toBe("action_high");
+ expect(actions[1].key).toBe("action_low");
+ } finally {
+ reg.remove("action_low");
+ reg.remove("action_high");
+ }
+});
+
+test("match() predicate filters out non-matching files", () => {
+ const reg = previewActionRegistry();
+ const onClick = () => undefined;
+ reg.add("picky_action", {
+ label: "PDF Only",
+ icon: "fa-file-pdf-o",
+ match: (f) => (f.mimetype || "").startsWith("image/"),
+ onClick,
+ });
+ try {
+ const pdfFile = _file(1, {mimetype: "application/pdf"});
+ const imgFile = _file(2, {mimetype: "image/jpeg"});
+ expect(getPreviewActions(pdfFile).map((a) => a.key)).not.toInclude(
+ "picky_action"
+ );
+ expect(getPreviewActions(imgFile).map((a) => a.key)).toInclude("picky_action");
+ } finally {
+ reg.remove("picky_action");
+ }
+});
+
+test("action without match() always shows", () => {
+ const reg = previewActionRegistry();
+ const onClick = () => undefined;
+ reg.add("universal_action", {label: "Always", icon: "fa-star", onClick});
+ try {
+ const actions = getPreviewActions(_file());
+ expect(actions.map((a) => a.key)).toInclude("universal_action");
+ } finally {
+ reg.remove("universal_action");
+ }
+});
diff --git a/dms/static/tests/components/preview_handlers.test.js b/dms/static/tests/components/preview_handlers.test.js
new file mode 100644
index 000000000..2b3a75a32
--- /dev/null
+++ b/dms/static/tests/components/preview_handlers.test.js
@@ -0,0 +1,151 @@
+// /** ********************************************************************************
+// Copyright 2026 ledoent — Don Kendall
+// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+//
+// Verifies the built-in preview handlers:
+// - Per-mimetype getter URLs (src / downloadHref) are constructed
+// correctly so the rendered
![]()
/