diff --git a/e2e/specs/06-search-and-filters.e2e.ts b/e2e/specs/06-search-and-filters.e2e.ts index 07abfac..63c3910 100644 --- a/e2e/specs/06-search-and-filters.e2e.ts +++ b/e2e/specs/06-search-and-filters.e2e.ts @@ -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'; /** @@ -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( diff --git a/src/app/core/services/i18n/translations/en.json b/src/app/core/services/i18n/translations/en.json index a531da5..880f890 100644 --- a/src/app/core/services/i18n/translations/en.json +++ b/src/app/core/services/i18n/translations/en.json @@ -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}}", @@ -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", diff --git a/src/app/core/services/i18n/translations/fr.json b/src/app/core/services/i18n/translations/fr.json index 4b74110..5b20926 100644 --- a/src/app/core/services/i18n/translations/fr.json +++ b/src/app/core/services/i18n/translations/fr.json @@ -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}}", @@ -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", diff --git a/src/app/core/state/notes-query.store.spec.ts b/src/app/core/state/notes-query.store.spec.ts index c11ed00..24149d6 100644 --- a/src/app/core/state/notes-query.store.spec.ts +++ b/src/app/core/state/notes-query.store.spec.ts @@ -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 diff --git a/src/app/core/state/notes-query.store.ts b/src/app/core/state/notes-query.store.ts index 2e805fc..84dbaa3 100644 --- a/src/app/core/state/notes-query.store.ts +++ b/src/app/core/state/notes-query.store.ts @@ -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); diff --git a/src/app/notes/header/search-box/search-box.component.html b/src/app/notes/header/search-box/search-box.component.html index 815015e..1f041b9 100644 --- a/src/app/notes/header/search-box/search-box.component.html +++ b/src/app/notes/header/search-box/search-box.component.html @@ -13,9 +13,18 @@ /> @if (matched() !== null) { - {{ - 'notes.searchMatched' | transloco: { count: matched() } - }} + + } @else { } diff --git a/src/app/notes/header/search-box/search-box.component.scss b/src/app/notes/header/search-box/search-box.component.scss index d2d94c4..d4b5482 100644 --- a/src/app/notes/header/search-box/search-box.component.scss +++ b/src/app/notes/header/search-box/search-box.component.scss @@ -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; } diff --git a/src/app/notes/header/search-box/search-box.component.spec.ts b/src/app/notes/header/search-box/search-box.component.spec.ts index 9200fcb..7f3e2f6 100644 --- a/src/app/notes/header/search-box/search-box.component.spec.ts +++ b/src/app/notes/header/search-box/search-box.component.spec.ts @@ -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(); }); @@ -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 () => { @@ -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 `