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:
authored and GitHub committed 2026-09-27 07:42:53 +02:00
1 parent b87291e388
commit ea51cae3db
77 files changed
+3304 -316

No files matched your search

@@ -381,3 +381,149 @@ describe('WorkspaceCommandPaletteComponent - recent section', () => {
).toBe(false);
});
});
describe('WorkspaceCommandPaletteComponent - settings group', () => {
function settingsCommand(
id: string,
label: string
): WorkspaceResolvedCommandItem {
return {
id: `settings:${id}`,
label,
description: 'Playback',
group: 'settings',
icon: 'play_circle',
keywords: [],
priority: 100,
visible: true,
enabled: true,
run: () => undefined,
};
}
const commands: WorkspaceResolvedCommandItem[] = [
{
id: 'open-settings',
label: 'Open settings',
description: '',
group: 'global',
icon: 'settings',
keywords: ['settings'],
priority: 50,
visible: true,
enabled: true,
run: () => undefined,
},
settingsCommand('video-player', 'Video player'),
settingsCommand('theme', 'Theme'),
];
function setup(options: {
query: string;
recentIds?: readonly string[];
searchSettings?: jest.Mock;
}) {
const searchSettings =
options.searchSettings ??
jest.fn((query: string) =>
query.includes('player') ? ['settings:video-player'] : []
);
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [WorkspaceCommandPaletteComponent],
providers: [
{ provide: MatDialogRef, useValue: { close: jest.fn() } },
{
provide: MAT_DIALOG_DATA,
useValue: {
query: options.query,
commands,
recentIds: options.recentIds ?? [],
searchSettings,
},
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
],
});
const fixture = TestBed.createComponent(
WorkspaceCommandPaletteComponent
);
fixture.detectChanges();
return {
component: fixture.componentInstance,
fixture,
searchSettings,
};
}
it('keeps settings out of the list while the query is empty', () => {
const { component, searchSettings } = setup({ query: '' });
expect(component.flatCommands().map((command) => command.id)).toEqual([
'open-settings',
]);
expect(searchSettings).not.toHaveBeenCalled();
});
it('lists settings matches after the command groups, in search order', () => {
const searchSettings = jest.fn(() => [
'settings:theme',
'settings:video-player',
]);
const { component, fixture } = setup({
query: 'settings',
searchSettings,
});
const groups = component.commandGroups();
expect(groups.map((group) => group.group)).toEqual([
'global',
'settings',
]);
expect(groups[1].items.map((item) => item.id)).toEqual([
'settings:theme',
'settings:video-player',
]);
expect(searchSettings).toHaveBeenLastCalledWith('settings');
expect(fixture.nativeElement.textContent).toContain(
'WORKSPACE.COMMAND_PALETTE.GROUP_SETTINGS'
);
});
it('ranks settings only through the search callback, never by substring', () => {
const { component } = setup({ query: 'theme' });
// "Theme" contains the query, but the search callback decides.
expect(component.flatCommands()).toEqual([]);
component.query.set('player');
expect(component.flatCommands().map((command) => command.id)).toEqual([
'settings:video-player',
]);
});
it('shows a recently opened setting in the recent section', () => {
const { component } = setup({
query: '',
recentIds: ['settings:theme'],
});
const [recent] = component.commandGroups();
expect(recent.group).toBe('recent');
expect(recent.items.map((item) => item.id)).toEqual(['settings:theme']);
});
});
@@ -23,10 +23,15 @@ import {
} from '@iptvnator/portal/shared/util';
import { foldSearchText } from '@iptvnator/shared/interfaces';
interface WorkspaceCommandPaletteData {
export interface WorkspaceCommandPaletteData {
commands: WorkspaceResolvedCommandItem[];
query?: string;
recentIds?: readonly string[];
/**
* Ranked ids of the `settings` group commands matching a query. Settings
* commands are listed only through this, never for an empty query.
*/
searchSettings?: (query: string) => readonly string[];
}
type PaletteSectionGroup = WorkspaceCommandGroup | 'recent';
@@ -65,7 +70,9 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
readonly filteredCommands = computed(() => {
const term = foldSearchText(this.query().trim());
const commands = this.visibleCommands();
const commands = this.visibleCommands().filter(
(command) => command.group !== 'settings'
);
if (!term) {
return commands;
@@ -107,6 +114,28 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
}
);
readonly settingsSection = computed<WorkspaceCommandGroupSection | null>(
() => {
const query = this.query().trim();
const searchSettings = this.data?.searchSettings;
if (!query || !searchSettings) {
return null;
}
const byId = new Map(
this.visibleCommands().map((command) => [command.id, command])
);
const items = searchSettings(query)
.map((id) => byId.get(id))
.filter(
(command): command is WorkspaceResolvedCommandItem =>
command?.group === 'settings'
);
return items.length === 0 ? null : { group: 'settings', items };
}
);
readonly commandGroups = computed<WorkspaceCommandGroupSection[]>(() => {
const commands = this.filteredCommands();
const recent = this.recentSection();
@@ -144,6 +173,12 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
);
sections.push(...groups);
const settings = this.settingsSection();
if (settings) {
sections.push(settings);
}
return sections;
});
@@ -259,6 +294,9 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
if (group === 'playlist') {
return 'WORKSPACE.COMMAND_PALETTE.GROUP_PLAYLIST';
}
if (group === 'settings') {
return 'WORKSPACE.COMMAND_PALETTE.GROUP_SETTINGS';
}
return 'WORKSPACE.COMMAND_PALETTE.GROUP_GLOBAL';
}
@@ -45,6 +45,11 @@
border-top: 1px solid var(--app-separator);
}
// Sections without settings-search matches stay reachable but recede.
.settings-section-item.has-no-matches:not(.active) {
opacity: 0.55;
}
.settings-section-item:hover:not(.active):not(.selected),
.settings-back-button:hover:not(.active):not(.selected) {
background: var(--app-selection-surface);
@@ -0,0 +1,75 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import { WorkspaceSettingsContextPanelComponent } from './workspace-settings-context-panel.component';
describe('WorkspaceSettingsContextPanelComponent', () => {
function setup() {
TestBed.configureTestingModule({
imports: [WorkspaceSettingsContextPanelComponent],
providers: [
provideRouter([]),
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
],
});
const ctx = TestBed.inject(SettingsContextService);
ctx.setSections([
{ id: 'general', label: 'SETTINGS.NAV_GENERAL', icon: 'tune' },
{
id: 'playback',
label: 'SETTINGS.NAV_PLAYBACK',
icon: 'play_circle',
},
]);
const fixture = TestBed.createComponent(
WorkspaceSettingsContextPanelComponent
);
fixture.detectChanges();
const link = (id: string) =>
(fixture.nativeElement as HTMLElement).querySelector(
`[data-test-id="settings-section-${id}"]`
) as HTMLElement;
return { ctx, fixture, link };
}
it('shows no counts while settings search is idle', () => {
const { link } = setup();
expect(link('general').querySelector('.nav-item-meta')).toBeNull();
expect(link('general').classList).not.toContain('has-no-matches');
});
it('shows per-section match counts and mutes sections without matches', () => {
const { ctx, fixture, link } = setup();
ctx.setMatchCounts({ general: 2 });
fixture.detectChanges();
expect(
link('general').querySelector('.nav-item-meta')?.textContent
).toBe('2');
expect(
link('playback').querySelector('.nav-item-meta')?.textContent
).toBe('0');
expect(link('general').classList).not.toContain('has-no-matches');
expect(link('playback').classList).toContain('has-no-matches');
ctx.reset();
fixture.detectChanges();
expect(ctx.matchCounts()).toBeNull();
});
});
@@ -28,10 +28,27 @@ import {
[routerLink]="['/workspace/settings', section.id]"
[replaceUrl]="true"
[attr.data-test-id]="'settings-section-' + section.id"
[class.has-no-matches]="
ctx.matchCounts() !== null &&
!ctx.matchCounts()?.[section.id]
"
(click)="onSectionClicked()"
>
<mat-icon>{{ section.icon }}</mat-icon>
<span>{{ section.label | translate }}</span>
<span class="nav-item-label">{{
section.label | translate
}}</span>
<!-- While a settings search is active, each section
shows how many of its settings match. -->
@if (ctx.matchCounts(); as counts) {
<span
class="nav-item-meta"
[attr.data-test-id]="
'settings-section-matches-' + section.id
"
>{{ counts[section.id] ?? 0 }}</span
>
}
</a>
}
</div>
@@ -42,7 +42,9 @@
/>
}
@if (!hasNoPlaylists()) {
<!-- Settings search works without any playlist, so a fresh user can
find settings before importing a first source. -->
@if (!hasNoPlaylists() || isSettingsRoute()) {
<label class="search-field">
<mat-icon>search</mat-icon>
@if (searchScopeLabel()) {
@@ -229,6 +229,21 @@ describe('WorkspaceShellHeaderComponent', () => {
).toBeNull();
});
it('hides search without playlists except on the settings route', () => {
const searchInput = () =>
fixture.nativeElement.querySelector('input[type="search"]');
fixture.componentRef.setInput('hasNoPlaylists', true);
fixture.detectChanges();
expect(searchInput()).toBeNull();
// Settings search needs no playlist: a fresh user can still search.
fixture.componentRef.setInput('isSettingsRoute', true);
fixture.detectChanges();
expect(searchInput()).not.toBeNull();
expect(searchInput().disabled).toBe(false);
});
it('renders scope and status chips when search metadata is provided', () => {
fixture.componentRef.setInput('searchScopeLabel', 'Movies / All Items');
fixture.componentRef.setInput(
@@ -0,0 +1,96 @@
import {
SettingsSearchEntry,
SettingsSearchResult,
SettingsSearchService,
SETTINGS_SECTION_DEFINITIONS,
} from '@iptvnator/workspace/shell/util/settings-search';
import {
buildSettingsPaletteCommands,
SETTINGS_PALETTE_RESULT_LIMIT,
} from './workspace-settings-commands';
const THEME: SettingsSearchEntry = {
id: 'theme',
section: 'general',
labelKey: 'SETTINGS.THEME',
keywords: ['dark'],
};
const PLAYER: SettingsSearchEntry = {
id: 'video-player',
section: 'playback',
labelKey: 'SETTINGS.VIDEO_PLAYER_LABEL',
};
function createSettingsSearch() {
return {
visibleEntries: jest.fn(() => [THEME, PLAYER]),
search: jest.fn((): SettingsSearchResult[] => [
{
entry: PLAYER,
section: SETTINGS_SECTION_DEFINITIONS[1],
label: 'Video player',
description: '',
sectionLabel: 'Playback',
score: 10,
},
]),
reveal: jest.fn(),
};
}
describe('buildSettingsPaletteCommands', () => {
const translate = (key: string) => `t(${key})`;
it('resolves one settings-group command per visible row', () => {
const settingsSearch = createSettingsSearch();
const { commands } = buildSettingsPaletteCommands(
settingsSearch as unknown as SettingsSearchService,
translate
);
expect(commands).toEqual([
expect.objectContaining({
id: 'settings:theme',
group: 'settings',
icon: 'tune',
label: 't(SETTINGS.THEME)',
description: 't(SETTINGS.NAV_GENERAL)',
keywords: ['dark'],
visible: true,
enabled: true,
}),
expect.objectContaining({
id: 'settings:video-player',
icon: 'play_circle',
description: 't(SETTINGS.NAV_PLAYBACK)',
}),
]);
});
it('reveals the row when a command runs', () => {
const settingsSearch = createSettingsSearch();
const { commands } = buildSettingsPaletteCommands(
settingsSearch as unknown as SettingsSearchService,
translate
);
commands[1].run({ query: 'player' });
expect(settingsSearch.reveal).toHaveBeenCalledWith(PLAYER);
});
it('delegates ranking to the settings search with the palette cap', () => {
const settingsSearch = createSettingsSearch();
const { search } = buildSettingsPaletteCommands(
settingsSearch as unknown as SettingsSearchService,
translate
);
expect(search('player')).toEqual(['settings:video-player']);
expect(settingsSearch.search).toHaveBeenCalledWith(
'player',
SETTINGS_PALETTE_RESULT_LIMIT
);
});
});
@@ -0,0 +1,58 @@
import { WorkspaceResolvedCommandItem } from '@iptvnator/portal/shared/util';
import {
SETTINGS_SECTION_DEFINITIONS,
SettingsSearchService,
} from '@iptvnator/workspace/shell/util/settings-search';
import { TranslateFn } from './workspace-shell-search-labels';
/** Palette rows reserved for settings matches, so commands stay visible. */
export const SETTINGS_PALETTE_RESULT_LIMIT = 6;
const SETTINGS_COMMAND_PREFIX = 'settings:';
export interface SettingsPaletteCommands {
/** One command per visible settings row, for recents and selection. */
readonly commands: WorkspaceResolvedCommandItem[];
/** Ids of the best settings commands for `query`, best first. */
readonly search: (query: string) => readonly string[];
}
/**
* Settings rows as command palette entries. Ranking is delegated to
* `SettingsSearchService` so the palette and the settings page search agree
* on which settings match and in which order.
*/
export function buildSettingsPaletteCommands(
settingsSearch: SettingsSearchService,
translate: TranslateFn
): SettingsPaletteCommands {
const sections = new Map(
SETTINGS_SECTION_DEFINITIONS.map((section) => [section.id, section])
);
const commands = settingsSearch
.visibleEntries()
.map((entry): WorkspaceResolvedCommandItem => {
const section = sections.get(entry.section);
return {
id: `${SETTINGS_COMMAND_PREFIX}${entry.id}`,
group: 'settings',
icon: section?.icon ?? 'settings',
label: translate(entry.labelKey),
description: section ? translate(section.navLabelKey) : '',
keywords: entry.keywords ?? [],
priority: 100,
visible: true,
enabled: true,
run: () => settingsSearch.reveal(entry),
};
});
return {
commands,
search: (query) =>
settingsSearch
.search(query, SETTINGS_PALETTE_RESULT_LIMIT)
.map(({ entry }) => `${SETTINGS_COMMAND_PREFIX}${entry.id}`),
};
}
@@ -468,6 +468,8 @@ export function getCommandGroupOrder(
return 0;
case 'playlist':
return 1;
case 'settings':
return 3;
default:
return 2;
}
@@ -10,6 +10,8 @@ export const FILTER_SECTION_PLACEHOLDER =
'WORKSPACE.SHELL.FILTER_SECTION_PLACEHOLDER';
export const SEARCH_SOURCES_PLACEHOLDER =
'WORKSPACE.SHELL.SEARCH_SOURCES_PLACEHOLDER';
export const SEARCH_SETTINGS_PLACEHOLDER =
'WORKSPACE.SHELL.SEARCH_SETTINGS_PLACEHOLDER';
export const SEARCH_LOADED_ONLY_STATUS =
'WORKSPACE.SHELL.SEARCH_STATUS_LOADED_ONLY';
@@ -6,9 +6,14 @@ import {
WorkspaceResolvedCommandItem,
WorkspaceViewCommandService,
} from '@iptvnator/portal/shared/util';
import { WorkspaceCommandPaletteComponent } from '../../workspace-command-palette/workspace-command-palette.component';
import {
WorkspaceCommandPaletteComponent,
WorkspaceCommandPaletteData,
} from '../../workspace-command-palette/workspace-command-palette.component';
import { RecentCommandsService } from '../../recent-commands';
import { WorkspacePlayerCommandsContributor } from '../../workspace-player-commands';
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
import { buildSettingsPaletteCommands } from './helpers/workspace-settings-commands';
import {
buildCommandPaletteItems,
CommandBuilderContext,
@@ -23,6 +28,7 @@ export class WorkspaceShellCommandPaletteService {
private readonly playerCommands = inject(
WorkspacePlayerCommandsContributor
);
private readonly settingsSearch = inject(SettingsSearchService);
private commandPaletteRef: MatDialogRef<
WorkspaceCommandPaletteComponent,
@@ -46,11 +52,18 @@ export class WorkspaceShellCommandPaletteService {
return;
}
const embeddedMpvSupportLoad =
this.playerCommands.ensureEmbeddedMpvSupportLoaded();
if (embeddedMpvSupportLoad) {
const embeddedMpvSupportLoads = [
this.playerCommands.ensureEmbeddedMpvSupportLoaded(),
this.settingsSearch.ensureEmbeddedMpvSupportLoaded(),
].filter((load): load is Promise<void> => load !== undefined);
if (embeddedMpvSupportLoads.length > 0) {
this.commandPaletteOpening = true;
void embeddedMpvSupportLoad.finally(() => {
// A failed probe only hides its commands; it must not block opening.
void Promise.all(
embeddedMpvSupportLoads.map((load) =>
load.catch(() => undefined)
)
).finally(() => {
this.commandPaletteOpening = false;
this.openResolvedCommandPalette(ctx, initialQuery);
});
@@ -68,17 +81,20 @@ export class WorkspaceShellCommandPaletteService {
return;
}
const commands = this.buildPaletteCommands(ctx);
const settings = buildSettingsPaletteCommands(
this.settingsSearch,
ctx.translate
);
const commands = [
...this.buildPaletteCommands(ctx),
...settings.commands,
];
const recentIds = this.recentCommands
.entries()
.map((entry) => entry.id);
const dialogRef = this.dialog.open<
WorkspaceCommandPaletteComponent,
{
commands: WorkspaceResolvedCommandItem[];
query: string;
recentIds: readonly string[];
},
WorkspaceCommandPaletteData,
WorkspaceCommandSelection | undefined
>(WorkspaceCommandPaletteComponent, {
width: 'min(760px, 92vw)',
@@ -89,6 +105,7 @@ export class WorkspaceShellCommandPaletteService {
commands,
query: initialQuery,
recentIds,
searchSettings: settings.search,
},
});
this.commandPaletteRef = dialogRef;
@@ -9,6 +9,7 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util';
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
import { SEARCH_INPUT_DEBOUNCE_MS } from './helpers/workspace-shell-constants';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
import { WorkspaceShellSearchService } from './workspace-shell-search.service';
@@ -133,6 +134,21 @@ describe('WorkspaceShellSearchSyncService', () => {
jest.useRealTimers();
});
it('drops a still-debouncing keystroke when a settings result is opened', () => {
// Typing one more letter and clicking a visible settings result
// within the debounce window: applying that keystroke afterwards
// would start a `q` navigation that supersedes the reveal.
service.onSearchInput('them');
TestBed.inject(SettingsSearchService).reveal({
id: 'theme',
section: 'general',
labelKey: 'SETTINGS.THEME',
});
jest.advanceTimersByTime(SEARCH_INPUT_DEBOUNCE_MS);
expect(service.appliedSearchQuery()).toBe('');
});
it('keeps in-flight typing when the page writes an unrelated query param', () => {
// The downloads filter chips write `?filter=…` with replaceUrl. Under
// load that navigation can land after the first keystroke — it must
@@ -5,6 +5,7 @@ import { filter } from 'rxjs';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { parseWorkspaceShellRoute } from '@iptvnator/workspace/shell/util';
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
import { SEARCH_INPUT_DEBOUNCE_MS } from './helpers/workspace-shell-constants';
import {
getRoutePath,
@@ -30,6 +31,13 @@ export class WorkspaceShellSearchSyncService {
constructor() {
this.destroyRef.onDestroy(() => this.cancelPendingSearchApply());
// Opening a settings search result navigates away from the typed
// term; a keystroke still debouncing must not apply afterwards.
this.destroyRef.onDestroy(
inject(SettingsSearchService).onReveal(() =>
this.cancelPendingSearchApply()
)
);
this.router.events
.pipe(
@@ -7,9 +7,11 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { WorkspaceSearchCapability } from '@iptvnator/workspace/shell/util';
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
import {
SEARCH_LOADED_ONLY_STATUS,
SEARCH_PLAYLIST_PLACEHOLDER,
SEARCH_SETTINGS_PLACEHOLDER,
} from './helpers/workspace-shell-constants';
import {
resolveSearchPlaceholderKey,
@@ -28,6 +30,7 @@ export class WorkspaceShellSearchService {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly routeState = inject(WorkspaceShellRouteStateService);
private readonly searchSync = inject(WorkspaceShellSearchSyncService);
private readonly settingsSearch = inject(SettingsSearchService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
@@ -44,17 +47,19 @@ export class WorkspaceShellSearchService {
const section = route.section;
const appliedQuery = this.appliedSearchQuery().trim();
// Settings search filters an in-memory index of setting rows (see
// SettingsSearchService); the settings page reads the term from `q`.
if (route.kind === 'settings') {
return {
enabled: false,
behavior: 'disabled',
enabled: true,
behavior: 'local-filter',
context: null,
section: null,
searchMode: 'none',
placeholderKey: SEARCH_PLAYLIST_PLACEHOLDER,
searchMode: 'local-filter',
placeholderKey: SEARCH_SETTINGS_PLACEHOLDER,
scopeLabel: '',
statusLabel: '',
minLength: 0,
minLength: 1,
advancedRouteTarget: null,
};
}
@@ -181,6 +186,21 @@ export class WorkspaceShellSearchService {
const trimmedValue = value.trim();
this.searchQuery.set(trimmedValue);
// Enter on settings jumps to the best match, like picking the first
// result. The reveal navigation drops `q`, which clears the box. The
// term is deliberately not applied: applying would make the `q` sync
// start its own navigation, superseding the reveal navigation (the
// reveal also cancels a keystroke still waiting for its debounce).
if (this.routeState.currentRoute().kind === 'settings') {
const [bestMatch] = this.settingsSearch.search(trimmedValue, 1);
if (bestMatch) {
this.settingsSearch.reveal(bestMatch.entry);
} else {
this.searchSync.applySearchQuery(trimmedValue);
}
return;
}
if (this.searchCapability().behavior === 'advanced-only') {
const advancedRouteTarget =
this.searchCapability().advancedRouteTarget;
@@ -0,0 +1,133 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { parseWorkspaceShellRoute } from '@iptvnator/workspace/shell/util';
import {
SettingsSearchEntry,
SettingsSearchService,
} from '@iptvnator/workspace/shell/util/settings-search';
import { SEARCH_SETTINGS_PLACEHOLDER } from './helpers/workspace-shell-constants';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
import { WorkspaceShellSearchSyncService } from './workspace-shell-search-sync.service';
import { WorkspaceShellSearchService } from './workspace-shell-search.service';
const THEME: SettingsSearchEntry = {
id: 'theme',
section: 'general',
labelKey: 'SETTINGS.THEME',
};
describe('WorkspaceShellSearchService on settings routes', () => {
let service: WorkspaceShellSearchService;
let currentRoute: ReturnType<
typeof signal<ReturnType<typeof parseWorkspaceShellRoute>>
>;
let searchSync: {
searchQuery: ReturnType<typeof signal<string>>;
appliedSearchQuery: ReturnType<typeof signal<string>>;
applySearchQuery: jest.Mock;
};
let settingsSearch: { search: jest.Mock; reveal: jest.Mock };
beforeEach(() => {
currentRoute = signal(
parseWorkspaceShellRoute('/workspace/settings/general')
);
searchSync = {
searchQuery: signal(''),
appliedSearchQuery: signal(''),
applySearchQuery: jest.fn(),
};
settingsSearch = {
search: jest.fn((query: string) =>
query === 'theme' ? [{ entry: THEME, score: 1 }] : []
),
reveal: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
WorkspaceShellSearchService,
{ provide: Router, useValue: { navigate: jest.fn() } },
{
provide: XtreamStore,
useValue: { getSelectedCategory: () => null },
},
{
provide: StalkerStore,
useValue: { getSelectedCategoryName: () => '' },
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
onLangChange: of(null),
},
},
{
provide: RuntimeCapabilitiesService,
useValue: { isElectron: true },
},
{
provide: WorkspaceShellRouteStateService,
useValue: {
currentRoute,
playlists: signal([]),
dashboardXtreamContext: signal(null),
currentContext: signal(null),
},
},
{
provide: WorkspaceShellSearchSyncService,
useValue: searchSync,
},
{ provide: SettingsSearchService, useValue: settingsSearch },
],
});
service = TestBed.inject(WorkspaceShellSearchService);
});
it('enables a local settings filter with the settings placeholder', () => {
expect(service.searchCapability()).toEqual(
expect.objectContaining({
enabled: true,
behavior: 'local-filter',
searchMode: 'local-filter',
placeholderKey: SEARCH_SETTINGS_PLACEHOLDER,
scopeLabel: '',
})
);
expect(service.canUseSearch()).toBe(true);
});
it('opens the best match on Enter without applying the term', () => {
service.onSearchEnter(' theme ');
expect(settingsSearch.search).toHaveBeenCalledWith('theme', 1);
expect(settingsSearch.reveal).toHaveBeenCalledWith(THEME);
// Applying would start the `q` sync navigation and supersede the
// reveal navigation.
expect(searchSync.applySearchQuery).not.toHaveBeenCalled();
});
it('applies the term on Enter when nothing matches', () => {
service.onSearchEnter('zzz ');
expect(settingsSearch.reveal).not.toHaveBeenCalled();
expect(searchSync.applySearchQuery).toHaveBeenCalledWith('zzz');
});
it('leaves Enter on other routes to their own search', () => {
currentRoute.set(parseWorkspaceShellRoute('/workspace/sources'));
service.onSearchEnter('theme');
expect(settingsSearch.search).not.toHaveBeenCalled();
expect(searchSync.applySearchQuery).toHaveBeenCalledWith('theme');
});
});
@@ -1152,7 +1152,7 @@ describe('WorkspaceShellFacade', () => {
expect(dialog.open).not.toHaveBeenCalled();
resolveSupport();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve));
expect(dialog.open).toHaveBeenCalledTimes(1);
});
@@ -546,6 +546,25 @@ describe('WorkspaceShellComponent', () => {
expect(event.defaultPrevented).toBe(true);
expect(facade.openGlobalSearch).toHaveBeenCalledWith('');
expect(header.focusSearchInput).toHaveBeenCalledWith({ select: true });
// On settings the header search is the settings search, so find
// focuses it in place instead of leaving for global search.
facade.openGlobalSearch.mockClear();
header.focusSearchInput.mockClear();
facade.isSettingsRoute.set(true);
const settingsEvent = new KeyboardEvent('keydown', {
key: 'f',
metaKey: true,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(settingsEvent);
jest.runOnlyPendingTimers();
expect(settingsEvent.defaultPrevented).toBe(true);
expect(facade.openGlobalSearch).not.toHaveBeenCalled();
expect(header.focusSearchInput).toHaveBeenCalledWith({ select: true });
jest.useRealTimers();
});
@@ -163,7 +163,11 @@ export class WorkspaceShellComponent {
}
event.preventDefault();
this.facade.openGlobalSearch(this.facade.searchQuery());
// On settings the header search already searches the settings, so
// find stays on the page instead of leaving for global search.
if (!this.facade.isSettingsRoute()) {
this.facade.openGlobalSearch(this.facade.searchQuery());
}
setTimeout(() => header?.focusSearchInput({ select: true }));
}
}