feat(fileDto, folderDto): add is_favorite + is_shared
- provide is_shared and is_favorite information in DTO, information propagated as badge/buttons per items
regarding performances I try to be minimalis on SQL to prevent any perf regression
- remove old set of sharedids and favoriteids (was not functionnal anymore)
- fix date picker (no past selection) in grant
- fix contextmenu close on /files section
This commit is contained in:
@@ -69,9 +69,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
m(listFolder).mockResolvedValue({
|
||||
folders: [folder('sub1', 'Sub')],
|
||||
files: [],
|
||||
favoriteIds: [],
|
||||
sharedIds: []
|
||||
files: []
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -119,13 +119,6 @@
|
||||
* fields (`modified_at`, `created_by`).
|
||||
*/
|
||||
contextMap?: Map<string, ItemContext>;
|
||||
/**
|
||||
* Set of item ids the caller considers "favorite". When
|
||||
* provided, the star widget renders next to each row and
|
||||
* `onfavorite` is invoked on click. Kept as an external Set so
|
||||
* the page owns the source of truth (e.g. the favorites store).
|
||||
*/
|
||||
favoriteIds?: Set<string>;
|
||||
/**
|
||||
* Resolve `userId → display name`. Optional; when absent
|
||||
* `UserVignette` falls back to its own internal resolution.
|
||||
@@ -213,6 +206,14 @@
|
||||
onopen?: (item: FileItem | FolderItem) => void;
|
||||
/** Per-item favorite star toggle. */
|
||||
onfavorite?: (item: FileItem | FolderItem) => void;
|
||||
/**
|
||||
* Per-item share affordance — opens the page's ShareDialog for
|
||||
* this row. Wired symmetrically to `onfavorite`: the button lives
|
||||
* in `.action-cell`, its `.active` styling tracks `item.is_shared`,
|
||||
* and rows that are shared keep the button visible in list view
|
||||
* even when the row isn't hovered.
|
||||
*/
|
||||
onshared?: (item: FileItem | FolderItem) => void;
|
||||
/** Selection changed (set of selected item ids). */
|
||||
onselectionchange?: (ids: Set<string>) => void;
|
||||
/**
|
||||
@@ -353,7 +354,6 @@
|
||||
title,
|
||||
items,
|
||||
contextMap,
|
||||
favoriteIds,
|
||||
resolveOwnerName,
|
||||
loading = false,
|
||||
error = null,
|
||||
@@ -383,6 +383,7 @@
|
||||
onreload,
|
||||
onopen,
|
||||
onfavorite,
|
||||
onshared,
|
||||
onselectionchange,
|
||||
oncontextmenu: onContextMenuOverride,
|
||||
menuPrepare,
|
||||
@@ -463,7 +464,11 @@
|
||||
// gate below. Feeds both the list-view column track and the header
|
||||
// row's trailing placeholder so the layout stays in sync.
|
||||
const hasActionCell = $derived(
|
||||
!!onfavorite || !!itemActions || !!onContextMenuOverride || !!contextActions?.length
|
||||
!!onfavorite ||
|
||||
!!onshared ||
|
||||
!!itemActions ||
|
||||
!!onContextMenuOverride ||
|
||||
!!contextActions?.length
|
||||
);
|
||||
|
||||
// Build the list-view column track from the enabled cells.
|
||||
@@ -978,7 +983,6 @@
|
||||
{#snippet row(item: FileItem | FolderItem)}
|
||||
{@const kind = isFile(item) ? 'file' : 'folder'}
|
||||
{@const iconName = kind === 'folder' ? 'folder' : iconNameFromClass(iconClassOf(item))}
|
||||
{@const isFav = favoriteIds?.has(item.id) ?? false}
|
||||
{@const ctx = ctxOf(item.id)}
|
||||
{@const ownerId = ownerIdOf(item)}
|
||||
{@const dateVal = dateOf(item)}
|
||||
@@ -1060,11 +1064,11 @@
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Row badge (e.g. /trash's expiration chip) sits absolutely
|
||||
inside `.file-icon` — one DOM location, one render, works
|
||||
for both views. Because it's positioned absolutely it
|
||||
never affects the row/card height; the surrounding
|
||||
layout can't stretch to accommodate it. -->
|
||||
<!-- `rowBadge` snippet — page-specific extension slot (e.g.
|
||||
`/trash`'s expiration chip). `is_favorite` / `is_shared`
|
||||
are NOT rendered here — both are surfaced as buttons in
|
||||
`.action-cell` so the two flags share a single visual
|
||||
grammar (button whose `.active` styling tracks the flag). -->
|
||||
{#if rowBadge}
|
||||
<span class="file-icon__badge">{@render rowBadge(item, ctx)}</span>
|
||||
{/if}
|
||||
@@ -1106,31 +1110,55 @@
|
||||
</div>
|
||||
<!--
|
||||
Every row that surfaces an action puts everything into a single
|
||||
`.action-cell` — the shared `ported/resourceList.css` styles both
|
||||
the favorite-star and the `.file-actions` kebab expecting them to
|
||||
live inside `.action-cell` (grid view uses the corner-overlay CSS
|
||||
to float `.file-actions` into the top-right; list view flexes them
|
||||
inline). The cell renders when ANY of favorite / itemActions /
|
||||
context-menu is enabled; a row with none of those still lays out
|
||||
cleanly because the columns collapse via the grid track.
|
||||
`.action-cell` — the shared `ported/resourceList.css` styles the
|
||||
favorite-star, the shared-button, and the `.file-actions` kebab
|
||||
expecting them to live inside `.action-cell` (grid view uses the
|
||||
corner-overlay CSS to float them into the top-right; list view
|
||||
flexes them inline). The cell renders when ANY of favorite /
|
||||
shared / itemActions / context-menu is enabled; a row with none
|
||||
of those still lays out cleanly because the columns collapse via
|
||||
the grid track.
|
||||
|
||||
Fav-star and shared-button share the same interaction grammar:
|
||||
each toggles/opens the corresponding affordance, each carries an
|
||||
`.active` class that tracks its DTO flag, and (via CSS in
|
||||
`ported/resourceList.css`) each stays visible in list view even
|
||||
without a row hover when its `.active` flag is set — so a
|
||||
favorited or shared row is discoverable at a glance without the
|
||||
user having to mouse over it.
|
||||
-->
|
||||
{#if onfavorite || itemActions || onContextMenuOverride || contextActions?.length}
|
||||
{#if hasActionCell}
|
||||
<div class="action-cell">
|
||||
{#if onshared}
|
||||
<button
|
||||
class="shared-button"
|
||||
class:active={item.is_shared}
|
||||
data-testid={`resource-list-shared-${item.id}-btn`}
|
||||
title={item.is_shared ? t('files.shared', 'Shared') : t('files.share', 'Share')}
|
||||
aria-pressed={item.is_shared}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onshared(item);
|
||||
}}
|
||||
>
|
||||
<Icon name="oxiexport" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if onfavorite}
|
||||
<button
|
||||
class="favorite-star"
|
||||
class:active={isFav}
|
||||
class:active={item.is_favorite}
|
||||
data-testid={`resource-list-favorite-${item.id}-btn`}
|
||||
title={isFav
|
||||
title={item.is_favorite
|
||||
? t('files.unfavorite', 'Remove favorite')
|
||||
: t('files.favorite', 'Add favorite')}
|
||||
aria-pressed={isFav}
|
||||
aria-pressed={item.is_favorite}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onfavorite(item);
|
||||
}}
|
||||
>
|
||||
<Icon name={isFav ? 'star' : 'star-outline'} />
|
||||
<Icon name={item.is_favorite ? 'star' : 'star-outline'} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if itemActions}{@render itemActions(item)}{/if}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
fetchGrantsForResource,
|
||||
notifyGrantRecipient,
|
||||
revokeGrant,
|
||||
todayIso,
|
||||
updateGrantRole,
|
||||
type Grant,
|
||||
type GrantSubject,
|
||||
@@ -378,15 +379,33 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ── Reusable expiry chip ─────────────────────────────────────────────── -->
|
||||
<!-- ── Reusable expiry chip ───────────────────────────────────────────────
|
||||
|
||||
Both branches drive a `<input type="date">` and open the native
|
||||
picker via `HTMLInputElement.showPicker()`. The previous invisible-
|
||||
overlay trick (an `opacity: 0` input covering a chip label) was
|
||||
unreliable — some browsers refuse to open the picker for a hidden
|
||||
input, which is why "Set expiry" appeared inert. `showPicker()`
|
||||
is the modern, explicit path and works from a button click.
|
||||
|
||||
`min={todayIso()}` (from `../../api/endpoints/grants`) hard-caps
|
||||
the picker to today-or-later so a past date can't be selected.
|
||||
The `onchange` handler mirrors the same check as a belt-and-braces
|
||||
guard against the min attribute being ignored. -->
|
||||
{#snippet expiryChip(value: string | null, onchange: (v: string | null) => void)}
|
||||
{@const today = todayIso()}
|
||||
<span class="chip-edit">
|
||||
{#if value}
|
||||
<input
|
||||
class="chip-edit__date"
|
||||
type="date"
|
||||
value={value ?? ''}
|
||||
onchange={(e) => onchange((e.currentTarget as HTMLInputElement).value || null)}
|
||||
min={today}
|
||||
onchange={(e) => {
|
||||
const v = (e.currentTarget as HTMLInputElement).value;
|
||||
if (v && v < today) return;
|
||||
onchange(v || null);
|
||||
}}
|
||||
aria-label={t('share.expiry', 'Expiry')}
|
||||
/>
|
||||
<button
|
||||
@@ -396,16 +415,34 @@
|
||||
aria-label={t('actions.clear', 'Clear')}>×</button
|
||||
>
|
||||
{:else}
|
||||
<label class="chip chip--ghost">
|
||||
<Icon name="infinity" />
|
||||
<span>{t('share.noExpiry', 'No expiry')}</span>
|
||||
<input
|
||||
class="chip-edit__date chip-edit__date--hidden"
|
||||
type="date"
|
||||
onchange={(e) => onchange((e.currentTarget as HTMLInputElement).value || null)}
|
||||
<span class="chip-edit__ghost">
|
||||
<button
|
||||
type="button"
|
||||
class="chip chip--ghost"
|
||||
aria-label={t('share.set_expiry', 'Set expiry')}
|
||||
onclick={(e) => {
|
||||
const picker = (e.currentTarget as HTMLElement)
|
||||
.nextElementSibling as HTMLInputElement | null;
|
||||
picker?.showPicker?.();
|
||||
picker?.focus();
|
||||
}}
|
||||
>
|
||||
<Icon name="infinity" />
|
||||
<span>{t('share.noExpiry', 'No expiry')}</span>
|
||||
</button>
|
||||
<input
|
||||
class="chip-edit__date chip-edit__date--offscreen"
|
||||
type="date"
|
||||
min={today}
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
onchange={(e) => {
|
||||
const v = (e.currentTarget as HTMLInputElement).value;
|
||||
if (v && v < today) return;
|
||||
onchange(v || null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
@@ -584,7 +621,12 @@
|
||||
type="date"
|
||||
data-testid="share-dialog-link-expires-input"
|
||||
value={expiresAt ?? ''}
|
||||
onchange={(e) => (expiresAt = e.currentTarget.value || null)}
|
||||
min={todayIso()}
|
||||
onchange={(e) => {
|
||||
const v = e.currentTarget.value;
|
||||
if (v && v < todayIso()) return;
|
||||
expiresAt = v || null;
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -892,7 +934,19 @@
|
||||
|
||||
.chip--ghost {
|
||||
border-style: dashed;
|
||||
color: var(--color-text-muted);
|
||||
/* WCAG-friendly foreground on both light and dark surfaces —
|
||||
`--color-text-muted` was under the minimum AA contrast ratio,
|
||||
making "No expiry" hard to read. Use the subtle-but-not-muted
|
||||
text token instead, and give the ghost chip a low-tint
|
||||
background so it visually separates from the modal body. */
|
||||
color: var(--color-text-subtle);
|
||||
background: var(--color-bg-input);
|
||||
}
|
||||
|
||||
.chip--ghost:hover,
|
||||
.chip--ghost:focus-visible {
|
||||
color: var(--color-text);
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.chip-edit__date {
|
||||
@@ -904,11 +958,24 @@
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.chip-edit__date--hidden {
|
||||
/* Positions the hidden `<input type="date">` off-screen (no `display:
|
||||
none` — `showPicker()` refuses to open on a display:none input in
|
||||
several browsers). The button next to it invokes `showPicker()`
|
||||
programmatically. */
|
||||
.chip-edit__ghost {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chip-edit__date--offscreen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chip-edit__clear {
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock('$lib/api/endpoints/grants', () => ({
|
||||
fetchGrantsForResource: vi.fn(),
|
||||
notifyGrantRecipient: vi.fn(),
|
||||
revokeGrant: vi.fn(),
|
||||
todayIso: () => '2026-07-22',
|
||||
updateGrantRole: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/recipients', () => ({
|
||||
|
||||
@@ -43,6 +43,8 @@ interface TestFile {
|
||||
sort_date: number;
|
||||
etag: string;
|
||||
content_hash: string;
|
||||
is_favorite: boolean;
|
||||
is_shared: boolean;
|
||||
}
|
||||
|
||||
function fileItem(i: number): TestFile {
|
||||
@@ -63,7 +65,9 @@ function fileItem(i: number): TestFile {
|
||||
size_formatted: '4 B',
|
||||
sort_date: 0,
|
||||
etag: 'e',
|
||||
content_hash: 'h'
|
||||
content_hash: 'h',
|
||||
is_favorite: false,
|
||||
is_shared: false
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user