mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
feat(settings): search settings from the header and the command palette (#1714)
* feat(settings): search settings from the header and the command palette The header search on the Settings page was shown but disabled. It now searches a shared index of all 56 settings rows by translated title, description and English synonyms, replaces the section page with ranked results, and opens a result by scrolling to, focusing and briefly highlighting its row. Enter opens the best match, and the section navigation shows per-section match counts. The command palette gains a "Settings" group that lists the best six matches for a non-empty query, so any setting is one Ctrl/Cmd+K away. Rows hidden by the current form state fall back to the control that reveals them; rows the runtime cannot render are never returned. The index ships through a new @iptvnator/workspace/shell/util/settings-search sub-entrypoint so it stays out of the eager bundle, and a registry spec keeps it in step with the section templates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): let search reveals win over pending input and gate embedded MPV rows - A reveal (result click, command palette, Enter) now cancels a search keystroke still waiting for its debounce, so its q navigation can no longer supersede the reveal and leave the results open. - Embedded MPV extra options and auto-reconnect require a lazily probed embedded MPV capability; frame copy also needs frameCopyAvailable, so search never offers a row the settings page cannot render. - Keyboard users keep a focus-visible ring on the revealed row after the highlight fades. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(workspace): wait for palette probes without Promise.allSettled The web tsconfig lib predates Promise.allSettled; use Promise.all over rejection-safe probes instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
77 files changed
+3304
-316
No files matched your search
@@ -0,0 +1,181 @@
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
|
||||
import {
|
||||
SETTINGS_SEARCH_ENTRIES,
|
||||
SettingsSearchEntry,
|
||||
SettingsSearchService,
|
||||
} from '@iptvnator/workspace/shell/util/settings-search';
|
||||
import { SettingsComponent } from './settings.component';
|
||||
import { SETTINGS_REVEALED_CLASS } from './settings-search.facade';
|
||||
import {
|
||||
configureSettingsComponentTestBed,
|
||||
createElectronStub,
|
||||
createEpgBridgeStub,
|
||||
MockRouter,
|
||||
setSettingsSearchQuery,
|
||||
setSettingsSection,
|
||||
stubSettingsSideEffects,
|
||||
} from './test-stubs/settings-test-harness.stub';
|
||||
|
||||
function entry(id: string): SettingsSearchEntry {
|
||||
const found = SETTINGS_SEARCH_ENTRIES.find((item) => item.id === id);
|
||||
if (!found) {
|
||||
throw new Error(`unknown settings entry ${id}`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings search on the page: the shell writes the header term to `q`,
|
||||
* the page swaps the section for ranked results, and a chosen result (or a
|
||||
* command palette pick) scrolls to and highlights the row. The labels are
|
||||
* translation keys here (no translations are loaded), which the index
|
||||
* searches like any other text.
|
||||
*/
|
||||
describe('SettingsComponent search', () => {
|
||||
let fixture: ComponentFixture<SettingsComponent>;
|
||||
let component: SettingsComponent;
|
||||
let settingsSearch: SettingsSearchService;
|
||||
let settingsCtx: SettingsContextService;
|
||||
let scrollIntoView: jest.Mock;
|
||||
const originalElectron = window.electron;
|
||||
const originalScrollIntoView = Element.prototype.scrollIntoView;
|
||||
|
||||
const element = (): HTMLElement => fixture.nativeElement as HTMLElement;
|
||||
const query = (selector: string) => element().querySelector(selector);
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
configureSettingsComponentTestBed(createEpgBridgeStub());
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
window.electron = createElectronStub();
|
||||
scrollIntoView = jest.fn();
|
||||
Element.prototype.scrollIntoView = scrollIntoView;
|
||||
|
||||
fixture = TestBed.createComponent(SettingsComponent);
|
||||
component = fixture.componentInstance;
|
||||
settingsSearch = TestBed.inject(SettingsSearchService);
|
||||
settingsCtx = TestBed.inject(SettingsContextService);
|
||||
stubSettingsSideEffects(component);
|
||||
const loadSettings = jest.spyOn(component.form, 'loadSettings');
|
||||
fixture.detectChanges();
|
||||
await loadSettings.mock.results[0].value;
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
window.electron = originalElectron;
|
||||
Element.prototype.scrollIntoView = originalScrollIntoView;
|
||||
});
|
||||
|
||||
it('replaces the section page with ranked results while searching', () => {
|
||||
expect(query('app-settings-general-section')).not.toBeNull();
|
||||
|
||||
setSettingsSearchQuery('theme');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('app-settings-general-section')).toBeNull();
|
||||
expect(
|
||||
query('[data-test-id="settings-search-result-theme"]')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
query('[data-test-id="settings-search-summary"]')?.getAttribute(
|
||||
'aria-live'
|
||||
)
|
||||
).toBe('polite');
|
||||
|
||||
setSettingsSearchQuery('');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('[data-test-id="settings-search-results"]')).toBeNull();
|
||||
expect(query('app-settings-general-section')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows an empty state when nothing matches', () => {
|
||||
setSettingsSearchQuery('zzzz-no-such-setting');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('[data-test-id="settings-search-empty"]')).not.toBeNull();
|
||||
expect(query('.settings-search-result')).toBeNull();
|
||||
});
|
||||
|
||||
it('publishes match counts per section for the navigation', () => {
|
||||
setSettingsSearchQuery('dark');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(settingsCtx.matchCounts()).toEqual(
|
||||
expect.objectContaining({ general: 1 })
|
||||
);
|
||||
|
||||
setSettingsSearchQuery('');
|
||||
fixture.detectChanges();
|
||||
expect(settingsCtx.matchCounts()).toBeNull();
|
||||
});
|
||||
|
||||
it('reveals the chosen result through the settings search service', () => {
|
||||
const reveal = jest.spyOn(settingsSearch, 'reveal');
|
||||
setSettingsSearchQuery('theme');
|
||||
fixture.detectChanges();
|
||||
|
||||
(
|
||||
query(
|
||||
'[data-test-id="settings-search-result-theme"]'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
|
||||
expect(reveal).toHaveBeenCalledWith(entry('theme'));
|
||||
expect(
|
||||
(TestBed.inject(Router) as unknown as MockRouter).navigate
|
||||
).toHaveBeenCalledWith(['/workspace/settings', 'general']);
|
||||
});
|
||||
|
||||
it('scrolls to, focuses and briefly highlights a revealed row', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
settingsSearch.reveal(entry('show-captions'));
|
||||
fixture.detectChanges();
|
||||
|
||||
const row = query('[data-setting-id="show-captions"]') as HTMLElement;
|
||||
expect(scrollIntoView).toHaveBeenCalled();
|
||||
expect(scrollIntoView.mock.contexts[0]).toBe(row);
|
||||
expect(row.classList).toContain(SETTINGS_REVEALED_CLASS);
|
||||
expect(document.activeElement).toBe(row);
|
||||
expect(settingsSearch.pendingReveal()).toBeNull();
|
||||
|
||||
jest.runOnlyPendingTimers();
|
||||
expect(row.classList).not.toContain(SETTINGS_REVEALED_CLASS);
|
||||
});
|
||||
|
||||
it('waits for the section page before revealing a row on it', () => {
|
||||
settingsSearch.reveal(entry('epg-offset'));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
expect(settingsSearch.pendingReveal()?.id).toBe('epg-offset');
|
||||
|
||||
setSettingsSection('epg');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('[data-setting-id="epg-offset"]')?.classList).toContain(
|
||||
SETTINGS_REVEALED_CLASS
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the controlling row when the searched row is hidden', () => {
|
||||
// The API key row only renders while TMDB is enabled.
|
||||
setSettingsSection('tmdb');
|
||||
fixture.detectChanges();
|
||||
expect(query('[data-setting-id="tmdb-api-key"]')).toBeNull();
|
||||
|
||||
settingsSearch.reveal(entry('tmdb-api-key'));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('[data-setting-id="tmdb-enable"]')?.classList).toContain(
|
||||
SETTINGS_REVEALED_CLASS
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user