fix(recipes): preserve scroll on in-place reloads

This commit is contained in:
Will Miao
2026-04-13 10:30:50 +08:00
parent 39c083db79
commit ba1800095e
9 changed files with 173 additions and 18 deletions

View File

@@ -4,10 +4,15 @@ const showToastMock = vi.hoisted(() => vi.fn());
const loadingManagerMock = vi.hoisted(() => ({
showSimpleLoading: vi.fn(),
hide: vi.fn(),
restoreProgressBar: vi.fn(),
}));
const virtualScrollerMock = vi.hoisted(() => ({
updateSingleItem: vi.fn(),
refreshWithData: vi.fn(),
}));
const getCurrentPageStateMock = vi.hoisted(() => vi.fn());
const captureScrollPositionMock = vi.hoisted(() => vi.fn());
const restoreScrollPositionMock = vi.hoisted(() => vi.fn());
vi.mock('../../../static/js/utils/uiHelpers.js', () => {
return {
@@ -25,16 +30,39 @@ vi.mock('../../../static/js/state/index.js', () => {
loadingManager: loadingManagerMock,
virtualScroller: virtualScrollerMock,
},
getCurrentPageState: vi.fn(),
getCurrentPageState: getCurrentPageStateMock,
};
});
import { RecipeSidebarApiClient, fetchRecipeDetails, updateRecipeMetadata } from '../../../static/js/api/recipeApi.js';
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
captureScrollPosition: captureScrollPositionMock,
restoreScrollPosition: restoreScrollPositionMock,
}));
import {
RecipeSidebarApiClient,
fetchRecipeDetails,
resetAndReload,
syncChanges,
updateRecipeMetadata
} from '../../../static/js/api/recipeApi.js';
describe('RecipeSidebarApiClient bulk operations', () => {
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
getCurrentPageStateMock.mockReturnValue({
pageSize: 50,
currentPage: 1,
hasMore: true,
isLoading: false,
sortBy: 'date:desc',
showFavoritesOnly: false,
activeFolder: null,
searchOptions: { recursive: true },
customFilter: { active: false },
filters: {},
});
});
afterEach(() => {
@@ -148,4 +176,44 @@ describe('RecipeSidebarApiClient bulk operations', () => {
{ title: 'Updated Title' }
);
});
it('preserves scroll position for recipe reloads when requested', async () => {
const scrollSnapshot = { scrollContainer: { scrollTop: 480 }, scrollTop: 480 };
captureScrollPositionMock.mockReturnValue(scrollSnapshot);
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({
items: [{ id: 'recipe-1' }],
total: 1,
total_pages: 1,
}),
});
await resetAndReload(false, { preserveScroll: true });
expect(captureScrollPositionMock).toHaveBeenCalledTimes(1);
expect(virtualScrollerMock.refreshWithData).toHaveBeenCalledWith(
[{ id: 'recipe-1' }],
1,
false
);
expect(restoreScrollPositionMock).toHaveBeenCalledWith(scrollSnapshot);
});
it('uses scroll-preserving reloads for syncChanges', async () => {
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({
items: [],
total: 0,
total_pages: 0,
}),
});
await syncChanges();
expect(captureScrollPositionMock).toHaveBeenCalledTimes(1);
expect(restoreScrollPositionMock).toHaveBeenCalledTimes(1);
expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1);
});
});

View File

@@ -212,6 +212,19 @@ describe('RecipeManager', () => {
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1);
});
it('supports preserve-scroll options while keeping boolean compatibility', async () => {
const manager = new RecipeManager();
await manager.loadRecipes({ preserveScroll: true });
expect(refreshVirtualScrollMock).toHaveBeenNthCalledWith(1, { preserveScroll: true });
await manager.loadRecipes(false);
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1);
await manager.loadRecipes({ resetPage: true, preserveScroll: false });
expect(refreshVirtualScrollMock).toHaveBeenNthCalledWith(2, { preserveScroll: false });
});
it('proxies duplicate management and refresh helpers', async () => {
const manager = new RecipeManager();