feat(controls): add model sub-type quick-switch dropdown to the controls bar
Single-select Model Type dropdown next to the sort control on the loras, checkpoints and other pages (hidden when only one sub-type exists). Selection maps to filters.modelTypes, persists via the existing filter storage, and stays in sync with the filter panel's multi-select chips.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "בניית מטמון מחדש",
|
||||
|
||||
@@ -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": "キャッシュを再構築",
|
||||
|
||||
@@ -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": "캐시 재구성",
|
||||
|
||||
@@ -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": "Перестроить кэш",
|
||||
|
||||
@@ -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": "重建缓存",
|
||||
|
||||
@@ -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": "重建快取",
|
||||
|
||||
+35
-15
@@ -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 <select> is kept in the DOM
|
||||
(visually hidden) so existing JS that reads/writes `.value` / `.disabled`
|
||||
and dynamically adds/removes <option>s keeps working. */
|
||||
and dynamically adds/removes <option>s keeps working. The model-type
|
||||
dropdown (.model-type-*) shares these styles — same trigger/menu look. */
|
||||
|
||||
.sort-dropdown-group {
|
||||
.sort-dropdown-group,
|
||||
.model-type-dropdown-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sort-trigger {
|
||||
.sort-trigger,
|
||||
.model-type-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -375,8 +378,14 @@ body.sticky-controls .sticky-topbar {
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.model-type-trigger {
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.sort-trigger:hover,
|
||||
.sort-trigger:focus-visible {
|
||||
.sort-trigger:focus-visible,
|
||||
.model-type-trigger:hover,
|
||||
.model-type-trigger:focus-visible {
|
||||
border-color: var(--lora-accent);
|
||||
background: var(--bg-color);
|
||||
transform: translateY(-1px);
|
||||
@@ -384,28 +393,33 @@ body.sticky-controls .sticky-topbar {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sort-trigger:active {
|
||||
.sort-trigger:active,
|
||||
.model-type-trigger:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.sort-trigger__label {
|
||||
.sort-trigger__label,
|
||||
.model-type-trigger__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sort-trigger__caret {
|
||||
.sort-trigger__caret,
|
||||
.model-type-trigger__caret {
|
||||
opacity: 0.8;
|
||||
transition: transform var(--transition-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sort-dropdown-group.active .sort-trigger__caret {
|
||||
.sort-dropdown-group.active .sort-trigger__caret,
|
||||
.model-type-dropdown-group.active .model-type-trigger__caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.sort-dropdown-group.active .sort-trigger {
|
||||
.sort-dropdown-group.active .sort-trigger,
|
||||
.model-type-dropdown-group.active .model-type-trigger {
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in oklch, var(--lora-accent) 15%, transparent);
|
||||
}
|
||||
@@ -423,7 +437,8 @@ body.sticky-controls .sticky-topbar {
|
||||
|
||||
/* Dropdown menu — sizes to its content, independent of trigger width.
|
||||
Inherits base .dropdown-menu styling; capped for very long i18n text. */
|
||||
.sort-dropdown-menu {
|
||||
.sort-dropdown-menu,
|
||||
.model-type-dropdown-menu {
|
||||
min-width: max-content;
|
||||
max-width: 320px;
|
||||
width: max-content;
|
||||
@@ -446,7 +461,8 @@ body.sticky-controls .sticky-topbar {
|
||||
}
|
||||
|
||||
/* Option items */
|
||||
.sort-dropdown-group .sort-option {
|
||||
.sort-dropdown-group .sort-option,
|
||||
.model-type-dropdown-group .model-type-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -457,7 +473,8 @@ body.sticky-controls .sticky-topbar {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sort-dropdown-group .sort-option::before {
|
||||
.sort-dropdown-group .sort-option::before,
|
||||
.model-type-dropdown-group .model-type-option::before {
|
||||
content: '';
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
@@ -465,16 +482,19 @@ body.sticky-controls .sticky-topbar {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sort-dropdown-group .sort-option:hover {
|
||||
.sort-dropdown-group .sort-option:hover,
|
||||
.model-type-dropdown-group .model-type-option:hover {
|
||||
background-color: color-mix(in oklch, var(--lora-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.sort-dropdown-group .sort-option.is-selected {
|
||||
.sort-dropdown-group .sort-option.is-selected,
|
||||
.model-type-dropdown-group .model-type-option.is-selected {
|
||||
color: var(--lora-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sort-dropdown-group .sort-option.is-selected::before {
|
||||
.sort-dropdown-group .sort-option.is-selected::before,
|
||||
.model-type-dropdown-group .model-type-option.is-selected::before {
|
||||
content: '\2713';
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
// ModelTypeDropdown.js — Single-select model sub-type switcher for the
|
||||
// controls bar (next to the sort dropdown).
|
||||
//
|
||||
// The filter panel's "Model Types" section is a multi-select filter buried
|
||||
// behind the Filter button; switching the current view between sub-types
|
||||
// (e.g. VAE vs Text Encoder on the Other page) through it takes several
|
||||
// clicks and leaves no visible state. This dropdown is the quick,
|
||||
// always-visible, single-select counterpart: picking a type sets
|
||||
// filterManager.filters.modelTypes to exactly that type, "All Types" clears
|
||||
// it. Multi-selections made in the filter panel are still honored — the
|
||||
// trigger then shows "{count} types" until the user picks a single entry.
|
||||
//
|
||||
// Rendered on pages whose model-types endpoint can return more than one
|
||||
// sub-type (loras / checkpoints / other); it hides itself otherwise.
|
||||
|
||||
import { MODEL_TYPE_DISPLAY_NAMES } from '../../utils/constants.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
|
||||
const GROUP_ID = 'modelTypeDropdown';
|
||||
const ALL_TYPES_VALUE = '';
|
||||
const ACTIVE_GROUP_SELECTOR = '.dropdown-group.active, .sort-dropdown-group.active';
|
||||
|
||||
function getLabelText() {
|
||||
return {
|
||||
all: translate('loras.controls.modelType.all', {}, 'All Types'),
|
||||
multiple: (count) => translate('loras.controls.modelType.multiple', { count }, `${count} types`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the model type dropdown for the current page.
|
||||
* Idempotent: safe to call more than once.
|
||||
* @param {string} pageType - 'loras' | 'checkpoints' | 'other'
|
||||
*/
|
||||
export async function initModelTypeDropdown(pageType) {
|
||||
const group = document.getElementById(GROUP_ID);
|
||||
if (!group || group.dataset.modelTypeReady === '1') return;
|
||||
group.dataset.modelTypeReady = '1';
|
||||
|
||||
const trigger = group.querySelector('.model-type-trigger');
|
||||
const menu = group.querySelector('.model-type-dropdown-menu');
|
||||
const label = group.querySelector('.model-type-trigger__label');
|
||||
if (!trigger || !menu || !label) return;
|
||||
|
||||
let types = [];
|
||||
try {
|
||||
const response = await fetch(`/api/lm/${pageType}/model-types?limit=20`);
|
||||
if (!response.ok) throw new Error('Failed to fetch model types');
|
||||
const data = await response.json();
|
||||
if (!data.success || !Array.isArray(data.model_types)) throw new Error('Invalid response format');
|
||||
// Same normalization as FilterManager.createModelTypeTags: lowercase,
|
||||
// and only sub-types we have a display name for.
|
||||
types = data.model_types
|
||||
.map((entry) => {
|
||||
if (!entry || !entry.type) return null;
|
||||
const typeKey = entry.type.toString().trim().toLowerCase();
|
||||
if (!typeKey || !MODEL_TYPE_DISPLAY_NAMES[typeKey]) return null;
|
||||
return { type: typeKey, count: Number(entry.count) || 0 };
|
||||
})
|
||||
.filter(Boolean);
|
||||
} catch (error) {
|
||||
console.error('Error loading model types for dropdown:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing to switch between — keep the control hidden.
|
||||
if (types.length <= 1) return;
|
||||
|
||||
const filterManager = () => window.filterManager;
|
||||
|
||||
const getSelectedTypes = () => filterManager()?.filters?.modelTypes ?? [];
|
||||
|
||||
const buildMenu = () => {
|
||||
const labels = getLabelText();
|
||||
menu.innerHTML = '';
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
const allItem = document.createElement('div');
|
||||
allItem.className = 'model-type-option';
|
||||
allItem.setAttribute('role', 'option');
|
||||
allItem.tabIndex = -1;
|
||||
allItem.dataset.value = ALL_TYPES_VALUE;
|
||||
allItem.textContent = labels.all;
|
||||
fragment.appendChild(allItem);
|
||||
|
||||
types.forEach(({ type, count }) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'model-type-option';
|
||||
item.setAttribute('role', 'option');
|
||||
item.tabIndex = -1;
|
||||
item.dataset.value = type;
|
||||
item.textContent = `${MODEL_TYPE_DISPLAY_NAMES[type]} (${count})`;
|
||||
fragment.appendChild(item);
|
||||
});
|
||||
|
||||
menu.appendChild(fragment);
|
||||
};
|
||||
|
||||
const getOptions = () => menu.querySelectorAll('.model-type-option');
|
||||
|
||||
const syncFromFilters = () => {
|
||||
const labels = getLabelText();
|
||||
const selected = getSelectedTypes();
|
||||
let labelText;
|
||||
if (selected.length === 0) {
|
||||
labelText = labels.all;
|
||||
} else if (selected.length === 1 && MODEL_TYPE_DISPLAY_NAMES[selected[0]]) {
|
||||
labelText = MODEL_TYPE_DISPLAY_NAMES[selected[0]];
|
||||
} else {
|
||||
labelText = labels.multiple(selected.length);
|
||||
}
|
||||
label.textContent = labelText;
|
||||
|
||||
const singleValue = selected.length === 1 ? selected[0] : null;
|
||||
getOptions().forEach((el) => {
|
||||
const isSelected = selected.length === 0
|
||||
? el.dataset.value === ALL_TYPES_VALUE
|
||||
: el.dataset.value === singleValue;
|
||||
el.classList.toggle('is-selected', isSelected);
|
||||
el.setAttribute('aria-selected', isSelected ? 'true' : 'false');
|
||||
});
|
||||
};
|
||||
|
||||
const choose = async (value) => {
|
||||
const fm = filterManager();
|
||||
if (!fm) return;
|
||||
fm.filters.modelTypes = value === ALL_TYPES_VALUE ? [] : [value];
|
||||
fm.updateModelTypeSelections();
|
||||
fm.updateActiveFiltersCount();
|
||||
syncFromFilters();
|
||||
await fm.applyFilters(false);
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
document.querySelectorAll(ACTIVE_GROUP_SELECTOR).forEach((g) => {
|
||||
if (g !== group) g.classList.remove('active');
|
||||
});
|
||||
group.classList.add('active');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
requestAnimationFrame(() => {
|
||||
const selected = menu.querySelector('.model-type-option.is-selected');
|
||||
(selected || getOptions()[0])?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
group.classList.remove('active');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
if (group.classList.contains('active')) close();
|
||||
else open();
|
||||
};
|
||||
|
||||
// ---- keyboard navigation (mirrors SortDropdown.js) ----
|
||||
|
||||
let typeBuffer = '';
|
||||
let typeTimer = null;
|
||||
|
||||
const focusOptionByText = (prefix) => {
|
||||
const options = getOptions();
|
||||
const lower = prefix.toLowerCase();
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
if (options[i].textContent.toLowerCase().startsWith(lower)) {
|
||||
options[i].focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const moveFocus = (options, direction) => {
|
||||
const focused = menu.querySelector('.model-type-option:focus');
|
||||
let idx = focused ? Array.from(options).indexOf(focused) : -1;
|
||||
idx = Math.max(0, Math.min(options.length - 1, idx + direction));
|
||||
options[idx]?.focus();
|
||||
};
|
||||
|
||||
const handleTypeToSelect = (event) => {
|
||||
if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return false;
|
||||
event.preventDefault();
|
||||
clearTimeout(typeTimer);
|
||||
typeBuffer += event.key;
|
||||
focusOptionByText(typeBuffer);
|
||||
typeTimer = setTimeout(() => { typeBuffer = ''; }, 800);
|
||||
return true;
|
||||
};
|
||||
|
||||
menu.addEventListener('click', (event) => {
|
||||
const item = event.target.closest('.model-type-option');
|
||||
if (!item) return;
|
||||
event.stopPropagation();
|
||||
choose(item.dataset.value);
|
||||
close();
|
||||
trigger.focus();
|
||||
});
|
||||
|
||||
trigger.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
toggle();
|
||||
});
|
||||
|
||||
trigger.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
close();
|
||||
} else if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
} else if (!group.classList.contains('active')) {
|
||||
if (handleTypeToSelect(event)) {
|
||||
open();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
menu.addEventListener('keydown', (event) => {
|
||||
const options = getOptions();
|
||||
if (options.length === 0) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
close();
|
||||
trigger.focus();
|
||||
return;
|
||||
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
moveFocus(options, 1);
|
||||
return;
|
||||
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
moveFocus(options, -1);
|
||||
return;
|
||||
|
||||
case 'Home':
|
||||
event.preventDefault();
|
||||
options[0]?.focus();
|
||||
return;
|
||||
|
||||
case 'End':
|
||||
event.preventDefault();
|
||||
options[options.length - 1]?.focus();
|
||||
return;
|
||||
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault();
|
||||
const focused = menu.querySelector('.model-type-option:focus');
|
||||
if (focused) {
|
||||
choose(focused.dataset.value);
|
||||
close();
|
||||
trigger.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
handleTypeToSelect(event);
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!group.contains(event.target)) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
buildMenu();
|
||||
syncFromFilters();
|
||||
group.classList.remove('hidden');
|
||||
|
||||
// Let FilterManager push external changes (panel chips, presets, clear)
|
||||
// back into the trigger label and menu selection.
|
||||
window.modelTypeDropdown = { syncFromFilters };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||
import { sidebarManager } from '../SidebarManager.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js';
|
||||
import { initModelTypeDropdown } from './ModelTypeDropdown.js';
|
||||
import { renderScanScopeMenu, resolveScanScopeTarget } from './ScanScopeMenu.js';
|
||||
|
||||
/**
|
||||
@@ -139,6 +140,10 @@ export class PageControls {
|
||||
await this.resetAndReload();
|
||||
});
|
||||
}
|
||||
|
||||
// Model type quick-switch dropdown (loras / checkpoints / other pages;
|
||||
// hides itself when the page has a single sub-type)
|
||||
initModelTypeDropdown(this.pageType);
|
||||
|
||||
// Refresh button handler
|
||||
const refreshBtn = document.querySelector('[data-action="refresh"]');
|
||||
@@ -992,6 +997,7 @@ export class PageControls {
|
||||
'[data-action="find-duplicates"]',
|
||||
'#favoriteFilterBtn',
|
||||
'.update-filter-group',
|
||||
'#modelTypeDropdown',
|
||||
];
|
||||
const customFilterIndicator = document.getElementById('customFilterIndicator');
|
||||
|
||||
|
||||
@@ -631,6 +631,8 @@ export class FilterManager {
|
||||
}
|
||||
|
||||
this.updateActiveFiltersCount();
|
||||
// Keep the controls-bar quick switcher in sync (no-op when absent)
|
||||
window.modelTypeDropdown?.syncFromFilters?.();
|
||||
await this.applyFilters(false);
|
||||
});
|
||||
|
||||
@@ -781,6 +783,8 @@ export class FilterManager {
|
||||
tag.classList.remove('active');
|
||||
}
|
||||
});
|
||||
// Keep the controls-bar quick switcher in sync (no-op when absent)
|
||||
window.modelTypeDropdown?.syncFromFilters?.();
|
||||
}
|
||||
|
||||
updateActiveFiltersCount() {
|
||||
|
||||
@@ -64,6 +64,15 @@
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
{% if page_id in ['loras', 'checkpoints', 'other'] %}
|
||||
<div class="control-group model-type-dropdown-group dropdown-group hidden" id="modelTypeDropdown" title="{{ t('loras.controls.modelType.title') }}">
|
||||
<button type="button" class="model-type-trigger" aria-haspopup="listbox" aria-expanded="false">
|
||||
<span class="model-type-trigger__label"></span>
|
||||
<i class="fas fa-caret-down model-type-trigger__caret" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu model-type-dropdown-menu" role="listbox"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group">
|
||||
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span><span>{{ t('common.actions.refresh') }}</span> <kbd class="shortcut-key">R</kbd></span></button>
|
||||
<button class="dropdown-toggle" aria-label="Show refresh options">
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
import { initModelTypeDropdown } from '../../../static/js/components/controls/ModelTypeDropdown.js';
|
||||
import { readTemplate } from '../utils/domFixtures.js';
|
||||
|
||||
function renderDropdownDom() {
|
||||
document.body.innerHTML = `
|
||||
<div class="control-group model-type-dropdown-group dropdown-group hidden" id="modelTypeDropdown">
|
||||
<button type="button" class="model-type-trigger" aria-haspopup="listbox" aria-expanded="false">
|
||||
<span class="model-type-trigger__label"></span>
|
||||
<i class="fas fa-caret-down model-type-trigger__caret" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu model-type-dropdown-menu" role="listbox"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user