feat(frontend): opt-in Other Models toggles, hidden nav and announcement

- The Other nav entry is hidden while the feature is off
  (nav-item--hidden, toggled client-side after enabling) and now uses the
  fa-shapes icon.
- Shared utils/otherModels.js helpers (enable through the settings API,
  open the settings Library section) are reused by the disabled page, the
  announcement banner and the download modal.
- BannerService registers a one-time dismissible "other-models-announcement"
  banner while the feature is off; SettingsManager drops the banner and
  updates the nav when the master switch flips.
- A disabled download routing answer now surfaces a showActionToast with an
  "Enable Other Models" action.
- Settings UI: master toggle + five sub_type checkboxes whose default-root
  selects are disabled when unchecked; i18n keys added to en.json and synced
  (other locales keep TODO placeholders).
This commit is contained in:
Will Miao
2026-09-13 07:59:28 +08:00
parent 28fbb86dce
commit 69a62d739c
25 changed files with 1006 additions and 19 deletions
@@ -27,6 +27,14 @@ vi.mock('../../../static/js/state/index.js', () => ({
}
}));
// Mock the shared Other Models helpers (exercised by their own tests)
vi.mock('../../../static/js/utils/otherModels.js', () => ({
enableOtherModels: vi.fn().mockResolvedValue(),
openOtherModelsSettings: vi.fn(),
}));
import { enableOtherModels, openOtherModelsSettings } from '../../../static/js/utils/otherModels.js';
describe('BannerService', () => {
beforeEach(() => {
// Clear all mocks
@@ -186,6 +194,91 @@ describe('BannerService', () => {
});
});
describe('Other Models announcement', () => {
const OTHER_MODELS_BANNER_ID = 'other-models-announcement';
const prepareBanner = (dismissed = []) => {
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === 'dismissed_banners') {
return dismissed;
}
return defaultValue;
});
bannerService.container = document.getElementById('banner-container');
bannerService.initialized = true;
bannerService.prepareOtherModelsBanner();
};
const bannerElement = () =>
document.querySelector(`[data-banner-id="${OTHER_MODELS_BANNER_ID}"]`);
beforeEach(() => {
state.global.settings.enable_other_models = false;
});
it('announces the feature while it is switched off', () => {
prepareBanner();
const element = bannerElement();
expect(element).not.toBeNull();
expect(element.querySelector('.banner-title').textContent)
.toContain('Other Models Management is available');
});
it('stays silent once the feature is enabled', () => {
state.global.settings.enable_other_models = true;
prepareBanner();
expect(bannerElement()).toBeNull();
});
it('stays silent when it was dismissed before', () => {
prepareBanner([OTHER_MODELS_BANNER_ID]);
expect(bannerElement()).toBeNull();
});
it('enables the feature from the primary action', () => {
prepareBanner();
const button = bannerElement().querySelector(
'.banner-action[data-action="enable-other-models"]'
);
expect(button).not.toBeNull();
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(enableOtherModels).toHaveBeenCalledTimes(1);
});
it('opens the settings section from the secondary action', () => {
prepareBanner();
const button = bannerElement().querySelector(
'.banner-action[data-action="open-other-models-settings"]'
);
expect(button).not.toBeNull();
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(openOtherModelsSettings).toHaveBeenCalledTimes(1);
});
it('can drop the announcement without dismissing it', () => {
prepareBanner();
expect(bannerService.banners.has(OTHER_MODELS_BANNER_ID)).toBe(true);
bannerService.removeOtherModelsAnnouncement();
expect(bannerService.banners.has(OTHER_MODELS_BANNER_ID)).toBe(false);
expect(storageHelpers.setStorageItem).not.toHaveBeenCalledWith(
'dismissed_banners',
expect.arrayContaining([OTHER_MODELS_BANNER_ID])
);
});
});
describe('Banner Dismissal', () => {
it('should add banner to dismissed_banners array when dismissed', () => {
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
@@ -11,6 +11,7 @@ const {
FOLDER_TREE_MANAGER_MODULE,
I18N_HELPERS_MODULE,
SUMMARY_MODULE,
OTHER_MODELS_MODULE,
} = vi.hoisted(() => ({
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
@@ -22,6 +23,7 @@ const {
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
OTHER_MODELS_MODULE: new URL('../../../static/js/utils/otherModels.js', import.meta.url).pathname,
}));
vi.mock(MODAL_MANAGER_MODULE, () => ({
@@ -29,6 +31,7 @@ vi.mock(MODAL_MANAGER_MODULE, () => ({
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
showActionToast: vi.fn(),
setupAutoNewlineOnPaste: vi.fn(),
}));
vi.mock(STATE_MODULE, () => ({
@@ -54,9 +57,15 @@ vi.mock(I18N_HELPERS_MODULE, () => ({
vi.mock(SUMMARY_MODULE, () => ({
showDownloadBatchSummary: vi.fn(),
}));
vi.mock(OTHER_MODELS_MODULE, () => ({
enableOtherModels: vi.fn(),
openOtherModelsSettings: vi.fn(),
}));
const { DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE);
const { state } = await import(STATE_MODULE);
const { showActionToast } = await import(UI_HELPERS_MODULE);
const { openOtherModelsSettings } = await import(OTHER_MODELS_MODULE);
describe('DownloadManager._resolveIsDiffusionModel', () => {
let manager;
@@ -217,6 +226,35 @@ describe('DownloadManager._resolveOtherSubType', () => {
expect(await manager._resolveOtherSubType()).toBeNull();
});
it('offers the settings shortcut when the feature is disabled for this type', async () => {
showActionToast.mockClear();
openOtherModelsSettings.mockClear();
manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'VAE' }] };
mockRoutingResponse({
success: true,
root_kind: 'other',
sub_type: null,
disabled: true,
reason: 'other_sub_type_disabled',
});
expect(await manager._resolveOtherSubType()).toBeNull();
expect(showActionToast).toHaveBeenCalledWith(
'other.disabled.downloadBlocked',
{},
'warning',
expect.objectContaining({
actionText: expect.any(String),
onAction: expect.any(Function),
}),
);
showActionToast.mock.calls.at(-1)[3].onAction();
expect(openOtherModelsSettings).toHaveBeenCalledTimes(1);
});
it('returns null when the endpoint fails', async () => {
manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'VAE' }] };
fetchMock.mockRejectedValue(new Error('network down'));
@@ -62,6 +62,7 @@ vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
}));
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
import { bannerService } from '../../../static/js/managers/BannerService.js';
import { showToast } from '../../../static/js/utils/uiHelpers.js';
import { state } from '../../../static/js/state/index.js';
@@ -605,6 +606,93 @@ describe('SettingsManager other-model root selects', () => {
});
});
describe('updateOtherModelsControls', () => {
const appendToggles = (...subTypes) => {
const container = document.createElement('div');
container.id = 'otherSubTypeToggles';
document.body.appendChild(container);
subTypes.forEach((subType) => {
const input = document.createElement('input');
input.type = 'checkbox';
input.value = subType;
input.dataset.otherSubtypeToggle = subType;
container.appendChild(input);
});
return container;
};
it('disables every toggle and select while the feature is off', () => {
const manager = createManager();
const container = appendToggles('vae', 'upscaler');
const selects = appendOtherRootSelects('vae', 'upscaler');
state.global.settings = {
enable_other_models: false,
enabled_other_sub_types: ['vae'],
};
manager.updateOtherModelsControls();
const vaeToggle = document.querySelector('[data-other-subtype-toggle="vae"]');
const upscalerToggle = document.querySelector('[data-other-subtype-toggle="upscaler"]');
expect(vaeToggle.checked).toBe(true);
expect(upscalerToggle.checked).toBe(false);
expect(vaeToggle.disabled).toBe(true);
expect(upscalerToggle.disabled).toBe(true);
expect(selects.vae.disabled).toBe(true);
expect(selects.upscaler.disabled).toBe(true);
expect(container.classList.contains('is-disabled')).toBe(true);
});
it('leaves enabled sub_types interactive and disables the rest', () => {
const manager = createManager();
const container = appendToggles('vae', 'upscaler');
const selects = appendOtherRootSelects('vae', 'upscaler');
state.global.settings = {
enable_other_models: true,
enabled_other_sub_types: ['vae'],
};
manager.updateOtherModelsControls();
const vaeToggle = document.querySelector('[data-other-subtype-toggle="vae"]');
const upscalerToggle = document.querySelector('[data-other-subtype-toggle="upscaler"]');
expect(vaeToggle.disabled).toBe(false);
expect(upscalerToggle.disabled).toBe(false);
expect(selects.vae.disabled).toBe(false);
expect(selects.upscaler.disabled).toBe(true);
expect(container.classList.contains('is-disabled')).toBe(false);
});
it('persists the checked sub_types as the whole allow-list', async () => {
const manager = createManager();
appendToggles('vae', 'upscaler', 'controlnet');
document.querySelector('[data-other-subtype-toggle="vae"]').checked = true;
document.querySelector('[data-other-subtype-toggle="controlnet"]').checked = true;
state.global.settings = {
enable_other_models: true,
enabled_other_sub_types: [],
};
const saveSpy = vi.spyOn(manager, 'saveSetting').mockResolvedValue();
const loadSpy = vi.spyOn(manager, 'loadOtherRoots').mockResolvedValue();
await manager.saveEnabledOtherSubTypes();
expect(saveSpy).toHaveBeenCalledWith('enabled_other_sub_types', [
'vae',
'controlnet',
]);
expect(loadSpy).toHaveBeenCalled();
expect(showToast).toHaveBeenCalledWith(
'toast.settings.settingsUpdated',
expect.objectContaining({ setting: 'other model types' }),
'success',
);
});
});
describe('saveOtherRootSetting', () => {
it('read-modify-writes the default_other_roots dict and posts it whole', async () => {
const manager = createManager();
@@ -679,6 +767,36 @@ describe('SettingsManager other-model root selects', () => {
});
});
describe('SettingsManager Other Models nav and banner sync', () => {
it('shows or hides the Other Models nav entry', () => {
const manager = createManager();
const navItem = document.createElement('a');
navItem.id = 'otherNavItem';
document.body.appendChild(navItem);
manager.updateOtherModelsNavVisibility(false);
expect(navItem.classList.contains('nav-item--hidden')).toBe(true);
manager.updateOtherModelsNavVisibility(true);
expect(navItem.classList.contains('nav-item--hidden')).toBe(false);
});
it('drops the announcement banner only when enabling', () => {
const manager = createManager();
const spy = vi
.spyOn(bannerService, 'removeOtherModelsAnnouncement')
.mockImplementation(() => {});
manager.removeOtherModelsAnnouncement(false);
expect(spy).not.toHaveBeenCalled();
manager.removeOtherModelsAnnouncement(true);
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
});
describe('SettingsManager recipes layout switch', () => {
it('dispatches lm:recipes-layout-changed without recalculating the old scroller', async () => {
const manager = createManager();
@@ -0,0 +1,88 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
const initializeAppMock = vi.fn();
const showToastMock = vi.fn();
vi.mock('../../../static/js/core.js', () => ({
appCore: {
initialize: initializeAppMock,
},
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
}));
describe('Other Models disabled page', () => {
const originalLocation = window.location;
let enableOtherModels;
let initializeOtherDisabledPage;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
initializeAppMock.mockResolvedValue(undefined);
document.body.innerHTML = '<button id="enableOtherModelsBtn"></button>';
Object.defineProperty(window, 'location', {
value: { ...originalLocation, reload: vi.fn() },
configurable: true,
writable: true,
});
({ enableOtherModels, initializeOtherDisabledPage } = await import(
'../../../static/js/other_disabled.js'
));
await initializeOtherDisabledPage();
});
afterEach(() => {
Object.defineProperty(window, 'location', {
value: originalLocation,
configurable: true,
writable: true,
});
delete global.fetch;
});
it('boots the shared app core so the header stays usable', () => {
expect(initializeAppMock).toHaveBeenCalledTimes(1);
});
it('enables Other Models through the settings API and reloads', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
await enableOtherModels();
expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/settings',
expect.objectContaining({ method: 'POST' }),
);
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({
enable_other_models: true,
});
expect(window.location.reload).toHaveBeenCalledTimes(1);
expect(showToastMock).not.toHaveBeenCalled();
});
it('re-enables the button and toasts when enabling fails', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ success: false, error: 'boom' }),
});
await enableOtherModels();
expect(window.location.reload).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledWith(
'other.disabled.enableFailed',
expect.objectContaining({ message: 'boom' }),
'error',
);
expect(document.getElementById('enableOtherModelsBtn').disabled).toBe(false);
});
});