diff --git a/static/css/components/resourceList.css b/static/css/components/resourceList.css index a26e0d90..d80ffd41 100644 --- a/static/css/components/resourceList.css +++ b/static/css/components/resourceList.css @@ -574,6 +574,20 @@ margin-top: 0; } +/* When the header contains a rich DOM node (e.g. a user vignette for the + "owner" group-by), reset the typographic overrides that only make sense + for plain-text labels, and lay the node out inline. */ +.resource-list__swimlane-header--node { + display: flex; + align-items: center; + padding: 4px 12px; + text-transform: none; + letter-spacing: normal; + font-size: inherit; + font-weight: normal; + color: inherit; +} + /* ── Swimlane group card (list view only) ────────────────── */ /* When swimlane groups are present, dissolve the outer container into the diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index f2af0004..086e4964 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -76,6 +76,19 @@ const GROUP_BY_DEFS = [ return labels[key] ?? key; } }, + { + key: 'size', + get label() { + return i18n.t('groupby.size', 'Size'); + }, + orderBy: 'size', + // sizeBucket(-1) → "Folders" sentinel; no labelFn needed. + keyFn: (item) => { + if (!('mime_type' in item)) return sizeBucket(-1); + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return sizeBucket(r.size ?? 0); + } + }, { key: 'modifiedAt', get label() { @@ -98,19 +111,6 @@ const GROUP_BY_DEFS = [ const r = /** @type {Record} */ (/** @type {unknown} */ (item)); return r.created_at ? normalizeDateBucket(r.created_at) : null; } - }, - { - key: 'size', - get label() { - return i18n.t('groupby.size', 'Size'); - }, - orderBy: 'size', - // sizeBucket(-1) → "Folders" sentinel; no labelFn needed. - keyFn: (item) => { - if (!('mime_type' in item)) return sizeBucket(-1); - const r = /** @type {Record} */ (/** @type {unknown} */ (item)); - return sizeBucket(r.size ?? 0); - } } ]; diff --git a/static/js/components/resourceList.js b/static/js/components/resourceList.js index d1393997..67f64d8e 100644 --- a/static/js/components/resourceList.js +++ b/static/js/components/resourceList.js @@ -115,6 +115,14 @@ export class ResourceListComponent { */ this._groupLabelFn = undefined; + /** + * Optional node-builder stored between `render()` and `append()` calls. + * When set, the swimlane header renders a DOM node instead of plain text + * (e.g. a user vignette for the "owner" group-by dimension). + * @type {((key: string) => HTMLElement) | undefined} + */ + this._headerNodeFn = undefined; + this._ownerVisible = this._cfg.showOwner; this._initDelegation(); @@ -138,8 +146,12 @@ export class ResourceListComponent { * @param {((key: string) => string)=} groupLabelFn * Optional: converts the raw grouping key to a human-readable header * label. When omitted the key itself is used. + * @param {((key: string) => HTMLElement)=} headerNodeFn + * Optional: builds a rich DOM node for the swimlane header (e.g. a user + * vignette for the "owner" group-by). When provided, `groupLabelFn` is + * ignored for the header and the returned node is appended instead. */ - render(items, groupFn, groupLabelFn) { + render(items, groupFn, groupLabelFn, headerNodeFn) { const header = this._container.querySelector('.list-header'); this._container.innerHTML = ''; if (header) this._container.appendChild(header); @@ -151,11 +163,12 @@ export class ResourceListComponent { this._lastGroupKey = undefined; this._lastGroupEl = null; this._groupLabelFn = groupLabelFn; + this._headerNodeFn = headerNodeFn; // Prevent ui.js global delegation from firing on this container this._container.dataset.managedBy = 'resource-list'; - this._appendItems(items, groupFn, groupLabelFn); + this._appendItems(items, groupFn, groupLabelFn, headerNodeFn); this._wireSelectAll(); } @@ -167,9 +180,10 @@ export class ResourceListComponent { * @param {Array} items * @param {((item: FileItem|FolderItem) => string|null)=} groupFn * @param {((key: string) => string)=} groupLabelFn + * @param {((key: string) => HTMLElement)=} headerNodeFn */ - append(items, groupFn, groupLabelFn) { - this._appendItems(items, groupFn, groupLabelFn ?? this._groupLabelFn); + append(items, groupFn, groupLabelFn, headerNodeFn) { + this._appendItems(items, groupFn, groupLabelFn ?? this._groupLabelFn, headerNodeFn ?? this._headerNodeFn); } /** Remove all items (but keep `.list-header` if present). */ @@ -328,8 +342,9 @@ export class ResourceListComponent { * @param {Array} items * @param {((item: FileItem|FolderItem) => string|null)=} groupFn * @param {((key: string) => string)=} groupLabelFn + * @param {((key: string) => HTMLElement)=} headerNodeFn */ - _appendItems(items, groupFn, groupLabelFn) { + _appendItems(items, groupFn, groupLabelFn, headerNodeFn) { const fragment = document.createDocumentFragment(); // Start from the persisted key so load-more pages continue seamlessly. @@ -354,7 +369,7 @@ export class ResourceListComponent { if (key !== null) { fragmentGroup = document.createElement('div'); fragmentGroup.className = 'resource-list__swimlane-group'; - fragmentGroup.appendChild(this._createGroupHeader(key, groupLabelFn)); + fragmentGroup.appendChild(this._createGroupHeader(key, groupLabelFn, headerNodeFn)); fragment.appendChild(fragmentGroup); } } @@ -382,14 +397,25 @@ export class ResourceListComponent { /** * Create a swimlane divider element. - * @param {string} key - Raw grouping key (e.g. UUID or bucket name). - * @param {((key: string) => string)=} labelFn - Optional human-readable resolver. + * + * When `headerNodeFn` is supplied the header renders a rich DOM node + * (e.g. a user vignette) instead of plain text; the `--node` CSS modifier + * is added to suppress the small-caps / uppercase text styles. + * + * @param {string} key - Raw grouping key (e.g. UUID or bucket name). + * @param {((key: string) => string)=} labelFn - Optional plain-text resolver. + * @param {((key: string) => HTMLElement)=} headerNodeFn - Optional rich-node builder. */ - _createGroupHeader(key, labelFn) { + _createGroupHeader(key, labelFn, headerNodeFn) { const el = document.createElement('div'); el.className = 'resource-list__swimlane-header'; el.dataset.swimlaneHeader = 'true'; - el.textContent = labelFn ? labelFn(key) : key; + if (headerNodeFn) { + el.classList.add('resource-list__swimlane-header--node'); + el.appendChild(headerNodeFn(key)); + } else { + el.textContent = labelFn ? labelFn(key) : key; + } return el; } diff --git a/static/js/views/favorites/favoritesView.js b/static/js/views/favorites/favoritesView.js index 86fffd07..c721e92a 100644 --- a/static/js/views/favorites/favoritesView.js +++ b/static/js/views/favorites/favoritesView.js @@ -18,6 +18,7 @@ import { ui } from '../../app/ui.js'; import { ResourceListComponent } from '../../components/resourceList.js'; +import { createUserVignette } from '../../components/userVignette.js'; import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js'; import { i18n } from '../../core/i18n.js'; import * as viewPrefs from '../../core/viewPrefs.js'; @@ -32,7 +33,8 @@ import { systemUsers } from '../../model/systemUsers.js'; /** * @typedef {{ key: string, label: string, orderBy: string, * keyFn: (item: FileItem|FolderItem) => string|null, - * labelFn?: (key: string) => string }} GroupByDef + * labelFn?: (key: string) => string, + * headerNodeFn?: (key: string) => HTMLElement }} GroupByDef */ /** @@ -42,6 +44,20 @@ import { systemUsers } from '../../model/systemUsers.js'; * @type {GroupByDef[]} */ const GROUP_BY_DEFS = [ + { + key: 'owner', + get label() { + return i18n.t('groupby.owner', 'Owner'); + }, + orderBy: 'owner', + // keyFn groups by UUID — stable, avoids collisions on identical display names. + keyFn: (item) => { + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return r.owner_id || null; + }, + labelFn: (id) => systemUsers.getDisplayNameSync(id), + headerNodeFn: (id) => createUserVignette(id, 'sm') + }, { key: 'type', get label() { @@ -71,6 +87,19 @@ const GROUP_BY_DEFS = [ return labels[key] ?? key; } }, + { + key: 'size', + get label() { + return i18n.t('groupby.size', 'Size'); + }, + orderBy: 'size', + // Folders have no size — sizeBucket(-1) returns the "Folders" label. + keyFn: (item) => { + if (!('mime_type' in item)) return sizeBucket(-1); + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return sizeBucket(r.size ?? 0); + } + }, { key: 'favoriteDate', get label() { @@ -94,32 +123,6 @@ const GROUP_BY_DEFS = [ const r = /** @type {Record} */ (/** @type {unknown} */ (item)); return r.modified_at ? normalizeDateBucket(r.modified_at) : null; } - }, - { - key: 'size', - get label() { - return i18n.t('groupby.size', 'Size'); - }, - orderBy: 'size', - // Folders have no size — sizeBucket(-1) returns the "Folders" label. - keyFn: (item) => { - if (!('mime_type' in item)) return sizeBucket(-1); - const r = /** @type {Record} */ (/** @type {unknown} */ (item)); - return sizeBucket(r.size ?? 0); - } - }, - { - key: 'owner', - get label() { - return i18n.t('groupby.owner', 'Owner'); - }, - orderBy: 'owner', - // keyFn groups by UUID — stable, avoids collisions on identical display names. - keyFn: (item) => { - const r = /** @type {Record} */ (/** @type {unknown} */ (item)); - return r.owner_id || null; - }, - labelFn: (id) => systemUsers.getDisplayNameSync(id) } ]; @@ -304,9 +307,9 @@ const favoritesView = { const items = this._mapItems(data.items); if (isFirstPage) { - this._component?.render(items, def?.keyFn, def?.labelFn); + this._component?.render(items, def?.keyFn, def?.labelFn, def?.headerNodeFn); } else { - this._component?.append(items, def?.keyFn, def?.labelFn); + this._component?.append(items, def?.keyFn, def?.labelFn, def?.headerNodeFn); } // Wire unified item tooltip (owner + path) after items are in the DOM diff --git a/static/js/views/recent/recentView.js b/static/js/views/recent/recentView.js index e2190916..a758bb3a 100644 --- a/static/js/views/recent/recentView.js +++ b/static/js/views/recent/recentView.js @@ -19,6 +19,7 @@ import { ui } from '../../app/ui.js'; import { ResourceListComponent } from '../../components/resourceList.js'; +import { createUserVignette } from '../../components/userVignette.js'; import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js'; import { i18n } from '../../core/i18n.js'; import * as viewPrefs from '../../core/viewPrefs.js'; @@ -33,7 +34,8 @@ import { systemUsers } from '../../model/systemUsers.js'; /** * @typedef {{ key: string, label: string, orderBy: string, * keyFn: (item: FileItem|FolderItem) => string|null, - * labelFn?: (key: string) => string }} GroupByDef + * labelFn?: (key: string) => string, + * headerNodeFn?: (key: string) => HTMLElement }} GroupByDef */ /** @@ -52,6 +54,19 @@ import { systemUsers } from '../../model/systemUsers.js'; * @type {GroupByDef[]} */ const GROUP_BY_DEFS = [ + { + key: 'owner', + get label() { + return i18n.t('groupby.owner', 'Owner'); + }, + orderBy: 'owner', + keyFn: (item) => { + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return r.owner_id || null; + }, + labelFn: (id) => systemUsers.getDisplayNameSync(id), + headerNodeFn: (id) => createUserVignette(id, 'sm') + }, { key: 'type', get label() { @@ -80,6 +95,18 @@ const GROUP_BY_DEFS = [ return labels[key] ?? key; } }, + { + key: 'size', + get label() { + return i18n.t('groupby.size', 'Size'); + }, + orderBy: 'size', + keyFn: (item) => { + if (!('mime_type' in item)) return sizeBucket(-1); + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return sizeBucket(r.size ?? 0); + } + }, { key: 'accessedAt', get label() { @@ -102,30 +129,6 @@ const GROUP_BY_DEFS = [ const r = /** @type {Record} */ (/** @type {unknown} */ (item)); return r.modified_at ? normalizeDateBucket(r.modified_at) : null; } - }, - { - key: 'size', - get label() { - return i18n.t('groupby.size', 'Size'); - }, - orderBy: 'size', - keyFn: (item) => { - if (!('mime_type' in item)) return sizeBucket(-1); - const r = /** @type {Record} */ (/** @type {unknown} */ (item)); - return sizeBucket(r.size ?? 0); - } - }, - { - key: 'owner', - get label() { - return i18n.t('groupby.owner', 'Owner'); - }, - orderBy: 'owner', - keyFn: (item) => { - const r = /** @type {Record} */ (/** @type {unknown} */ (item)); - return r.owner_id || null; - }, - labelFn: (id) => systemUsers.getDisplayNameSync(id) } ]; @@ -311,9 +314,9 @@ const recentView = { const items = this._mapItems(data.items); if (isFirstPage) { - this._component?.render(items, def?.keyFn, def?.labelFn); + this._component?.render(items, def?.keyFn, def?.labelFn, def?.headerNodeFn); } else { - this._component?.append(items, def?.keyFn, def?.labelFn); + this._component?.append(items, def?.keyFn, def?.labelFn, def?.headerNodeFn); } // Wire unified item tooltip (owner + path) after items are in the DOM. diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js index ba4cb839..d8323b87 100644 --- a/static/js/views/sharedWithMe/sharedWithMeView.js +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -12,6 +12,7 @@ import { ui } from '../../app/ui.js'; import { ResourceListComponent } from '../../components/resourceList.js'; +import { createUserVignette } from '../../components/userVignette.js'; import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js'; import { i18n } from '../../core/i18n.js'; import * as viewPrefs from '../../core/viewPrefs.js'; @@ -26,7 +27,8 @@ import { systemUsers } from '../../model/systemUsers.js'; /** * @typedef {{ key: string, label: string, orderBy: string, * keyFn: (item: FileItem|FolderItem) => string|null, - * labelFn?: (key: string) => string }} GroupByDef + * labelFn?: (key: string) => string, + * headerNodeFn?: (key: string) => HTMLElement }} GroupByDef */ /** @@ -42,6 +44,24 @@ import { systemUsers } from '../../model/systemUsers.js'; * @type {GroupByDef[]} */ const GROUP_BY_DEFS = [ + { + key: 'owner', + // label is accessed via syncGroupByMenu → read at section-switch time, + // when translations are guaranteed to be loaded. + get label() { + return i18n.t('groupby.owner', 'Owner'); + }, + orderBy: 'granted_by', + // keyFn groups by UUID — stable and unique, avoids collisions between + // users with the same display name. + keyFn: (item) => { + const r = /** @type {Record} */ (/** @type {unknown} */ (item)); + return r.owner_id || null; + }, + // labelFn resolves UUID → display name from the pre-fetched cache. + labelFn: (id) => systemUsers.getDisplayNameSync(id), + headerNodeFn: (id) => createUserVignette(id, 'sm') + }, { key: 'type', get label() { @@ -74,23 +94,6 @@ const GROUP_BY_DEFS = [ return labels[key] ?? key; } }, - { - key: 'owner', - // label is accessed via syncGroupByMenu → read at section-switch time, - // when translations are guaranteed to be loaded. - get label() { - return i18n.t('groupby.owner', 'Owner'); - }, - orderBy: 'granted_by', - // keyFn groups by UUID — stable and unique, avoids collisions between - // users with the same display name. - keyFn: (item) => { - const r = /** @type {Record} */ (/** @type {unknown} */ (item)); - return r.owner_id || null; - }, - // labelFn resolves UUID → display name from the pre-fetched cache. - labelFn: (id) => systemUsers.getDisplayNameSync(id) - }, { key: 'size', get label() { @@ -316,9 +319,9 @@ const sharedWithMeView = { const items = this._mapItems(data.items); if (isFirstPage) { - this._component?.render(items, def?.keyFn, def?.labelFn); + this._component?.render(items, def?.keyFn, def?.labelFn, def?.headerNodeFn); } else { - this._component?.append(items, def?.keyFn, def?.labelFn); + this._component?.append(items, def?.keyFn, def?.labelFn, def?.headerNodeFn); } // Wire owner tooltips after items are in the DOM