Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest';
import { Observable, of } from 'rxjs';

import { fakeAsync, tick } from '@angular/core/testing';

import { ConfirmationService } from 'primeng/api';
import { DynamicDialogRef } from 'primeng/dynamicdialog';

import { DotHttpErrorManagerService, DotMessageService, DotOsgiService } from '@dotcms/data-access';

import { DotPluginsExtraPackagesComponent } from './dot-plugins-extra-packages.component';
import {
DotPluginsExtraPackagesComponent,
SEARCH_DEBOUNCE_MS
} from './dot-plugins-extra-packages.component';

describe('DotPluginsExtraPackagesComponent', () => {
let spectator: Spectator<DotPluginsExtraPackagesComponent>;
Expand Down Expand Up @@ -153,6 +158,100 @@ describe('DotPluginsExtraPackagesComponent', () => {
});
});

describe('search', () => {
const PACKAGES_TEXT = 'com.example.foo\ncom.example.bar\norg.example.baz\ncom.example.qux';

function searchInput(): HTMLInputElement | null {
const host = spectator.query(byTestId('plugins-extra-packages-search'));
if (!host) return null;
return host instanceof HTMLInputElement
? host
: host.querySelector<HTMLInputElement>('input');
}

beforeEach(() => {
component.extraPackages.set(PACKAGES_TEXT);
spectator.detectChanges();
});

it('should keep focus on the search input after typing (debounced)', fakeAsync(() => {
const input = searchInput()!;
const textarea = nativeTextarea()!;
expect(input).toBeTruthy();
expect(textarea).toBeTruthy();

input.focus();
expect(document.activeElement).toBe(input);

spectator.typeInElement('com', input);
tick(SEARCH_DEBOUNCE_MS);
spectator.detectChanges();

expect(component.matchCount()).toBe(3);
expect(document.activeElement).toBe(input);
}));

it('should pre-select the first match on the textarea while typing without focusing it', fakeAsync(() => {
const input = searchInput()!;
const textarea = nativeTextarea()!;

input.focus();
spectator.typeInElement('com', input);
tick(SEARCH_DEBOUNCE_MS);
spectator.detectChanges();

expect(document.activeElement).toBe(input);
const firstStart = component.matchPositions()[0];
expect(textarea.selectionStart).toBe(firstStart);
expect(textarea.selectionEnd).toBe(firstStart + 'com'.length);
}));

it('should move focus to the textarea on next-match navigation', fakeAsync(() => {
const input = searchInput()!;
const textarea = nativeTextarea()!;

input.focus();
spectator.typeInElement('com', input);
tick(SEARCH_DEBOUNCE_MS);
spectator.detectChanges();

component.nextMatch();

expect(document.activeElement).toBe(textarea);
expect(component.currentMatchIndex()).toBe(1);
const secondStart = component.matchPositions()[1];
expect(textarea.selectionStart).toBe(secondStart);
expect(textarea.selectionEnd).toBe(secondStart + 'com'.length);
}));

it('should move focus to the textarea on prev-match navigation', fakeAsync(() => {
const input = searchInput()!;
const textarea = nativeTextarea()!;

input.focus();
spectator.typeInElement('com', input);
tick(SEARCH_DEBOUNCE_MS);
spectator.detectChanges();

component.prevMatch();

expect(document.activeElement).toBe(textarea);
expect(component.currentMatchIndex()).toBe(component.matchCount() - 1);
}));

it('should leave focus untouched when the query has no matches', fakeAsync(() => {
const input = searchInput()!;

input.focus();
spectator.typeInElement('does-not-exist', input);
tick(SEARCH_DEBOUNCE_MS);
spectator.detectChanges();

expect(component.matchCount()).toBe(0);
expect(document.activeElement).toBe(input);
}));
});

describe('save', () => {
it('should save the current packages and close the dialog with true', () => {
component.extraPackages.set('pkg1\npkg2\npkg3');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { DotMessagePipe } from '@dotcms/ui';
export const EXTRA_PACKAGES_RESET_RESULT = 'restart' as const;

/** Debounce delay (ms) before scrolling to a match while the user is typing. */
const SEARCH_DEBOUNCE_MS = 500;
export const SEARCH_DEBOUNCE_MS = 500;

@Component({
selector: 'dot-plugins-extra-packages',
Expand Down Expand Up @@ -115,13 +115,13 @@ export class DotPluginsExtraPackagesComponent {
nextMatch(): void {
const next = (this.currentMatchIndex() + 1) % this.matchCount();
this.currentMatchIndex.set(next);
this.#scrollToMatch(next);
this.#scrollToMatch(next, { focus: true });
}

prevMatch(): void {
const prev = (this.currentMatchIndex() - 1 + this.matchCount()) % this.matchCount();
this.currentMatchIndex.set(prev);
this.#scrollToMatch(prev);
this.#scrollToMatch(prev, { focus: true });
}

save(): void {
Expand Down Expand Up @@ -180,16 +180,20 @@ export class DotPluginsExtraPackagesComponent {
});
}

/** Focuses the textarea, selects the match at `index`, and scrolls to center it. */
#scrollToMatch(index: number): void {
/**
* Selects the match at `index` and scrolls to center it. Focus is only moved to the
* textarea when `focus: true` is passed (explicit ▲/▼ navigation); the search-as-you-type
* path must leave focus on the search input so further keystrokes don't edit the textarea.
*/
#scrollToMatch(index: number, { focus = false }: { focus?: boolean } = {}): void {
const positions = this.matchPositions();
const textarea = this.textareaRef()?.nativeElement;
if (!textarea || positions.length === 0) return;

const start = positions[index];
const end = start + this.searchQuery().length;

textarea.focus();
if (focus) textarea.focus();
textarea.setSelectionRange(start, end);
textarea.scrollTop = this.#scrollTopForChar(textarea, start);
}
Expand Down
Loading