From 5ff6eae34a5904812e8f148e171918b484d37987 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Tue, 15 Sep 2026 02:21:37 +0200 Subject: [PATCH] Leave a filtered canvas in one gesture The search, the tags and the languages were each undone where they were set, across three bands of the header. The result count doubles as the way out of all three, and Escape falls through to it once there is no selection left to clear. The quick filter is deliberately untouched: is_filtering does not count it, so it is not what turned the gesture on, and it has a visible control of its own with "All" in it. Co-Authored-By: Claude Opus 5 --- e2e/specs/06-search-and-filters.e2e.ts | 31 +++++++- .../core/services/i18n/translations/en.json | 3 +- .../core/services/i18n/translations/fr.json | 3 +- src/app/core/state/notes-query.store.spec.ts | 77 +++++++++++++++++++ src/app/core/state/notes-query.store.ts | 22 ++++++ .../search-box/search-box.component.html | 15 +++- .../search-box/search-box.component.scss | 21 +++++ .../search-box/search-box.component.spec.ts | 41 ++++++++-- .../header/search-box/search-box.component.ts | 22 +++++- src/app/notes/notes-page.component.html | 1 + .../directives/canvas-keyboard.directive.ts | 10 ++- 11 files changed, 232 insertions(+), 14 deletions(-) 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 `