From 65ba7506342c6d568baaa666e6ca7a82b4a60f5f Mon Sep 17 00:00:00 2001 From: Will Miao Date: Thu, 27 Aug 2026 22:37:10 +0800 Subject: [PATCH] feat(recipes): improve recipe LoRA status indicators and missing-badge affordance (#1076) - Recipe card: compact status pill with state icon + available/total fraction (e.g. "2/3"), pinned to the footer bottom-right like model card actions; status is encoded by icon + color, never color alone - Recipe modal: "N missing" is now a real `; } else if (deletedLorasCount > 0 && missingLorasCount === 0) { - statusHTML = `
${deletedLorasCount} deleted
`; + statusHTML = `
${translate('recipes.status.deletedCount', { count: deletedLorasCount }, `${deletedLorasCount} deleted`)}
`; } } lorasCountElement.innerHTML = ` ${totalCount} ${totalCount === 1 ? 'LoRA' : 'LoRAs'} ${statusHTML}`; + const missingStatus = lorasCountElement.querySelector('.recipe-status.missing'); + if (missingStatus && missingLorasCount > 0) { + missingStatus.addEventListener('click', () => this.showDownloadMissingLorasModal()); + } + setTimeout(() => { const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn'); if (viewRecipeLorasBtn) { viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage()); } - - const missingStatus = document.querySelector('.recipe-status.missing'); - if (missingStatus && missingLorasCount > 0) { - missingStatus.classList.add('clickable'); - missingStatus.addEventListener('click', () => this.showDownloadMissingLorasModal()); - } }, 100); } diff --git a/tests/frontend/components/recipeCard.loraBadges.test.js b/tests/frontend/components/recipeCard.loraBadges.test.js new file mode 100644 index 00000000..45ed92fb --- /dev/null +++ b/tests/frontend/components/recipeCard.loraBadges.test.js @@ -0,0 +1,155 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { + RECIPE_CARD_MODULE, + UI_HELPERS_MODULE, + RECIPE_API_MODULE, + MODEL_CARD_MODULE, + MODAL_MANAGER_MODULE, + STATE_MODULE, + BULK_MANAGER_MODULE, + CONSTANTS_MODULE, + I18N_MODULE, + UNDO_HELPERS_MODULE, +} = vi.hoisted(() => ({ + RECIPE_CARD_MODULE: new URL('../../../static/js/components/RecipeCard.js', import.meta.url).pathname, + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, + RECIPE_API_MODULE: new URL('../../../static/js/api/recipeApi.js', import.meta.url).pathname, + MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname, + MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname, + STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname, + BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname, + CONSTANTS_MODULE: new URL('../../../static/js/utils/constants.js', import.meta.url).pathname, + I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, + UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname, +})); + +const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showToast: vi.fn(), + showActionToast: vi.fn(), + copyToClipboard: vi.fn(), + sendLoraToWorkflow: vi.fn(), +})); + +vi.mock(RECIPE_API_MODULE, () => ({ + updateRecipeMetadata: vi.fn(), +})); + +vi.mock(MODEL_CARD_MODULE, () => ({ + configureModelCardVideo: vi.fn(), +})); + +vi.mock(MODAL_MANAGER_MODULE, () => ({ + modalManager: { + showModal: vi.fn(), + closeModal: vi.fn(), + }, +})); + +vi.mock(STATE_MODULE, () => ({ + state: { + global: { settings: {} }, + settings: {}, + virtualScroller: { removeItemByFilePath: vi.fn() }, + }, + getCurrentPageState: vi.fn(() => ({})), +})); + +vi.mock(BULK_MANAGER_MODULE, () => ({ + bulkManager: {}, +})); + +vi.mock(CONSTANTS_MODULE, () => ({ + NSFW_LEVELS: {}, + getBaseModelAbbreviation: vi.fn((label) => label), + getMatureBlurThreshold: vi.fn(() => 10), +})); + +vi.mock(I18N_MODULE, () => ({ + translate: translateMock, +})); + +vi.mock(UNDO_HELPERS_MODULE, () => ({ + handleUndoDelete: vi.fn(), +})); + +function buildRecipe(loras) { + return { + id: 'recipe-1', + file_path: '/recipes/r1.json', + title: 'Badge Recipe', + file_url: '/preview.png', + preview_nsfw_level: 0, + created_date: '2024-01-01', + base_model: 'SDXL', + loras, + }; +} + +async function createCard(loras) { + const { RecipeCard } = await import(RECIPE_CARD_MODULE); + return new RecipeCard(buildRecipe(loras), vi.fn()); +} + +describe('RecipeCard LoRA status pill', () => { + beforeEach(() => { + translateMock.mockClear(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('shows available/total with a warning icon when LoRAs are missing', async () => { + const card = await createCard([ + { name: 'a', inLibrary: true }, + { name: 'b', inLibrary: false }, + { name: 'c', inLibrary: true }, + ]); + + const pill = card.element.querySelector('.lora-count.missing'); + expect(pill).not.toBeNull(); + expect(pill.querySelector('.fa-exclamation-triangle')).not.toBeNull(); + expect(pill.textContent).toContain('2/3'); + expect(pill.title).toBe('1 of 3 LoRAs missing'); + }); + + it('shows a green check with n/n when every LoRA is available', async () => { + const card = await createCard([ + { name: 'a', inLibrary: true }, + { name: 'b', inLibrary: true }, + ]); + + const pill = card.element.querySelector('.lora-count.ready'); + expect(pill).not.toBeNull(); + expect(pill.querySelector('.fa-check')).not.toBeNull(); + expect(pill.textContent).toContain('2/2'); + expect(pill.title).toBe('All LoRAs available - Ready to use'); + expect(card.element.querySelector('.lora-count.missing')).toBeNull(); + }); + + it('does not count deleted LoRAs as missing', async () => { + const card = await createCard([ + { name: 'a', inLibrary: true }, + { name: 'b', inLibrary: false, isDeleted: true }, + ]); + + expect(card.element.querySelector('.lora-count.missing')).toBeNull(); + expect(card.element.querySelector('.lora-count.ready')).not.toBeNull(); + }); + + it('shows a neutral layers icon with a bare 0 when the recipe has no LoRAs', async () => { + const card = await createCard([]); + + const pill = card.element.querySelector('.lora-count'); + expect(pill).not.toBeNull(); + expect(pill.classList.contains('missing')).toBe(false); + expect(pill.classList.contains('ready')).toBe(false); + expect(pill.querySelector('.fa-layer-group')).not.toBeNull(); + expect(pill.textContent).toContain('0'); + expect(pill.textContent).not.toContain('/'); + expect(pill.title).toBe('No LoRAs in this recipe'); + }); +}); diff --git a/tests/frontend/components/recipeModal.missingStatus.test.js b/tests/frontend/components/recipeModal.missingStatus.test.js new file mode 100644 index 00000000..62bc7fcd --- /dev/null +++ b/tests/frontend/components/recipeModal.missingStatus.test.js @@ -0,0 +1,197 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const showToastMock = vi.fn(); +const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)); + +const loadingManagerStub = { + showSimpleLoading: vi.fn(), + hide: vi.fn(), + show: vi.fn(), + restoreProgressBar: vi.fn(), +}; + +const virtualScrollerStub = { + updateSingleItem: vi.fn(), + getNavigationState: vi.fn(() => ({ + index: 0, + hasPrev: false, + hasNext: false, + loadedItems: 1, + totalItems: 1, + })), + getAdjacentItemByFilePath: vi.fn(async () => null), +}; + +const stateStub = { + global: { settings: {}, loadingManager: loadingManagerStub }, + loadingManager: loadingManagerStub, + virtualScroller: virtualScrollerStub, +}; + +const modalManagerMock = { + showModal: vi.fn(), + closeModal: vi.fn(), +}; + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: showToastMock, + copyToClipboard: vi.fn(), + sendLoraToWorkflow: vi.fn(), + sendModelPathToWorkflow: vi.fn(), + openCivitaiByMetadata: vi.fn(), + stripLoraTags: vi.fn((text) => text), + sendPromptToWorkflow: vi.fn(), + sendGenParamsToWorkflow: vi.fn(), +})); + +vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ + translate: translateMock, +})); + +vi.mock('../../../static/js/state/index.js', () => ({ + state: stateStub, +})); + +vi.mock('../../../static/js/utils/storageHelpers.js', () => ({ + setSessionItem: vi.fn(), + removeSessionItem: vi.fn(), + getStorageItem: vi.fn(() => null), + setStorageItem: vi.fn(), +})); + +vi.mock('../../../static/js/api/recipeApi.js', () => ({ + fetchRecipeDetails: vi.fn(), + updateRecipeMetadata: vi.fn(() => Promise.resolve({ success: true })), + sendRecipeWorkflow: vi.fn(), +})); + +vi.mock('../../../static/js/api/apiConfig.js', () => ({ + MODEL_TYPES: { + LORA: 'loras', + CHECKPOINT: 'checkpoints', + EMBEDDING: 'embeddings', + }, +})); + +function recipeModalFixture() { + return ` + + `; +} + +const recipeWithMissing = { + id: 'recipe-missing', + file_path: '/recipes/missing.json', + title: 'Missing Recipe', + tags: [], + loras: [ + { name: 'present-lora', inLibrary: true }, + { name: 'gone-lora', inLibrary: false }, + ], +}; + +const recipeReady = { + id: 'recipe-ready', + file_path: '/recipes/ready.json', + title: 'Ready Recipe', + tags: [], + loras: [ + { name: 'present-lora', inLibrary: true }, + ], +}; + +const createdModals = []; + +async function createRecipeModal() { + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + const recipeModal = new RecipeModal(); + createdModals.push(recipeModal); + return recipeModal; +} + +describe('RecipeModal missing LoRA status', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = recipeModalFixture(); + global.modalManager = modalManagerMock; + global.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({}), + })); + }); + + afterEach(() => { + createdModals.forEach(recipeModal => recipeModal.cleanupNavigationShortcuts()); + createdModals.length = 0; + document.body.innerHTML = ''; + delete global.modalManager; + delete global.fetch; + }); + + it('renders the missing status as a button with a persistent affordance', async () => { + const recipeModal = await createRecipeModal(); + recipeModal.showRecipeDetails(recipeWithMissing); + + const status = document.querySelector('#recipeLorasCount .recipe-status.missing'); + expect(status).not.toBeNull(); + expect(status.tagName).toBe('BUTTON'); + expect(status.type).toBe('button'); + expect(status.classList.contains('clickable')).toBe(true); + expect(status.getAttribute('aria-label')).toBe('Download 1 missing LoRAs'); + expect(status.title).toBe('Click to download missing LoRAs'); + // Leading download icon hints the action; the warning glyph was removed + // because the red tint + text already encode the state + expect(status.querySelector('i').classList.contains('fa-download')).toBe(true); + expect(status.querySelector('.fa-exclamation-triangle')).toBeNull(); + expect(status.textContent).toContain('1 missing'); + + // The hover-only tooltip was replaced by the always-visible button styling + expect(status.querySelector('.missing-tooltip')).toBeNull(); + }); + + it('opens the download-missing flow when the status button is clicked', async () => { + const recipeModal = await createRecipeModal(); + const downloadSpy = vi + .spyOn(recipeModal, 'showDownloadMissingLorasModal') + .mockImplementation(() => {}); + + recipeModal.showRecipeDetails(recipeWithMissing); + + const status = document.querySelector('#recipeLorasCount .recipe-status.missing'); + status.click(); + + expect(downloadSpy).toHaveBeenCalledTimes(1); + }); + + it('renders a non-interactive ready badge when every LoRA is available', async () => { + const recipeModal = await createRecipeModal(); + recipeModal.showRecipeDetails(recipeReady); + + const ready = document.querySelector('#recipeLorasCount .recipe-status.ready'); + expect(ready).not.toBeNull(); + expect(ready.tagName).toBe('DIV'); + expect(ready.textContent).toContain('Ready to use'); + expect(document.querySelector('#recipeLorasCount .recipe-status.missing')).toBeNull(); + }); +});