diff --git a/locales/de.json b/locales/de.json index 5b64b267..d29fafeb 100644 --- a/locales/de.json +++ b/locales/de.json @@ -970,6 +970,11 @@ "random": "Zufällig", "randomAction": "Zufällig mischen" }, + "modelType": { + "title": "[TODO: Translate] Filter by model type", + "all": "[TODO: Translate] All Types", + "multiple": "[TODO: Translate] {count} types" + }, "refresh": { "title": "Modelliste aktualisieren", "full": "Cache neu aufbauen", diff --git a/locales/en.json b/locales/en.json index 9b57e510..f63ee14b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -970,6 +970,11 @@ "random": "Random", "randomAction": "Randomize (shuffle)" }, + "modelType": { + "title": "Filter by model type", + "all": "All Types", + "multiple": "{count} types" + }, "refresh": { "title": "Refresh model list", "full": "Rebuild Cache", diff --git a/locales/es.json b/locales/es.json index 8121ce00..066168cd 100644 --- a/locales/es.json +++ b/locales/es.json @@ -970,6 +970,11 @@ "random": "Aleatorio", "randomAction": "Aleatorizar (barajar)" }, + "modelType": { + "title": "[TODO: Translate] Filter by model type", + "all": "[TODO: Translate] All Types", + "multiple": "[TODO: Translate] {count} types" + }, "refresh": { "title": "Actualizar lista de modelos", "full": "Reconstruir caché", diff --git a/locales/fr.json b/locales/fr.json index 784c4fd3..977414b5 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -970,6 +970,11 @@ "random": "Aléatoire", "randomAction": "Aléatoire (mélanger)" }, + "modelType": { + "title": "[TODO: Translate] Filter by model type", + "all": "[TODO: Translate] All Types", + "multiple": "[TODO: Translate] {count} types" + }, "refresh": { "title": "Actualiser la liste des modèles", "full": "Reconstruire le cache", diff --git a/locales/he.json b/locales/he.json index caec9515..bbb7dcca 100644 --- a/locales/he.json +++ b/locales/he.json @@ -970,6 +970,11 @@ "random": "אקראי", "randomAction": "ערבוב אקראי" }, + "modelType": { + "title": "[TODO: Translate] Filter by model type", + "all": "[TODO: Translate] All Types", + "multiple": "[TODO: Translate] {count} types" + }, "refresh": { "title": "רענן רשימת מודלים", "full": "בניית מטמון מחדש", diff --git a/locales/ja.json b/locales/ja.json index 7151d4ee..e493536c 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -970,6 +970,11 @@ "random": "ランダム", "randomAction": "シャッフル(ランダム)" }, + "modelType": { + "title": "[TODO: Translate] Filter by model type", + "all": "[TODO: Translate] All Types", + "multiple": "[TODO: Translate] {count} types" + }, "refresh": { "title": "モデルリストを更新", "full": "キャッシュを再構築", diff --git a/locales/ko.json b/locales/ko.json index 467aa3b6..3ebe0920 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -970,6 +970,11 @@ "random": "랜덤", "randomAction": "셔플 (무작위)" }, + "modelType": { + "title": "[TODO: Translate] Filter by model type", + "all": "[TODO: Translate] All Types", + "multiple": "[TODO: Translate] {count} types" + }, "refresh": { "title": "모델 목록 새로고침", "full": "캐시 재구성", diff --git a/locales/ru.json b/locales/ru.json index 0b6acab5..9fdadb62 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -970,6 +970,11 @@ "random": "Случайно", "randomAction": "Перемешать" }, + "modelType": { + "title": "[TODO: Translate] Filter by model type", + "all": "[TODO: Translate] All Types", + "multiple": "[TODO: Translate] {count} types" + }, "refresh": { "title": "Обновить список моделей", "full": "Перестроить кэш", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 648e8c08..568f983a 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -970,6 +970,11 @@ "random": "随机", "randomAction": "随机排序(洗牌)" }, + "modelType": { + "title": "[TODO: Translate] Filter by model type", + "all": "[TODO: Translate] All Types", + "multiple": "[TODO: Translate] {count} types" + }, "refresh": { "title": "刷新模型列表", "full": "重建缓存", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index f7bff8b6..1ef83bc2 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -970,6 +970,11 @@ "random": "隨機", "randomAction": "隨機排序(洗牌)" }, + "modelType": { + "title": "[TODO: Translate] Filter by model type", + "all": "[TODO: Translate] All Types", + "multiple": "[TODO: Translate] {count} types" + }, "refresh": { "title": "重新整理模型列表", "full": "重建快取", diff --git a/static/css/layout.css b/static/css/layout.css index 130ad517..7de45c3f 100644 --- a/static/css/layout.css +++ b/static/css/layout.css @@ -351,14 +351,17 @@ body.sticky-controls .sticky-topbar { sizes to the currently selected text only; the dropdown menu sizes to its content independently. The native + {% if page_id in ['loras', 'checkpoints', 'other'] %} + + {% endif %} + `; +} + +function createFilterManagerStub(modelTypes = []) { + return { + filters: { modelTypes: [...modelTypes] }, + updateModelTypeSelections: vi.fn(), + updateActiveFiltersCount: vi.fn(), + applyFilters: vi.fn(async () => {}), + }; +} + +function mockModelTypesResponse(modelTypes) { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, model_types: modelTypes }), + }); +} + +const OTHER_TYPES = [ + { type: 'vae', count: 12 }, + { type: 'upscaler', count: 5 }, + { type: 'text_encoder', count: 30 }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + renderDropdownDom(); + window.filterManager = createFilterManagerStub(); + mockModelTypesResponse(OTHER_TYPES); +}); + +afterEach(() => { + delete window.filterManager; + delete window.modelTypeDropdown; + delete global.fetch; +}); + +describe('ModelTypeDropdown', () => { + it('template carries dropdown-group so the menu can become visible', () => { + // Regression: the menu only displays via `.dropdown-group.active .dropdown-menu`, + // so the group element in controls.html must carry the `dropdown-group` class. + const template = readTemplate('components/controls.html'); + const groupLine = template.split('\n').find((line) => line.includes('id="modelTypeDropdown"')); + expect(groupLine).toBeTruthy(); + expect(groupLine).toContain('model-type-dropdown-group'); + expect(groupLine).toContain('dropdown-group'); + }); + + it('stays hidden when the page has a single model type', async () => { + mockModelTypesResponse([{ type: 'embedding', count: 40 }]); + + await initModelTypeDropdown('embeddings'); + + const group = document.getElementById('modelTypeDropdown'); + expect(group.classList.contains('hidden')).toBe(true); + expect(window.modelTypeDropdown).toBeUndefined(); + }); + + it('stays hidden when no element with the dropdown id exists', async () => { + document.body.innerHTML = ''; + + await initModelTypeDropdown('other'); + + expect(window.modelTypeDropdown).toBeUndefined(); + }); + + it('renders All Types plus one item per type with counts', async () => { + await initModelTypeDropdown('other'); + + const group = document.getElementById('modelTypeDropdown'); + expect(group.classList.contains('hidden')).toBe(false); + + const options = group.querySelectorAll('.model-type-option'); + expect(options).toHaveLength(4); + expect(options[0].dataset.value).toBe(''); + expect(options[0].textContent).toBe('All Types'); + expect(options[1].textContent).toBe('VAE (12)'); + expect(options[2].textContent).toBe('Upscaler (5)'); + expect(options[3].textContent).toBe('Text Encoder (30)'); + }); + + it('ignores types without a known display name', async () => { + mockModelTypesResponse([ + { type: 'vae', count: 12 }, + { type: 'mystery_type', count: 3 }, + { type: 'upscaler', count: 5 }, + ]); + + await initModelTypeDropdown('other'); + + const options = document.querySelectorAll('.model-type-option'); + expect(options).toHaveLength(3); + expect([...options].map((o) => o.dataset.value)).toEqual(['', 'vae', 'upscaler']); + }); + + it('initializes the trigger label from the restored filters', async () => { + window.filterManager = createFilterManagerStub(['text_encoder']); + + await initModelTypeDropdown('other'); + + const label = document.querySelector('.model-type-trigger__label'); + expect(label.textContent).toBe('Text Encoder'); + }); + + it('shows a count label when multiple types are selected in the filter panel', async () => { + window.filterManager = createFilterManagerStub(['vae', 'upscaler']); + + await initModelTypeDropdown('other'); + + const label = document.querySelector('.model-type-trigger__label'); + expect(label.textContent).toBe('2 types'); + }); + + it('selecting a type sets a single-entry modelTypes filter and applies it', async () => { + await initModelTypeDropdown('other'); + + const item = document.querySelector('.model-type-option[data-value="vae"]'); + item.click(); + await vi.waitFor(() => { + expect(window.filterManager.applyFilters).toHaveBeenCalledWith(false); + }); + + expect(window.filterManager.filters.modelTypes).toEqual(['vae']); + expect(window.filterManager.updateModelTypeSelections).toHaveBeenCalled(); + expect(window.filterManager.updateActiveFiltersCount).toHaveBeenCalled(); + expect(document.querySelector('.model-type-trigger__label').textContent).toBe('VAE'); + }); + + it('selecting All Types clears the modelTypes filter', async () => { + window.filterManager = createFilterManagerStub(['vae']); + await initModelTypeDropdown('other'); + + document.querySelector('.model-type-option[data-value=""]').click(); + await vi.waitFor(() => { + expect(window.filterManager.applyFilters).toHaveBeenCalled(); + }); + + expect(window.filterManager.filters.modelTypes).toEqual([]); + expect(document.querySelector('.model-type-trigger__label').textContent).toBe('All Types'); + }); + + it('syncFromFilters reflects external filter changes (filter panel multi-select)', async () => { + await initModelTypeDropdown('other'); + + window.filterManager.filters.modelTypes = ['vae', 'upscaler']; + window.modelTypeDropdown.syncFromFilters(); + + expect(document.querySelector('.model-type-trigger__label').textContent).toBe('2 types'); + + window.filterManager.filters.modelTypes = ['upscaler']; + window.modelTypeDropdown.syncFromFilters(); + + expect(document.querySelector('.model-type-trigger__label').textContent).toBe('Upscaler'); + const selected = document.querySelector('.model-type-option.is-selected'); + expect(selected.dataset.value).toBe('upscaler'); + }); + + it('toggles the menu on trigger click and closes on outside click', async () => { + await initModelTypeDropdown('other'); + + const group = document.getElementById('modelTypeDropdown'); + const trigger = group.querySelector('.model-type-trigger'); + + trigger.click(); + expect(group.classList.contains('active')).toBe(true); + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + + document.body.click(); + expect(group.classList.contains('active')).toBe(false); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + it('selects the focused option with Enter and closes the menu', async () => { + await initModelTypeDropdown('other'); + + const group = document.getElementById('modelTypeDropdown'); + const trigger = group.querySelector('.model-type-trigger'); + const menu = group.querySelector('.model-type-dropdown-menu'); + + trigger.click(); + const item = menu.querySelector('.model-type-option[data-value="upscaler"]'); + item.focus(); + menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await vi.waitFor(() => { + expect(window.filterManager.applyFilters).toHaveBeenCalled(); + }); + + expect(window.filterManager.filters.modelTypes).toEqual(['upscaler']); + expect(group.classList.contains('active')).toBe(false); + }); + + it('Escape closes the menu without changing the filter', async () => { + await initModelTypeDropdown('other'); + + const group = document.getElementById('modelTypeDropdown'); + const menu = group.querySelector('.model-type-dropdown-menu'); + + group.querySelector('.model-type-trigger').click(); + menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(group.classList.contains('active')).toBe(false); + expect(window.filterManager.filters.modelTypes).toEqual([]); + expect(window.filterManager.applyFilters).not.toHaveBeenCalled(); + }); +});