mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-09 07:20:15 -03:00
feat(recipes): branch masonry scroller instantiation for recipes page
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { VirtualScroller } from './VirtualScroller.js';
|
import { VirtualScroller } from './VirtualScroller.js';
|
||||||
|
import { MasonryScroller } from './MasonryScroller.js';
|
||||||
import { createModelCard, setupModelCardEventDelegation } from '../components/shared/ModelCard.js';
|
import { createModelCard, setupModelCardEventDelegation } from '../components/shared/ModelCard.js';
|
||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
import { showToast } from './uiHelpers.js';
|
import { showToast } from './uiHelpers.js';
|
||||||
@@ -141,8 +142,14 @@ async function initializeVirtualScroll(pageType) {
|
|||||||
throw new Error(`Required components not available for ${pageType} page`);
|
throw new Error(`Required components not available for ${pageType} page`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Masonry applies to the recipes page only; the read pattern mirrors
|
||||||
|
// VirtualScroller.js (display_density).
|
||||||
|
const useMasonry = pageType === 'recipes'
|
||||||
|
&& (state.global.settings?.recipes_layout ?? 'grid') === 'masonry';
|
||||||
|
const ScrollerClass = useMasonry ? MasonryScroller : VirtualScroller;
|
||||||
|
|
||||||
// Initialize virtual scroller with renamed container elements
|
// Initialize virtual scroller with renamed container elements
|
||||||
state.virtualScroller = new VirtualScroller({
|
state.virtualScroller = new ScrollerClass({
|
||||||
gridElement: grid,
|
gridElement: grid,
|
||||||
containerElement: gridContainer,
|
containerElement: gridContainer,
|
||||||
scrollContainer: scrollContainer,
|
scrollContainer: scrollContainer,
|
||||||
@@ -250,3 +257,19 @@ export async function refreshVirtualScroll(options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rebuild the virtual scroller from scratch (used for layout switching).
|
||||||
|
// refreshVirtualScroll only resets the existing instance, so it cannot swap
|
||||||
|
// the scroller class. Keyboard navigation must be cleaned up first:
|
||||||
|
// setupKeyboardNavigation appends a new document keydown listener on every
|
||||||
|
// initializeVirtualScroll call and never removes the previous one.
|
||||||
|
export async function recreateVirtualScroll(pageType) {
|
||||||
|
cleanupKeyboardNavigation();
|
||||||
|
|
||||||
|
if (state.virtualScroller) {
|
||||||
|
state.virtualScroller.dispose();
|
||||||
|
state.virtualScroller = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await initializeVirtualScroll(pageType);
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ vi.mock('../../../static/js/state/index.js', () => {
|
|||||||
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||||
captureScrollPosition: captureScrollPositionMock,
|
captureScrollPosition: captureScrollPositionMock,
|
||||||
restoreScrollPosition: restoreScrollPositionMock,
|
restoreScrollPosition: restoreScrollPositionMock,
|
||||||
|
recreateVirtualScroll: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ vi.mock('../../../static/js/utils/eventManagementInit.js', () => ({
|
|||||||
|
|
||||||
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||||
initializeInfiniteScroll: vi.fn(),
|
initializeInfiniteScroll: vi.fn(),
|
||||||
|
recreateVirtualScroll: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
|
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ vi.mock('../../../static/js/components/DuplicatesManager.js', () => ({
|
|||||||
|
|
||||||
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||||
refreshVirtualScroll: refreshVirtualScrollMock,
|
refreshVirtualScroll: refreshVirtualScrollMock,
|
||||||
|
recreateVirtualScroll: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||||
|
|||||||
198
tests/frontend/utils/infiniteScroll.test.js
Normal file
198
tests/frontend/utils/infiniteScroll.test.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
VirtualScrollerMock,
|
||||||
|
MasonryScrollerMock,
|
||||||
|
RecipeCardMock,
|
||||||
|
fetchRecipesPageMock,
|
||||||
|
getModelApiClientMock,
|
||||||
|
} = vi.hoisted(() => {
|
||||||
|
const makeScrollerClass = () => vi.fn(function (options) {
|
||||||
|
this.options = options;
|
||||||
|
this.initialize = vi.fn(async () => {});
|
||||||
|
this.dispose = vi.fn();
|
||||||
|
this.reset = vi.fn();
|
||||||
|
this.handlePageUpDown = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
VirtualScrollerMock: makeScrollerClass(),
|
||||||
|
MasonryScrollerMock: makeScrollerClass(),
|
||||||
|
RecipeCardMock: vi.fn(function () {
|
||||||
|
this.element = document.createElement('div');
|
||||||
|
}),
|
||||||
|
fetchRecipesPageMock: vi.fn(async () => ({ items: [], totalItems: 0, hasMore: false })),
|
||||||
|
getModelApiClientMock: vi.fn(() => ({
|
||||||
|
fetchModelsPage: vi.fn(async () => ({ items: [], totalItems: 0, hasMore: false })),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/VirtualScroller.js', () => ({
|
||||||
|
VirtualScroller: VirtualScrollerMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/MasonryScroller.js', () => ({
|
||||||
|
MasonryScroller: MasonryScrollerMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
|
||||||
|
RecipeCard: RecipeCardMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||||
|
fetchRecipesPage: fetchRecipesPageMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||||
|
getModelApiClient: getModelApiClientMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
|
||||||
|
createModelCard: vi.fn(() => document.createElement('div')),
|
||||||
|
setupModelCardEventDelegation: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
initializeInfiniteScroll,
|
||||||
|
recreateVirtualScroll,
|
||||||
|
} from '../../../static/js/utils/infiniteScroll.js';
|
||||||
|
import { state } from '../../../static/js/state/index.js';
|
||||||
|
|
||||||
|
function setupPageDom() {
|
||||||
|
const pageContent = document.createElement('div');
|
||||||
|
pageContent.className = 'page-content';
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'container';
|
||||||
|
pageContent.appendChild(container);
|
||||||
|
document.body.appendChild(pageContent);
|
||||||
|
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
vi.spyOn(document, 'getElementById').mockImplementation((id) =>
|
||||||
|
id === 'recipeGrid' || id === 'modelGrid' ? grid : null);
|
||||||
|
|
||||||
|
return { grid };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('infiniteScroll scroller class branching', () => {
|
||||||
|
let originalSettings;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalSettings = state.global.settings;
|
||||||
|
state.global.settings = { ...originalSettings };
|
||||||
|
if (state.pages.recipes) {
|
||||||
|
state.pages.recipes.duplicatesMode = false;
|
||||||
|
}
|
||||||
|
state.virtualScroller = null;
|
||||||
|
state.keyboardNavHandler = null;
|
||||||
|
setupPageDom();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
state.global.settings = originalSettings;
|
||||||
|
state.virtualScroller = null;
|
||||||
|
state.keyboardNavHandler = null;
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('constructs MasonryScroller for the recipes page when recipes_layout is masonry', async () => {
|
||||||
|
state.global.settings.recipes_layout = 'masonry';
|
||||||
|
|
||||||
|
await initializeInfiniteScroll('recipes');
|
||||||
|
|
||||||
|
expect(MasonryScrollerMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(VirtualScrollerMock).not.toHaveBeenCalled();
|
||||||
|
expect(state.virtualScroller).toBeInstanceOf(MasonryScrollerMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('constructs VirtualScroller for the recipes page when recipes_layout is grid', async () => {
|
||||||
|
state.global.settings.recipes_layout = 'grid';
|
||||||
|
|
||||||
|
await initializeInfiniteScroll('recipes');
|
||||||
|
|
||||||
|
expect(VirtualScrollerMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(MasonryScrollerMock).not.toHaveBeenCalled();
|
||||||
|
expect(state.virtualScroller).toBeInstanceOf(VirtualScrollerMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the grid branch when recipes_layout is missing', async () => {
|
||||||
|
delete state.global.settings.recipes_layout;
|
||||||
|
|
||||||
|
await expect(initializeInfiniteScroll('recipes')).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(VirtualScrollerMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(MasonryScrollerMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always constructs VirtualScroller for the loras page regardless of recipes_layout', async () => {
|
||||||
|
state.global.settings.recipes_layout = 'masonry';
|
||||||
|
|
||||||
|
await initializeInfiniteScroll('loras');
|
||||||
|
|
||||||
|
expect(VirtualScrollerMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(MasonryScrollerMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recreateVirtualScroll', () => {
|
||||||
|
let originalSettings;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalSettings = state.global.settings;
|
||||||
|
state.global.settings = { ...originalSettings };
|
||||||
|
if (state.pages.recipes) {
|
||||||
|
state.pages.recipes.duplicatesMode = false;
|
||||||
|
}
|
||||||
|
state.virtualScroller = null;
|
||||||
|
state.keyboardNavHandler = null;
|
||||||
|
setupPageDom();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
state.global.settings = originalSettings;
|
||||||
|
state.virtualScroller = null;
|
||||||
|
state.keyboardNavHandler = null;
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up keyboard navigation, disposes the old scroller, and rebuilds with the new layout', async () => {
|
||||||
|
state.global.settings.recipes_layout = 'grid';
|
||||||
|
await initializeInfiniteScroll('recipes');
|
||||||
|
const oldScroller = state.virtualScroller;
|
||||||
|
const oldKeyboardHandler = state.keyboardNavHandler;
|
||||||
|
expect(oldKeyboardHandler).toBeTypeOf('function');
|
||||||
|
|
||||||
|
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
|
||||||
|
state.global.settings.recipes_layout = 'masonry';
|
||||||
|
|
||||||
|
await recreateVirtualScroll('recipes');
|
||||||
|
|
||||||
|
// cleanupKeyboardNavigation removed the previous document keydown listener
|
||||||
|
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', oldKeyboardHandler);
|
||||||
|
// Old scroller disposed and replaced by a new masonry instance
|
||||||
|
expect(oldScroller.dispose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(MasonryScrollerMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(state.virtualScroller).toBeInstanceOf(MasonryScrollerMock);
|
||||||
|
expect(state.virtualScroller).not.toBe(oldScroller);
|
||||||
|
// A fresh keyboard navigation listener was registered for the new instance
|
||||||
|
expect(state.keyboardNavHandler).toBeTypeOf('function');
|
||||||
|
expect(state.keyboardNavHandler).not.toBe(oldKeyboardHandler);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works when there is no existing virtual scroller', async () => {
|
||||||
|
state.global.settings.recipes_layout = 'masonry';
|
||||||
|
|
||||||
|
await expect(recreateVirtualScroll('recipes')).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(MasonryScrollerMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(state.virtualScroller).toBeInstanceOf(MasonryScrollerMock);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user