fix(ui): better handling of new folder creation

issue: on folder creation view was wiped and displaying only the new folder
    fix: add a "new" swimlane if in group mode and scroll up to the new created folder
This commit is contained in:
Edouard Vanbelle
2026-06-05 11:15:44 +02:00
parent ca0f5b3fc5
commit 540c947e61
19 changed files with 233 additions and 24 deletions
+11 -3
View File
@@ -366,9 +366,17 @@ async function _loadPage({ isFirstPage = false } = {}) {
function addItem(item) {
const component = _ensureComponent();
if (!component) return;
// Reveal the list if the empty-state is showing
ui.resetFilesList();
component.addItem(item);
// Hide the empty-state placeholder if it's currently showing —
// creating a folder in an empty directory should reveal the new
// row, not display both states side-by-side. (The component will
// un-hide `#files-list` itself when the item is inserted.)
document.getElementById('files-container-error')?.classList.add('hidden');
// Hand the item to the component so it can place it in the right
// swimlane (when the current view is grouped) and pulse-highlight
// + smooth-scroll it into view. We deliberately do NOT call
// `ui.resetFilesList()` here — that wipes the rendered DOM,
// defeating the whole point of an optimistic single-item insert.
component.addItem(item, { scroll: true, highlight: true });
}
/**
+151 -5
View File
@@ -28,6 +28,15 @@ import { createUserVignette } from './userVignette.js';
* @import {FileItem, FolderItem} from '../core/types.js'
*/
/**
* Reusable swimlane key for the client-side "just added" lane that
* `addItem()` opens at the top of the list in grouped views. Distinct
* from any natural group key the server might produce so the lookup
* can't collide with a real bucket whose label happens to read "New".
* @type {string}
*/
const JUST_ADDED_KEY = '__oxicloud_just_added__';
/**
* @typedef {Object} CustomAction
* @property {string} iconHtml - Inner HTML for the button icon (e.g. `<i class="fas fa-undo"></i>`).
@@ -131,6 +140,16 @@ export class ResourceListComponent {
*/
this._lastGroupEl = null;
/**
* Optional grouping-key resolver stored between `render()` / `append()`
* calls so `addItem()` can place a new row in the correct swimlane
* without the caller having to re-supply it. `undefined` means the
* current view is flat (no group-by); `null` is never stored — only
* function or `undefined`.
* @type {((item: FileItem|FolderItem) => string|null) | undefined}
*/
this._groupFn = undefined;
/**
* Optional label-resolver stored between `render()` and `append()` calls.
* @type {((key: string) => string) | undefined}
@@ -184,6 +203,7 @@ export class ResourceListComponent {
// Reset group tracking for the fresh render
this._lastGroupKey = undefined;
this._lastGroupEl = null;
this._groupFn = groupFn;
this._groupLabelFn = groupLabelFn;
this._headerNodeFn = headerNodeFn;
@@ -205,7 +225,13 @@ export class ResourceListComponent {
* @param {((key: string) => HTMLElement)=} headerNodeFn
*/
append(items, groupFn, groupLabelFn, headerNodeFn) {
this._appendItems(items, groupFn, groupLabelFn ?? this._groupLabelFn, headerNodeFn ?? this._headerNodeFn);
// Persist the latest non-undefined callbacks so `addItem()` can
// reuse them without the caller having to re-supply them on every
// optimistic insertion.
if (groupFn !== undefined) this._groupFn = groupFn;
if (groupLabelFn !== undefined) this._groupLabelFn = groupLabelFn;
if (headerNodeFn !== undefined) this._headerNodeFn = headerNodeFn;
this._appendItems(items, groupFn ?? this._groupFn, groupLabelFn ?? this._groupLabelFn, headerNodeFn ?? this._headerNodeFn);
}
/** Remove all items (but keep `.list-header` if present). */
@@ -218,6 +244,9 @@ export class ResourceListComponent {
this._lastClickedIndex = -1;
this._lastGroupKey = undefined;
this._lastGroupEl = null;
this._groupFn = undefined;
this._groupLabelFn = undefined;
this._headerNodeFn = undefined;
// Hand delegation back to ui.js
delete this._container.dataset.managedBy;
}
@@ -276,16 +305,128 @@ export class ResourceListComponent {
/**
* Append a single item, skipping silently if already present (duplicate guard).
* Clears the empty-state placeholder when the first item is added.
*
* Group-by aware: if the current view is grouped, the row goes into
* a dedicated **"New" swimlane pinned at the top of the list** that
* is created on first call and reused across subsequent inserts in
* the same session. This deliberately sidesteps re-computing the
* item's natural bucket on the client:
*
* - Different group-by dimensions (date, type, size, …) would each
* need their own resolver, and date-bucket math is sensitive to
* clock skew between client and server.
* - Cross-swimlane sort-position is impossible to mirror exactly
* without re-implementing the server's tiebreaker chain.
*
* The "New" lane is purely client-side and dissolves on the next
* full reload (when the server's authoritative grouping reasserts).
* Predictable and uniform across every group-by mode.
*
* @param {FileItem|FolderItem} item
* @param {{ scroll?: boolean, highlight?: boolean }} [opts]
* - `scroll`: smooth-scroll the new row into view. The
* `.resource-row--just-added` class also sets `scroll-margin`
* so the sticky page header doesn't cover the row.
* - `highlight`: flash a brief CSS pulse on the new row so the
* user can spot it amid similar siblings.
* @returns {HTMLElement | null} The inserted row, or `null` when the
* item was deduped.
*/
addItem(item) {
if (this._items.has(item.id)) return;
addItem(item, opts = {}) {
if (this._items.has(item.id)) return null;
// Also guard against stale DOM remnants not tracked in _items
const isFile = 'mime_type' in item;
const attr = isFile ? `data-file-id="${item.id}"` : `data-folder-id="${item.id}"`;
if (this._container.querySelector(`.file-item[${attr}]`)) return;
if (this._container.querySelector(`.file-item[${attr}]`)) return null;
this._container.classList.remove('hidden');
this._appendItems([item]);
/** @type {HTMLElement | null} */
let row = null;
if (this._groupFn) {
// Grouped view → drop the new row into the top-of-list
// "New" swimlane, creating it on first call.
const lane = this._ensureJustAddedLane();
this._items.set(item.id, item);
row = isFile ? this._createFileItem(/** @type {FileItem} */ (item)) : this._createFolderItem(/** @type {FolderItem} */ (item));
lane.appendChild(row);
} else {
// Flat list (no grouping) — append at the end like before.
this._appendItems([item]);
row = /** @type {HTMLElement | null} */ (this._container.querySelector(`.file-item[${attr}]`));
}
if (!row) return null;
if (opts.highlight) {
row.classList.add('resource-row--just-added');
// Self-cleaning: drop the class once the keyframe completes
// so a future re-render starts from a neutral baseline.
row.addEventListener('animationend', () => row?.classList.remove('resource-row--just-added'), { once: true });
}
if (opts.scroll) {
// `block: 'nearest'` is a no-op when the row is already in
// view. `.resource-row--just-added` sets `scroll-margin-top`
// so the sticky page header doesn't clip the row when the
// scroll lands.
row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
return row;
}
/**
* Ensure the top-of-list "New" swimlane exists and return its
* wrapper. Created on first call after a render; reused across
* subsequent `addItem()` calls in the same session. The lane
* dissolves on the next `render()` / `clear()`, at which point
* the server's authoritative grouping reasserts.
*
* Header label uses i18n key `groupby.justAdded` with an English
* fallback so views that haven't translated it still read sensibly.
*
* @returns {HTMLElement}
*/
_ensureJustAddedLane() {
const existing = this._findLaneByKey(JUST_ADDED_KEY);
if (existing) return existing;
const lane = document.createElement('div');
lane.className = 'resource-list__swimlane-group resource-list__swimlane-group--just-added';
lane.dataset.groupKey = JUST_ADDED_KEY;
const header = document.createElement('div');
header.className = 'resource-list__swimlane-header';
header.dataset.swimlaneHeader = 'true';
header.textContent = i18n.t('groupby.justAdded', 'New');
lane.appendChild(header);
// Insert at the very top of the container, immediately after
// the optional `.list-header` row, so the affordance is
// discoverable and the row's scroll-into-view brings the
// swimlane header into view too.
const listHeader = this._container.querySelector('.list-header');
if (listHeader?.nextSibling) {
this._container.insertBefore(lane, listHeader.nextSibling);
} else if (listHeader) {
this._container.appendChild(lane);
} else {
this._container.prepend(lane);
}
return lane;
}
/**
* Locate an on-screen swimlane wrapper by its group key. Returns
* `null` when no swimlane currently matches.
*
* @param {string} key
* @returns {HTMLElement | null}
*/
_findLaneByKey(key) {
// CSS.escape covers arbitrary key shapes (dates with colons,
// UUIDs with dashes, etc.) so the attribute selector is safe.
return /** @type {HTMLElement | null} */ (this._container.querySelector(`.resource-list__swimlane-group[data-group-key="${CSS.escape(String(key))}"]`));
}
/**
@@ -404,6 +545,11 @@ export class ResourceListComponent {
if (key !== null) {
fragmentGroup = document.createElement('div');
fragmentGroup.className = 'resource-list__swimlane-group';
// Stamp the group key on the wrapper so `addItem()`
// can locate this swimlane later via
// `_findLaneByKey()` and append into it without a
// full re-render.
fragmentGroup.dataset.groupKey = key;
fragmentGroup.appendChild(this._createGroupHeader(key, groupLabelFn, headerNodeFn));
fragment.appendChild(fragmentGroup);
}