feat(user-pref): revert view mode as user-prefs serverside
previous change is breaking playwright tests, need to check later changes
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import Icon from '$lib/icons/Icon.svelte';
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
|
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||||
import { preferences } from '$lib/stores/preferences.svelte';
|
import { preferences } from '$lib/stores/preferences.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -126,19 +127,19 @@
|
|||||||
{#if showViewToggle}
|
{#if showViewToggle}
|
||||||
<button
|
<button
|
||||||
class="toggle-btn"
|
class="toggle-btn"
|
||||||
class:active={preferences.viewMode === 'grid'}
|
class:active={filesStore.viewMode === 'grid'}
|
||||||
title={t('view.grid', 'Grid view')}
|
title={t('view.grid', 'Grid view')}
|
||||||
aria-pressed={preferences.viewMode === 'grid'}
|
aria-pressed={filesStore.viewMode === 'grid'}
|
||||||
data-testid="list-toolbar-view-grid-btn"
|
data-testid="list-toolbar-view-grid-btn"
|
||||||
onclick={() => preferences.setViewMode('grid')}><Icon name="th" /></button
|
onclick={() => filesStore.setViewMode('grid')}><Icon name="th" /></button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="toggle-btn"
|
class="toggle-btn"
|
||||||
class:active={preferences.viewMode === 'list'}
|
class:active={filesStore.viewMode === 'list'}
|
||||||
title={t('view.list', 'List view')}
|
title={t('view.list', 'List view')}
|
||||||
aria-pressed={preferences.viewMode === 'list'}
|
aria-pressed={filesStore.viewMode === 'list'}
|
||||||
data-testid="list-toolbar-view-list-btn"
|
data-testid="list-toolbar-view-list-btn"
|
||||||
onclick={() => preferences.setViewMode('list')}><Icon name="list" /></button
|
onclick={() => filesStore.setViewMode('list')}><Icon name="list" /></button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if showDotfileToggle}
|
{#if showDotfileToggle}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
import UserVignette from '$lib/components/UserVignette.svelte';
|
import UserVignette from '$lib/components/UserVignette.svelte';
|
||||||
import VirtualList from '$lib/components/VirtualList.svelte';
|
import VirtualList from '$lib/components/VirtualList.svelte';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
import { preferences } from '$lib/stores/preferences.svelte';
|
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||||
import { formatBytes } from '$lib/utils/format';
|
import { formatBytes } from '$lib/utils/format';
|
||||||
import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display';
|
import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display';
|
||||||
import { gridColumns } from '$lib/utils/grid';
|
import { gridColumns } from '$lib/utils/grid';
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
|
|
||||||
const isEmpty = $derived(items.length === 0);
|
const isEmpty = $derived(items.length === 0);
|
||||||
const viewClass = $derived(
|
const viewClass = $derived(
|
||||||
preferences.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
|
filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
|
||||||
);
|
);
|
||||||
/** Content width, for computing the grid's column count to match auto-fill. */
|
/** Content width, for computing the grid's column count to match auto-fill. */
|
||||||
let gridWidth = $state(0);
|
let gridWidth = $state(0);
|
||||||
@@ -458,7 +458,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if preferences.viewMode === 'list'}
|
{#if filesStore.viewMode === 'list'}
|
||||||
<!-- Window each section's rows so a large grouped list (e.g. a big
|
<!-- Window each section's rows so a large grouped list (e.g. a big
|
||||||
trash, grouped by remaining days) doesn't mount every row. The
|
trash, grouped by remaining days) doesn't mount every row. The
|
||||||
grid-grouped branch stays un-windowed: `files-grid-view` is itself
|
grid-grouped branch stays un-windowed: `files-grid-view` is itself
|
||||||
@@ -471,7 +471,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if preferences.viewMode === 'list'}
|
{:else if filesStore.viewMode === 'list'}
|
||||||
<!-- Flat list view: only the visible rows are mounted. The spacer keeps the
|
<!-- Flat list view: only the visible rows are mounted. The spacer keeps the
|
||||||
full scroll height so the end-of-list sentinel still fires. -->
|
full scroll height so the end-of-list sentinel still fires. -->
|
||||||
<div class="files-list-view" style="--files-list-columns: {columns}">
|
<div class="files-list-view" style="--files-list-columns: {columns}">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { preferences } from '$lib/stores/preferences.svelte';
|
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Number of placeholder cards/rows to render (default 6). */
|
/** Number of placeholder cards/rows to render (default 6). */
|
||||||
@@ -12,11 +12,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="files-container">
|
<div class="files-container">
|
||||||
<div
|
<div class={filesStore.viewMode === 'grid' ? 'files-grid-view files-skeleton' : 'files-skeleton'}>
|
||||||
class={preferences.viewMode === 'grid' ? 'files-grid-view files-skeleton' : 'files-skeleton'}
|
|
||||||
>
|
|
||||||
{#each placeholders as i (i)}
|
{#each placeholders as i (i)}
|
||||||
{#if preferences.viewMode === 'grid'}
|
{#if filesStore.viewMode === 'grid'}
|
||||||
<div class="skeleton-card">
|
<div class="skeleton-card">
|
||||||
<div class="skeleton skeleton-thumb"></div>
|
<div class="skeleton skeleton-thumb"></div>
|
||||||
<div class="skeleton skeleton-line skeleton-line--medium"></div>
|
<div class="skeleton skeleton-line skeleton-line--medium"></div>
|
||||||
|
|||||||
@@ -72,23 +72,35 @@ export type Section =
|
|||||||
| 'photos'
|
| 'photos'
|
||||||
| 'music';
|
| 'music';
|
||||||
|
|
||||||
// `viewMode` used to live here (localStorage `oxi-view-mode`), but
|
const VIEW_KEY = 'oxi-view-mode';
|
||||||
// moved to the server-side `ui_preferences` bag so the choice
|
|
||||||
// follows the user across devices. Read via
|
function readViewMode(): ViewMode {
|
||||||
// `preferences.viewMode` and mutate via `preferences.setViewMode`
|
if (typeof localStorage === 'undefined') return 'grid';
|
||||||
// (`lib/stores/preferences.svelte.ts`). Kept `ViewMode` as an
|
return localStorage.getItem(VIEW_KEY) === 'list' ? 'list' : 'grid';
|
||||||
// exported type because template code still needs it for prop
|
}
|
||||||
// annotations without pulling in the whole preferences module.
|
|
||||||
|
|
||||||
class FilesStore {
|
class FilesStore {
|
||||||
currentFolder = $state<string | null>(null);
|
currentFolder = $state<string | null>(null);
|
||||||
currentFolderInfo = $state<FolderItem | null>(null);
|
currentFolderInfo = $state<FolderItem | null>(null);
|
||||||
breadcrumbPath = $state<Array<{ id: string; name: string }>>([]);
|
breadcrumbPath = $state<Array<{ id: string; name: string }>>([]);
|
||||||
|
// View mode INTENTIONALLY lives here (localStorage) rather than in
|
||||||
|
// the server-side `preferences` bag. See the note in
|
||||||
|
// `preferences.svelte.ts::UiPreferences` for the full rationale —
|
||||||
|
// short version: server persistence broke Playwright test
|
||||||
|
// isolation (favorites.spec's list-view click leaked into every
|
||||||
|
// downstream test's context), and view mode isn't a preference
|
||||||
|
// users have asked to sync across devices.
|
||||||
|
viewMode = $state<ViewMode>(readViewMode());
|
||||||
section = $state<Section>('files');
|
section = $state<Section>('files');
|
||||||
isSearchMode = $state(false);
|
isSearchMode = $state(false);
|
||||||
// Reactive set: in-place mutations below drive template/$derived reads.
|
// Reactive set: in-place mutations below drive template/$derived reads.
|
||||||
selection = new SvelteSet<string>();
|
selection = new SvelteSet<string>();
|
||||||
|
|
||||||
|
setViewMode(mode: ViewMode): void {
|
||||||
|
this.viewMode = mode;
|
||||||
|
if (typeof localStorage !== 'undefined') localStorage.setItem(VIEW_KEY, mode);
|
||||||
|
}
|
||||||
|
|
||||||
clearSelection(): void {
|
clearSelection(): void {
|
||||||
this.selection.clear();
|
this.selection.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,13 @@ it('shows the owner as "Me" for the current user and a short id otherwise', () =
|
|||||||
expect(ownerLabel('abcdef123456', 'someone-else')).toBe('abcdef12');
|
expect(ownerLabel('abcdef123456', 'someone-else')).toBe('abcdef12');
|
||||||
});
|
});
|
||||||
|
|
||||||
// View-mode assertions moved to `preferences.svelte.test.ts` — the
|
it('persists the view mode and toggles selection', () => {
|
||||||
// setting now lives on the server-side `ui_preferences` bag via the
|
files.setViewMode('list');
|
||||||
// `preferences` store, not on `FilesStore`. What remains of `FilesStore`
|
expect(files.viewMode).toBe('list');
|
||||||
// is navigation + selection state, exercised below.
|
expect(localStorage.getItem('oxi-view-mode')).toBe('list');
|
||||||
it('toggles selection', () => {
|
files.setViewMode('grid');
|
||||||
|
expect(files.viewMode).toBe('grid');
|
||||||
|
|
||||||
files.clearSelection();
|
files.clearSelection();
|
||||||
expect(files.selection.size).toBe(0);
|
expect(files.selection.size).toBe(0);
|
||||||
files.toggleSelected('a');
|
files.toggleSelected('a');
|
||||||
|
|||||||
@@ -41,22 +41,23 @@ export interface UiPreferences {
|
|||||||
* preserved on upload, matching Nextcloud / ownCloud / Seafile.
|
* preserved on upload, matching Nextcloud / ownCloud / Seafile.
|
||||||
*/
|
*/
|
||||||
hide_dotfiles?: boolean;
|
hide_dotfiles?: boolean;
|
||||||
/**
|
// NOTE: view_mode (grid/list) DELIBERATELY stays in localStorage
|
||||||
* App-wide file list view: grid tiles or list rows. Default
|
// (`oxi-view-mode` on `filesStore`). Making it server-persistent
|
||||||
* `'grid'`. Migrated from the localStorage `oxi-view-mode` key
|
// caused a real Playwright regression: `favorites.spec.ts` clicks
|
||||||
* so the choice follows the user across devices — muscle memory
|
// the list-view toggle, and on the server-backed store that
|
||||||
* for "I use list on my laptop, grid on my tablet" is rare;
|
// preference would then leak into every downstream test's fresh
|
||||||
* consistency across devices is the common case. Public-share
|
// browser context — Playwright's default context isolation
|
||||||
* viewers still use `oxi-share-view` (localStorage) because
|
// relies on localStorage being fresh per test, which the server
|
||||||
* anonymous consumers have no server preferences.
|
// bag can't provide. Result: files-extra's `Zip-*` folder fell
|
||||||
*/
|
// outside list view's smaller virtualisation window (~25 vs ~75
|
||||||
view_mode?: 'grid' | 'list';
|
// grid items) and `getByTestId` timed out. Google Drive / Finder
|
||||||
|
// / Dropbox also keep view mode per-device — the sync-across-
|
||||||
|
// devices UX isn't a strongly-requested pattern.
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reasonable default for an empty bag or a missing key. */
|
/** Reasonable default for an empty bag or a missing key. */
|
||||||
const DEFAULTS: Required<UiPreferences> = {
|
const DEFAULTS: Required<UiPreferences> = {
|
||||||
hide_dotfiles: false,
|
hide_dotfiles: false
|
||||||
view_mode: 'grid'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,8 +85,6 @@ class PreferencesStore {
|
|||||||
: DEFAULTS.hide_dotfiles
|
: DEFAULTS.hide_dotfiles
|
||||||
);
|
);
|
||||||
|
|
||||||
viewMode = $derived<'grid' | 'list'>(this.bag.view_mode === 'list' ? 'list' : DEFAULTS.view_mode);
|
|
||||||
|
|
||||||
// ── Mutations ─────────────────────────────────────────────────
|
// ── Mutations ─────────────────────────────────────────────────
|
||||||
|
|
||||||
private patchTimer: ReturnType<typeof setTimeout> | null = null;
|
private patchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -158,10 +157,6 @@ class PreferencesStore {
|
|||||||
toggleHideDotfiles(): void {
|
toggleHideDotfiles(): void {
|
||||||
this.setHideDotfiles(!this.hideDotfiles);
|
this.setHideDotfiles(!this.hideDotfiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
setViewMode(mode: 'grid' | 'list'): void {
|
|
||||||
this.set({ view_mode: mode });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const preferences = new PreferencesStore();
|
export const preferences = new PreferencesStore();
|
||||||
|
|||||||
@@ -1366,7 +1366,7 @@
|
|||||||
// hiding N items" hint so users aren't confused.
|
// hiding N items" hint so users aren't confused.
|
||||||
const isEmpty = $derived(visibleFolders.length === 0 && visibleFiles.length === 0);
|
const isEmpty = $derived(visibleFolders.length === 0 && visibleFiles.length === 0);
|
||||||
const viewClass = $derived(
|
const viewClass = $derived(
|
||||||
preferences.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
|
filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Client-side sort (flat, Drive-style). The listing endpoint returns the
|
// Client-side sort (flat, Drive-style). The listing endpoint returns the
|
||||||
@@ -1806,7 +1806,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if preferences.viewMode === 'list'}
|
{:else if filesStore.viewMode === 'list'}
|
||||||
<!-- Flat list: only the rows near the viewport are mounted. -->
|
<!-- Flat list: only the rows near the viewport are mounted. -->
|
||||||
<div class="files-list-view">
|
<div class="files-list-view">
|
||||||
{@render fileListHeader()}
|
{@render fileListHeader()}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ vi.mock('$lib/api/endpoints/folders', () => ({
|
|||||||
import { fetchFolderListing, createFolder, deleteFolder } from '$lib/api/endpoints/folders';
|
import { fetchFolderListing, createFolder, deleteFolder } from '$lib/api/endpoints/folders';
|
||||||
import { deleteFile } from '$lib/api/endpoints/files';
|
import { deleteFile } from '$lib/api/endpoints/files';
|
||||||
import { apiFetch } from '$lib/api/client';
|
import { apiFetch } from '$lib/api/client';
|
||||||
import { preferences } from '$lib/stores/preferences.svelte';
|
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||||
import FilesPage from './[...path]/+page.svelte';
|
import FilesPage from './[...path]/+page.svelte';
|
||||||
|
|
||||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||||
@@ -126,12 +126,7 @@ beforeEach(() => {
|
|||||||
// listing-oriented tests target a folder directly.
|
// listing-oriented tests target a folder directly.
|
||||||
pageState.params.path = 'home';
|
pageState.params.path = 'home';
|
||||||
// List view renders the select-all header + per-row checkboxes; grid hides them.
|
// List view renders the select-all header + per-row checkboxes; grid hides them.
|
||||||
// The store's `setViewMode` writes through to the server bag via a
|
filesStore.viewMode = 'list';
|
||||||
// debounced PATCH; in the test harness there's no session so the
|
|
||||||
// PATCH silently no-ops on the network side but the optimistic local
|
|
||||||
// mutation (session.user.ui_preferences.view_mode) still lands and
|
|
||||||
// downstream `preferences.viewMode` re-derives to 'list'.
|
|
||||||
preferences.setViewMode('list');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads the home folder listing on mount and renders its contents', async () => {
|
it('loads the home folder listing on mount and renders its contents', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user