added group by:
- None (= ordered by folders/file name)
- Type (Folder first, then Image, Vidao, Audio, Document, etc...)
- Owner
- Size (With logarithmic groups))
- Shared date (with groups: today, last 7 days, last 30 days, then year)
16 KiB
Plan: Group-by swimlanes in SharedWithMe
Context
The SharedWithMe view now uses ResourceListComponent which already accepts an optional groupFn in render() / append(). The task is to expose a Group by dropdown in the actions-bar that lets users cluster items into swimlane sections by Owner or Share date. Changing the grouping restarts the cursor-paginated fetch with the matching sort_by query param so the server delivers items pre-sorted for the chosen dimension — the frontend only needs to inject dividers when the key changes.
Architecture overview
main.js (UI) sharedWithMeView.js grants.js / backend
──────────────────────── ─────────────────── ──────────────────
[Group by] dropdown ──────→ setGroupBy(key) ──────→ fetchSharedWithMe({ orderBy })
shows: None / Owner / _groupBy state ↳ GET …?sort_by=granted_by
Share date resets cursor ↳ GET …?sort_by=granted_at
_makeGroupFn() ←────── items in server sort order
render(f, flds, keyFn, labelFn)
↓
ResourceListComponent
injects swimlane dividers
when keyFn(item) changes
Extensibility contract (GroupByDef)
Each view that supports grouping defines a GroupByDef[] array locally:
/**
* @typedef {{ key: string, orderBy: string, keyFn: (item: FileItem|FolderItem) => string|null, labelFn?: (key: string) => string }} GroupByDef
*/
key— internal identifier (''= none,'owner','shareDate')orderBy— value forwarded to the API assort_bykeyFn(item)— returns the grouping key (UUID, bucket name). Same key → same swimlane.labelFn(key)— converts the raw key to a human-readable header. Optional (identity if omitted).
The separation of keyFn / labelFn is critical for the Owner case: grouping is keyed by UUID (stable, unique), but the swimlane header shows the resolved display name.
Changes — Frontend
1. static/js/components/resourceList.js
A. Persist _lastGroupKey across append() calls
Current bug: _lastGroupKey is local to _appendItems, so loading page 2 always inserts a redundant swimlane header for the first item even if it belongs to the same group as the last item on page 1.
Fix:
// constructor
this._lastGroupKey = /** @type {string|null|undefined} */ (undefined);
// render() — reset before first page
this._lastGroupKey = undefined;
// _appendItems() — read and write instance field
let lastGroupKey = this._lastGroupKey;
// ... existing loop (unchanged) ...
this._lastGroupKey = lastGroupKey; // persist for next append()
B. Add optional groupLabelFn parameter
/**
* @param {FolderItem[]} folders
* @param {FileItem[]} files
* @param {((item: FileItem|FolderItem) => string|null)=} groupKeyFn
* @param {((key: string) => string)=} groupLabelFn — defaults to identity
*/
render(folders, files, groupKeyFn, groupLabelFn) { … }
append(folders, files, groupKeyFn, groupLabelFn) { … }
Pass groupLabelFn down to _appendItems and use it in _createGroupHeader:
_createGroupHeader(key, labelFn) {
const label = labelFn ? labelFn(key) : key;
el.textContent = label;
…
}
Store this._groupLabelFn on the instance between render() and append() calls (same pattern as _lastGroupKey).
2. static/css/components/resourceList.css
Add missing swimlane-header styles (block was referenced in JS but had no CSS):
/* ── swimlane group header ─────────────────────────── */
.resource-list__swimlane-header {
grid-column: 1 / -1;
padding: 6px 12px 4px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-faint);
border-bottom: 1px solid var(--color-border);
margin-top: 8px;
}
.resource-list__swimlane-header:first-child { margin-top: 0; }
3. static/js/core/formatters.js
Add normalizeDateBucket(dateStr) — pure, no imports needed:
/**
* Normalize an ISO-8601 date string into a human-readable bucket label.
* Buckets (newest-first): Today | Last 7 days | Last 30 days | <YYYY>
* @param {string} dateStr
* @returns {string}
*/
export function normalizeDateBucket(dateStr) {
const date = new Date(dateStr);
const diffDays = Math.floor((Date.now() - date.getTime()) / 86_400_000);
if (diffDays === 0) return i18n.t('dateBucket.today', 'Today');
if (diffDays <= 7) return i18n.t('dateBucket.last7days', 'Last 7 days');
if (diffDays <= 30) return i18n.t('dateBucket.last30days','Last 30 days');
return String(date.getFullYear());
}
(Import i18n at top of formatters.js if not already present — check first.)
4. static/js/model/systemUsers.js
Add synchronous best-effort lookup for use in groupKeyFn / swimlane labels:
/**
* Synchronous best-effort display-name lookup from the pre-fetched cache.
* Returns a shortened UUID prefix when the cache is not yet loaded.
* @param {string} userId
* @returns {string}
*/
getDisplayNameSync(userId) {
if (_index === null) return `${userId.slice(0, 8)}…`;
return _index.get(userId) ?? `${userId.slice(0, 8)}…`;
},
The cache is loaded by prefetch() which sharedWithMeView.init() already calls at startup. By the time the first items render, the cache is warm in virtually all cases.
5. static/js/model/grants.js
Add orderBy param to fetchSharedWithMe:
async fetchSharedWithMe({ resourceTypes = ['file', 'folder'], limit = 50, cursor, orderBy } = {}) {
const params = new URLSearchParams({ limit: String(limit), resource_types: resourceTypes.join(',') });
if (cursor) params.set('cursor', cursor);
if (orderBy) params.set('sort_by', orderBy);
…
}
6. static/js/views/sharedWithMe/sharedWithMeView.js
New state:
/** @type {string} '' | 'owner' | 'shareDate' */
_groupBy: '',
GROUP_BY_DEFS constant (module-level):
const GROUP_BY_DEFS = [
{
key: 'owner',
orderBy: 'granted_by',
keyFn: (item) => item.owner_id || null,
labelFn: (id) => systemUsers.getDisplayNameSync(id)
},
{
key: 'shareDate',
orderBy: 'granted_at',
// sort_date is set to item.granted_at in _mapItems()
keyFn: (item) => {
const d = /** @type {Record<string,string>} */ (/** @type {unknown} */ (item)).sort_date;
return d ? normalizeDateBucket(d) : null;
}
}
];
setGroupBy(key) public method:
setGroupBy(key) {
if (this._groupBy === key) return;
this._groupBy = key;
this._nextCursor = null; // restart from page 1
this._component?.clear(); // clear DOM items
this._loadPage();
},
_mapItems() change: Set sort_date: item.granted_at on both folders and files (replaces f.modified_at in files). This is the field the shareDate keyFn reads.
_loadPage() change: Derive active def and pass to API + component:
const def = GROUP_BY_DEFS.find(d => d.key === this._groupBy);
const data = await grants.fetchSharedWithMe({
…,
orderBy: def?.orderBy // undefined when no grouping
});
…
if (isFirstPage) {
this._component?.render(folders, files, def?.keyFn, def?.labelFn);
} else {
this._component?.append(folders, files, def?.keyFn, def?.labelFn);
}
7. static/js/app/main.js
A. New _toggleButtonsWithGroupBy template (inside .view-toggle):
const _toggleButtonsWithGroupBy = `
<div class="view-toggle">
<div class="group-by-selector" id="group-by-selector">
<button class="toggle-btn group-by-btn" id="group-by-btn" title="Group by" data-i18n-title="groupby.title">
<i class="fas fa-layer-group"></i>
</button>
<div class="group-by-menu hidden" id="group-by-menu">
<button class="group-by-option active" data-group-by="" data-i18n="groupby.none">None</button>
<button class="group-by-option" data-group-by="owner" data-i18n="groupby.owner">Owner</button>
<button class="group-by-option" data-group-by="shareDate" data-i18n="groupby.shareDate">Share date</button>
</div>
</div>
<span class="view-toggle-separator"></span>
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
<i class="fas fa-th"></i>
</button>
<button class="toggle-btn" id="list-view-btn" title="List view">
<i class="fas fa-list"></i>
</button>
</div>
`;
B. Update sharedwithme template: Also add missing _batchToolbarButons:
sharedwithme: `
<div class="action-buttons" id="default-buttons"></div>
${_batchToolbarButons}
${_toggleButtonsWithGroupBy}
`
C. setupActionsBarDelegation() — add group-by handling before the switch:
// Group-by option selected
if (btn.classList.contains('group-by-option')) {
const key = btn.dataset.groupBy ?? '';
sharedWithMeView.setGroupBy(key);
document.querySelectorAll('.group-by-option').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('group-by-menu')?.classList.add('hidden');
document.getElementById('group-by-btn')?.classList.toggle('active', !!key);
return;
}
switch (btn.id) {
case 'group-by-btn':
document.getElementById('group-by-menu')?.classList.toggle('hidden');
break;
…
}
D. Add a document.addEventListener('click', …) (or reuse the existing upload-dropdown pattern) to close the group-by menu on outside clicks.
8. CSS for group-by dropdown
Add to static/css/components/buttons.css (already contains .view-toggle styles):
/* ── Group-by selector (inside .view-toggle) ─────────── */
.view-toggle-separator {
width: 1px;
height: 20px;
background: var(--color-border);
align-self: center;
margin: 0 2px;
}
.group-by-selector {
position: relative;
}
.group-by-btn.active { color: var(--color-accent); }
.group-by-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 200;
min-width: 140px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 4px 16px var(--color-shadow);
padding: 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
.group-by-menu.hidden { display: none; }
.group-by-option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
color: var(--color-text);
text-align: left;
width: 100%;
}
.group-by-option:hover { background: var(--color-border); }
.group-by-option.active { color: var(--color-accent); font-weight: 600; }
Changes — Backend
9. src/domain/services/authorization.rs — update GrantCursor
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GrantCursor {
/// Sort dimension active when this cursor was issued.
/// Mis-match with the current `sort_by` param → cursor is discarded.
#[serde(default = "GrantCursor::default_sort")]
pub sort_by: String,
pub granted_at: chrono::DateTime<chrono::Utc>,
pub resource_id: Uuid,
/// Present only when `sort_by == "granted_by"`.
#[serde(skip_serializing_if = "Option::is_none")]
pub granted_by: Option<Uuid>,
}
impl GrantCursor {
fn default_sort() -> String { "granted_at".to_owned() }
}
impl PageCursor for GrantCursor {}
Old cursors (which lack sort_by) fail serde and are treated as "start from top" — the existing "undecodable cursor → restart" invariant applies.
10. src/application/ports/authorization_ports.rs — add sort_by arg
async fn list_incoming_resources_paged(
&self,
subject: Subject,
kinds: &[ResourceKind],
limit: u32,
cursor: Option<GrantCursor>,
sort_by: &str, // "granted_at" | "granted_by"
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError>;
11. src/infrastructure/services/pg_acl_engine.rs — branch SQL on sort_by
Two separate sqlx::query_as calls, selected at runtime:
sort_by = "granted_by" SQL:
WITH agg AS ( … same aggregation … )
SELECT resource_type, resource_id, permissions, granted_at, granted_by
FROM agg
WHERE ( $4::uuid IS NULL -- cursor_by
OR granted_by > $4::uuid
OR (granted_by = $4::uuid AND (
$5::timestamptz IS NULL -- cursor_at
OR granted_at < $5
OR (granted_at = $5 AND resource_id < $6::uuid))))
ORDER BY granted_by ASC, granted_at DESC, resource_id DESC
LIMIT $7
Cursor for next page: GrantCursor { sort_by: "granted_by", granted_at: r.3, resource_id: r.1, granted_by: Some(r.4) }
sort_by = "granted_at" SQL (existing, unchanged except cursor struct gains sort_by field):
Cursor for next page: GrantCursor { sort_by: "granted_at", granted_at: r.3, resource_id: r.1, granted_by: None }
12. src/interfaces/api/handlers/grant_handler.rs
let sort_by = q.sort_by.as_deref().unwrap_or("granted_at");
if !matches!(sort_by, "granted_at" | "granted_by") {
return (StatusCode::BAD_REQUEST, Json(json!({"error": "invalid sort_by"}))).into_response();
}
// Invalidate cursor when sort mode changed (prevents keyset confusion)
let cursor = q.decode_cursor::<GrantCursor>()
.filter(|c| c.sort_by == sort_by);
let (summaries, next_cursor) = state.authorization
.list_incoming_resources_paged(subject, &kinds, limit, cursor, sort_by)
.await …;
Files touched
| File | Change |
|---|---|
static/js/components/resourceList.js |
Persist _lastGroupKey; add groupLabelFn param to render/append |
static/css/components/resourceList.css |
Add .resource-list__swimlane-header styles |
static/js/core/formatters.js |
Add normalizeDateBucket() |
static/js/model/systemUsers.js |
Add getDisplayNameSync() |
static/js/model/grants.js |
Add orderBy param |
static/js/views/sharedWithMe/sharedWithMeView.js |
Add _groupBy, setGroupBy(), GROUP_BY_DEFS; update _mapItems, _loadPage |
static/js/app/main.js |
Add _toggleButtonsWithGroupBy; add batch toolbar to sharedwithme; wire group-by delegation |
static/css/components/buttons.css |
Add group-by dropdown styles + .view-toggle-separator |
src/domain/services/authorization.rs |
Update GrantCursor struct |
src/application/ports/authorization_ports.rs |
Add sort_by to trait method signature |
src/infrastructure/services/pg_acl_engine.rs |
Branch SQL on sort_by; emit new cursor shape |
src/interfaces/api/handlers/grant_handler.rs |
Extract + validate sort_by; filter cursor on mismatch |
Known limitations (out of scope)
- Owner sort order is by UUID, not display name. Items from the same owner are correctly grouped, but the ORDER of groups is UUID-lexicographic, not alphabetical by name. Alphabetical ordering would require a server-side join to the users table and a different cursor — deferred.
- Batch operations from SharedWithMe navigate to Files —
batchDelete()callsloadFiles(). Pre-existing bug; separate PR. - Group-by is SharedWithMe-only — the
GroupByDefcontract is extensible but no other view is wired in this PR.
Verification
# Backend
cargo fmt --all
cargo clippy --all-features --all-targets -- -D warnings
cargo test --workspace
# Frontend
biome lint static/js/
stylelint static/css/
tsc -p jsconfig.json --noEmit
Manual smoke tests:
- SharedWithMe loads with no group-by → items appear, no swimlane headers
- Select "Owner" → page reloads, swimlane headers show resolved display names grouped by granter
- "Load more" appends without inserting a redundant header for a continuing group
- Select "Share date" → swimlane headers: Today / Last 7 days / Last 30 days / year
- Switch back to "None" → plain list, no headers
- Cursor cursor changes don't bleed across sort modes (switching group-by resets to page 1)
- Grid ↔ list toggle still works in all group-by states
- Group-by menu closes when clicking outside