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
31 changes: 30 additions & 1 deletion e2e/specs/06-search-and-filters.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { $, expect } from '@wdio/globals';

import { canvas } from '../pageobjects/canvas.page.js';
import { cursorOf, reloadCanvas, testid } from '../support/app.js';
import { blur, cursorOf, press, reloadCanvas, testid } from '../support/app.js';
import { bridge, draft, homeSpaceId } from '../support/bridge.js';

/**
Expand Down Expand Up @@ -113,6 +113,35 @@ describe('Search, filters and facets', () => {
expect(await $(testid('search-matched')).isExisting()).toBe(false);
});

/**
* The search, the tags and the languages were undone one at a time, each where it was
* set — three bands of the header. The count doubles as the way out of all three.
*/
it('drops the search, the tag and the language in one click', async () => {
await canvas.search('Docker');
await canvas.toggleTag('ops');
await canvas.toggleLanguage('sh');

await $(testid('search-matched')).click();
await canvas.open();

expect(await $(testid('search-input')).getValue()).toBe('');
expect(await $(testid('search-matched')).isExisting()).toBe(false);
expect(await canvas.tagPill('ops').getAttribute('aria-pressed')).toBe('false');
expect(await canvas.languageChip('sh').getAttribute('aria-pressed')).toBe('false');
});

it('does the same on Escape, once there is no selection to clear', async () => {
await canvas.search('Docker');
// ⚠️ Focus has to leave the field: the canvas keyboard ignores a keystroke aimed at
// an input, which is what leaves Ctrl+K and typing alone.
await blur();
await press('Escape');
await canvas.open();

expect(await $(testid('search-matched')).isExisting()).toBe(false);
});

it('quotes the line that matched rather than the head of the body', async () => {
const spaceId = await homeSpaceId();
await bridge.createNote(
Expand Down
3 changes: 2 additions & 1 deletion src/app/core/services/i18n/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"loading": "Loading notes…",
"noResults": "No note matches this search.",
"searchMatched": "{{count}} result(s)",
"clearFilters": "Show everything",
"attachmentsCount": "{{count}} attachment(s)",
"placeholdersCount": "{{count}} field(s) to fill",
"selectNote": "Select note {{title}}",
Expand Down Expand Up @@ -350,7 +351,7 @@
"extendWithClick": "Extend the selection with a click",
"trash": "Send to the trash",
"undo": "Undo the last deletion",
"clearSelection": "Clear the selection"
"clearSelection": "Clear the selection, then the filters"
},
"editor": {
"close": "Close the editor, saving",
Expand Down
3 changes: 2 additions & 1 deletion src/app/core/services/i18n/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"loading": "Chargement des notes…",
"noResults": "Aucune note ne correspond à cette recherche.",
"searchMatched": "{{count}} résultat(s)",
"clearFilters": "Tout afficher",
"attachmentsCount": "{{count}} pièce(s) jointe(s)",
"placeholdersCount": "{{count}} champ(s) à remplir",
"selectNote": "Sélectionner la note {{title}}",
Expand Down Expand Up @@ -350,7 +351,7 @@
"extendWithClick": "Étendre la sélection au clic",
"trash": "Mettre à la corbeille",
"undo": "Annuler la dernière suppression",
"clearSelection": "Vider la sélection"
"clearSelection": "Annuler la sélection, puis les filtres"
},
"editor": {
"close": "Fermer l'éditeur en enregistrant",
Expand Down
77 changes: 77 additions & 0 deletions src/app/core/state/notes-query.store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,83 @@ describe('NotesQueryStore', () => {
});
});

/**
* The search, the tags and the languages were each undone where they were set — three
* bands of the header, one gesture apiece. This is the way out in one.
*/
describe('clearing the filters', () => {
it('drops the search, the tags and the languages together', async () => {
const { canvas, repository } = await createNotesHarness([createNote()]);
canvas.setSearchQuery('deploy');
canvas.toggleTag('urgent');
canvas.toggleLanguage('json');
// ⚠️ Waited for: the params are a `computed` feeding a `resource`, so three setters
// undone before it runs would collapse to no change at all and prove nothing.
await vi.waitFor(() => expect(repository.lastQuery?.search).toBe('deploy'));
const before = repository.queryCount;

canvas.clearFilters();
await awaitQuery(repository, before);

expect(repository.lastQuery?.search).toBe('');
expect(repository.lastQuery?.tags).toEqual([]);
expect(repository.lastQuery?.languages).toEqual([]);
// The field is what the user is looking at, and it has to look empty too.
expect(canvas.searchQuery()).toBe('');
});

/**
* ⚠️ Not through `setSearchQuery`: its debounce would leave the canvas filtered for
* another 150 ms after the user asked it not to be.
*/
it('takes the search out of the query without waiting for the debounce', async () => {
const { canvas, repository } = await createNotesHarness([createNote()]);
canvas.setSearchQuery('deploy');
await vi.waitFor(() => expect(repository.lastQuery?.search).toBe('deploy'));
const before = repository.queryCount;

canvas.clearFilters();
await awaitQuery(repository, before);

expect(repository.lastQuery?.search).toBe('');
});

/**
* ⚠️ Clearing has to **cancel** the pending call, not merely set the signals past it.
* A keystroke from a moment ago is still on its way; it lands 150 ms later and puts
* the query back, so the canvas filters itself again with an empty field to explain
* it. Setting the two signals alone left exactly that.
*
* Real timers here, and a real wait: the bug needs the debounce to actually elapse,
* and faking it only proves the assertion ran before the timer did.
*/
it('drops a keystroke still in flight when the filters are cleared', async () => {
const { canvas, repository } = await createNotesHarness([createNote()]);
const query = vi.spyOn(repository, 'query');

canvas.setSearchQuery('deploy');
canvas.clearFilters();
await new Promise((resolve) => setTimeout(resolve, SEARCH_DEBOUNCE_MS * 3));

const searched = query.mock.calls.map(([sent]) => sent.search);
expect(searched).not.toContain('deploy');
expect(canvas.searchQuery()).toBe('');
});

it('leaves the quick filter alone, which has a control of its own', async () => {
const { canvas, repository } = await createNotesHarness([createNote()]);
canvas.setFilter('pinned');
canvas.toggleTag('urgent');
const before = repository.queryCount;

canvas.clearFilters();
await awaitQuery(repository, before);

expect(repository.lastQuery?.filter).toBe('pinned');
expect(canvas.activeFilter()).toBe('pinned');
});
});

describe('search debounce', () => {
beforeEach(() => {
// Only Date and timers: faking requestAnimationFrame would hang the
Expand Down
22 changes: 22 additions & 0 deletions src/app/core/state/notes-query.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,28 @@ export class NotesQueryStore {
this._selectedLanguages.update((languages) => toggled(languages, language));
}

/**
* Drops the search, the tags and the languages in one go — the three the back end
* counts as filtering, and the three that had to be undone one at a time where each
* was set.
*
* ⚠️ The quick filter is deliberately left alone. `is_filtering` in `notes::view` does
* not count it, so it is not what made this gesture appear; and it has a visible
* three-way control of its own with "All" in it, which is already the way out.
*
* ⚠️ The pending debounce is cancelled, **then** the two search signals are set. Setting
* them alone was not enough: a keystroke from a moment ago is still on its way, and it
* lands 150 ms later and puts the query back — the canvas filters itself again with an
* empty field to explain it.
*/
clearFilters(): void {
this.commitSearch.cancel();
this._searchQuery.set('');
this._debouncedSearch.set('');
this._selectedTags.set(new Set());
this._selectedLanguages.set(new Set());
}

findVisible(id: string): Note | null {
for (const section of this.sections()) {
const found = section.notes.find((note) => note.id === id);
Expand Down
15 changes: 12 additions & 3 deletions src/app/notes/header/search-box/search-box.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,18 @@
/>
<!-- `!== null` and not a truthiness check: zero results is the answer that matters most. -->
@if (matched() !== null) {
<span class="count" data-testid="search-matched">{{
'notes.searchMatched' | transloco: { count: matched() }
}}</span>
<!-- The count doubles as the way out: it is the one thing on screen that appears
exactly when the canvas is narrowed. -->
<button
type="button"
class="count"
data-testid="search-matched"
[attr.aria-label]="'notes.clearFilters' | transloco"
(click)="onClear($event)"
>
{{ 'notes.searchMatched' | transloco: { count: matched() } }}
<span class="count-clear" aria-hidden="true">✕</span>
</button>
} @else {
<span class="kbd" aria-hidden="true">{{ shortcutHint }}</span>
}
Expand Down
21 changes: 21 additions & 0 deletions src/app/notes/header/search-box/search-box.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,29 @@

// Takes the shortcut hint's place, and its alignment with it.
.count {
all: unset;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 5px;
margin-left: auto;
white-space: nowrap;
font-size: 11px;
color: var(--text-2);
cursor: pointer;
}

.count:hover,
.count:focus-visible {
color: var(--amber);
}

.count:focus-visible {
outline: 2px solid var(--amber);
outline-offset: 2px;
border-radius: 4px;
}

.count-clear {
font-size: 10px;
}
41 changes: 35 additions & 6 deletions src/app/notes/header/search-box/search-box.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,16 @@ describe('SearchBoxComponent', () => {
* `NotesView.matched` decided one boolean and was never shown.
*/
describe('the count', () => {
function matchedText(): string {
const node = fixture.nativeElement.querySelector('[data-testid="search-matched"]');
return node.textContent.replace(/\s+/g, ' ').trim();
}

it('replaces the shortcut hint while something is being filtered', async () => {
fixture.componentRef.setInput('matched', 12);
await fixture.whenStable();

expect(fixture.nativeElement.querySelector('[data-testid="search-matched"]').textContent.trim()).toBe(
'12 résultat(s)',
);
expect(matchedText()).toBe('12 résultat(s) ✕');
expect(fixture.nativeElement.querySelector('.kbd')).toBeNull();
});

Expand All @@ -75,9 +78,7 @@ describe('SearchBoxComponent', () => {
fixture.componentRef.setInput('matched', 0);
await fixture.whenStable();

expect(fixture.nativeElement.querySelector('[data-testid="search-matched"]').textContent.trim()).toBe(
'0 résultat(s)',
);
expect(matchedText()).toBe('0 résultat(s) ✕');
});

it('shows the hint again when nothing is being filtered', async () => {
Expand All @@ -87,6 +88,34 @@ describe('SearchBoxComponent', () => {
expect(fixture.nativeElement.querySelector('[data-testid="search-matched"]')).toBeNull();
expect(fixture.nativeElement.querySelector('.kbd')).not.toBeNull();
});

/**
* The count doubles as the way out. ⚠️ `preventDefault` matters: the field is inside
* the `<label>`, so the click would otherwise focus it and hand the user a cursor in
* a field they had just emptied.
*/
it('asks for the filters to be dropped, without focusing the field it emptied', async () => {
fixture.componentRef.setInput('matched', 12);
await fixture.whenStable();
let asked = 0;
fixture.componentInstance.cleared.subscribe(() => (asked += 1));

const event = new MouseEvent('click', { bubbles: true, cancelable: true });
fixture.nativeElement.querySelector('[data-testid="search-matched"]').dispatchEvent(event);
await fixture.whenStable();

expect(asked).toBe(1);
expect(event.defaultPrevented).toBe(true);
});

it('names the gesture for a screen reader, the count alone reading as a label', async () => {
fixture.componentRef.setInput('matched', 12);
await fixture.whenStable();

const button = fixture.nativeElement.querySelector('[data-testid="search-matched"]');
expect(button.tagName).toBe('BUTTON');
expect(button.getAttribute('aria-label')).toBe('Tout afficher');
});
});

it('focuses the input on Ctrl/Cmd+K and prevents the browser default', () => {
Expand Down
22 changes: 21 additions & 1 deletion src/app/notes/header/search-box/search-box.component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { ChangeDetectionStrategy, Component, ElementRef, input, model, viewChild } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
ElementRef,
input,
model,
output,
viewChild,
} from '@angular/core';
import { TranslocoPipe } from '@jsverse/transloco';

/** Showing "⌘K" on Windows would name a key that does not exist there. */
Expand Down Expand Up @@ -32,6 +40,9 @@ export class SearchBoxComponent {
*/
readonly matched = input<number | null>(null);

/** Asked for from the count, which is the only thing on screen that says it is on. */
readonly cleared = output<void>();

protected readonly shortcutHint = platformShortcutHint();

private readonly inputRef = viewChild.required<ElementRef<HTMLInputElement>>('searchInput');
Expand All @@ -48,6 +59,15 @@ export class SearchBoxComponent {
this.inputRef().nativeElement.focus();
}

/**
* ⚠️ The field is inside the <label>, so a click on this button would focus it and
* hand the user a cursor in a field they just emptied.
*/
protected onClear(event: MouseEvent): void {
event.preventDefault();
this.cleared.emit();
}

protected onInput(value: string): void {
this.query.set(value);
}
Expand Down
1 change: 1 addition & 0 deletions src/app/notes/notes-page.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
[query]="canvas.searchQuery()"
[shortcutEnabled]="searchShortcutEnabled()"
[matched]="canvas.matched()"
(cleared)="canvas.clearFilters()"
(queryChange)="canvas.setSearchQuery($event)"
/>
<app-filter-chips [active]="canvas.activeFilter()" (filterChanged)="canvas.setFilter($event)" />
Expand Down
Loading