From 243826db63a840e1eaf14b31ecf9c50276347fcc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Oct 2026 23:41:03 +0000 Subject: [PATCH 1/4] Sync search filters with the page URL. Reading q, suitable, and type on enter restores the search form and runs the search. Chip changes, search, and clear rewrite those params with replaceUrl. Co-authored-by: Timmy Wong --- e2e/search-dictation-query.spec.ts | 40 +++++ .../search-dictation.page.spec.ts | 134 +++++++++++++++ .../search-dictation/search-dictation.page.ts | 161 +++++++++++++++++- src/app/services/share.service.spec.ts | 12 ++ 4 files changed, 344 insertions(+), 3 deletions(-) create mode 100644 e2e/search-dictation-query.spec.ts diff --git a/e2e/search-dictation-query.spec.ts b/e2e/search-dictation-query.spec.ts new file mode 100644 index 00000000..3427dc7b --- /dev/null +++ b/e2e/search-dictation-query.spec.ts @@ -0,0 +1,40 @@ +/** + * A filtered /search-dictation URL fills the form and runs the search. + * Changing a filter rewrites the query with replaceUrl. + * Run: PLAYWRIGHT_BASE_URL=http://localhost:4200 npx playwright test e2e/search-dictation-query.spec.ts + */ +import {expect, test} from '@playwright/test'; + +test.describe('search dictation query params', () => { + test('a filtered URL restores the form and results', async ({ page }) => { + await page.goto('/search-dictation?q=apple&suitable=JuniorPrimary&type=Vocab'); + + const keyword = page.locator('ion-input[formcontrolname="keyword"] input'); + await expect(keyword).toHaveValue('apple'); + await expect(page.locator('[data-filter="suitable"]')).toContainText('Junior Primary (6-8)'); + await expect(page.locator('[data-filter="type"]')).toContainText('Word'); + await expect(page).toHaveURL(/q=apple/); + await expect(page).toHaveURL(/suitable=JuniorPrimary/); + await expect(page).toHaveURL(/type=Vocab/); + + const firstResult = page.locator('#dictation-list ion-item').first(); + await expect(firstResult).toBeVisible({ timeout: 20_000 }); + + await page.screenshot({ + path: 'test-results/search-query-deep-link.png', + fullPage: true, + }); + + const historyLength = await page.evaluate(() => history.length); + await page.locator('#type-filter').click(); + await page.locator('ion-item', { hasText: 'Sentence' }).click(); + await expect(page).toHaveURL(/type=Article/); + await expect(page).toHaveURL(/q=apple/); + expect(await page.evaluate(() => history.length)).toBe(historyLength); + + await page.locator('ion-button', { hasText: 'Search' }).click(); + await expect(page.locator('#dictation-list ion-item').first()).toBeVisible({ timeout: 20_000 }); + await expect(page).toHaveURL(/type=Article/); + expect(await page.evaluate(() => history.length)).toBe(historyLength); + }); +}); diff --git a/src/app/pages/search-dictation/search-dictation.page.spec.ts b/src/app/pages/search-dictation/search-dictation.page.spec.ts index 54ff71e1..eb50330f 100644 --- a/src/app/pages/search-dictation/search-dictation.page.spec.ts +++ b/src/app/pages/search-dictation/search-dictation.page.spec.ts @@ -1,9 +1,17 @@ import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; +import {ActivatedRoute, convertToParamMap, Params, Router} from '@angular/router'; import {TranslateService} from '@ngx-translate/core'; import {of} from 'rxjs'; +class SearchQueryRoute { + query: Params = {}; + get snapshot() { + return { queryParamMap: convertToParamMap(this.query) }; + } +} + import {SearchDictationPage} from './search-dictation.page'; import {SharedTestModule} from '../../../testing/shared-test.module'; import {ManageVocabHistoryServiceSpy, StorageSpy} from '../../../testing/mocks-ionic'; @@ -18,10 +26,13 @@ describe('SearchDictationPage', () => { let component: SearchDictationPage; let fixture: ComponentFixture; let storageSpy; + let queryRoute: SearchQueryRoute; + let navigateSpy: jasmine.Spy; beforeEach(waitForAsync(() => { storageSpy = StorageSpy(); storageSpy.get.and.returnValue(Promise.resolve(['old search history'])); + queryRoute = new SearchQueryRoute(); TestBed.configureTestingModule({ declarations: [ SearchDictationPage ], @@ -32,12 +43,14 @@ describe('SearchDictationPage', () => { providers: [ { provide: StorageService, useValue: storageSpy }, { provide: ManageVocabHistoryService, useValue: ManageVocabHistoryServiceSpy}, + { provide: ActivatedRoute, useValue: queryRoute }, ] }) .compileComponents(); })); beforeEach(() => { + navigateSpy = spyOn(TestBed.inject(Router), 'navigate').and.returnValue(Promise.resolve(true)); fixture = TestBed.createComponent(SearchDictationPage); component = fixture.componentInstance; fixture.detectChanges(); @@ -179,6 +192,11 @@ describe('SearchDictationPage', () => { expect(component.minDate.value).toBe(component.dateOptions[2]); expect(chipText('date')).toBe('Date: Within 3 Month'); expect(search).not.toHaveBeenCalled(); + expect(navigateSpy).toHaveBeenCalledTimes(2); + const lastParams = navigateSpy.calls.mostRecent().args[1].queryParams; + expect(lastParams).toEqual({ suitable: 'JuniorPrimary', type: 'Vocab' }); + expect(navigateSpy.calls.mostRecent().args[1].replaceUrl).toBeTrue(); + expect(navigateSpy.calls.mostRecent().args[1].queryParamsHandling).toBe('merge'); }); function chipText(group: string): string { @@ -298,6 +316,117 @@ describe('SearchDictationPage', () => { expect(fixture.nativeElement.querySelector('#dictation-list ion-item')).toBeTruthy(); })); + it('loads q, suitable, and type from the URL and searches', fakeAsync(() => { + const search = spyOn(component.dictationService, 'search').and.returnValue(of([TestData.fillInDictation()])); + component.creator.setValue('ann'); + component.chooseDate(component.dateOptions[2]); + queryRoute.query = { q: ' apple ', suitable: 'JuniorPrimary', type: 'Vocab', sort: 'new' }; + openFromUrl(); + + expect(component.keyword.value).toBe('apple'); + expect(component.suitableStudent.value).toBe('JuniorPrimary'); + expect(component.type.value).toBe('Vocab'); + expect(component.creator.value).toBe(''); + expect(component.minDate.value).toBe(component.dateOptions[0]); + expect(search).toHaveBeenCalledTimes(1); + expect(search.calls.mostRecent().args[0]).toEqual(jasmine.objectContaining({ + keyword: 'apple', + suitableStudent: 'JuniorPrimary', + type: 'Vocab', + creator: '', + })); + expect(search.calls.mostRecent().args[0].minDate).toBeUndefined(); + expect(component.history[0]).toBe('apple'); + expect(component.history[1]).toBe('old search history'); + expect(navigateSpy).toHaveBeenCalledWith([], jasmine.objectContaining({ + replaceUrl: true, + queryParamsHandling: 'merge', + queryParams: { suitable: 'JuniorPrimary', type: 'Vocab', q: 'apple' }, + })); + fixture.detectChanges(); + expect(chipText('suitable')).toBe('Suitable (Age): SuitableStudent.JuniorPrimary'); + expect(chipText('type')).toBe('Type: Word'); + + const results = component.results; + openFromUrl(); + expect(search).toHaveBeenCalledTimes(1); + expect(component.results).toBe(results); + })); + + it('drops unknown suitable and type and skips a blank search', fakeAsync(() => { + const search = spyOn(component.dictationService, 'search').and.returnValue(of([])); + queryRoute.query = { q: ' ', suitable: 'nope', type: 'Sentence' }; + openFromUrl(); + + expect(component.keyword.value).toBe(''); + expect(component.suitableStudent.value).toBe('Any'); + expect(component.type.value).toBe('Any'); + expect(search).not.toHaveBeenCalled(); + expect(navigateSpy).toHaveBeenCalledWith([], jasmine.objectContaining({ + replaceUrl: true, + queryParamsHandling: 'merge', + queryParams: { q: null, suitable: null, type: null }, + })); + })); + + it('shows the keyword error and does not search a too-short q', fakeAsync(() => { + useNoResultsCopy(); + const search = spyOn(component.dictationService, 'search').and.returnValue(of([])); + queryRoute.query = { q: 'ab', suitable: 'JuniorPrimary' }; + openFromUrl(); + fixture.detectChanges(); + + expect(search).not.toHaveBeenCalled(); + expect(component.keyword.errors.pattern).toBeDefined(); + expect(fixture.nativeElement.querySelector('.error-text').textContent) + .toContain('cannot less than 3 characters'); + expect(navigateSpy).not.toHaveBeenCalled(); + })); + + it('writes q, suitable, and type when search runs', fakeAsync(() => { + spyOn(component.dictationService, 'search').and.returnValue(of([])); + component.keyword.setValue(' apple '); + component.chooseSuitable('JuniorPrimary'); + component.chooseType('Article'); + navigateSpy.calls.reset(); + runSearch(); + + expect(component.keyword.value).toBe('apple'); + expect(navigateSpy).toHaveBeenCalledWith([], jasmine.objectContaining({ + replaceUrl: true, + queryParamsHandling: 'merge', + queryParams: { q: 'apple', suitable: 'JuniorPrimary', type: 'Article' }, + })); + })); + + it('clears q, suitable, and type from the URL', () => { + component.keyword.setValue('apple'); + component.chooseType('Vocab'); + queryRoute.query = { q: 'apple', type: 'Vocab' }; + navigateSpy.calls.reset(); + + component.clearSearch(); + + expect(navigateSpy).toHaveBeenCalledWith([], jasmine.objectContaining({ + replaceUrl: true, + queryParamsHandling: 'merge', + queryParams: { q: null, suitable: null, type: null }, + })); + }); + + it('a later visit with no params clears a previous filtered search', fakeAsync(() => { + const search = spyOn(component.dictationService, 'search').and.returnValue(of([TestData.fillInDictation()])); + queryRoute.query = { q: 'apple' }; + openFromUrl(); + expect(search).toHaveBeenCalledTimes(1); + + queryRoute.query = {}; + openFromUrl(); + expect(component.keyword.value).toBe(''); + expect(component.results).toBeNull(); + expect(search).toHaveBeenCalledTimes(1); + })); + it('do not store duplicate search history', fakeAsync(() => { component.keyword.setValue(`new search`); component.search(); @@ -321,6 +450,11 @@ describe('SearchDictationPage', () => { fixture.detectChanges(); } + function openFromUrl() { + component.ionViewWillEnter(); + tick(); + } + function noResultsMessage(): string { return fixture.nativeElement.querySelector('.no-results-message')?.textContent.replace(/\s+/g, ' ').trim() ?? ''; } diff --git a/src/app/pages/search-dictation/search-dictation.page.ts b/src/app/pages/search-dictation/search-dictation.page.ts index cd95dea2..8a37a202 100644 --- a/src/app/pages/search-dictation/search-dictation.page.ts +++ b/src/app/pages/search-dictation/search-dictation.page.ts @@ -1,6 +1,7 @@ import {ChangeDetectorRef, Component, OnDestroy, OnInit} from '@angular/core'; import {Subscription} from 'rxjs'; import {UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators} from '@angular/forms'; +import {ActivatedRoute, Router} from '@angular/router'; import {TranslateService} from '@ngx-translate/core'; import {Dictation, Dictations, SuitableStudentOptions} from '../../entity/dictation'; import {DICTATION_SEARCH_MAX_RESULTS, DictationService} from '../../services/dictation/dictation.service'; @@ -40,6 +41,10 @@ export class SearchDictationPage implements OnInit, OnDestroy { typeChipLabel = ''; dateChipLabel = ''; private langChange: Subscription; + private historyReady: Promise | null = null; + /** Last q / suitable / type applied from the URL or a committed search. */ + private appliedQuery: string | null = null; + private applyingQuery = false; constructor( public formBuilder: UntypedFormBuilder, @@ -47,6 +52,8 @@ export class SearchDictationPage implements OnInit, OnDestroy { public translateService: TranslateService, public storage: StorageService, public ionicComponentService: IonicComponentService, + private route: ActivatedRoute, + private router: Router, private cdr: ChangeDetectorRef, ) { } @@ -81,20 +88,24 @@ export class SearchDictationPage implements OnInit, OnDestroy { this.inputForm.get('suitableStudent').setValue('Any'); } + ionViewWillEnter() { + void this.applyRouteQuery(); + } + ionViewDidEnter() { - this.storage.get(this.SEARCH_HISTORY_KEY).then(h => { - this.history = h ? h : []; - }); + void this.ensureHistory(); } chooseSuitable(state: string) { this.suitableStudent.setValue(state); this.refreshChipLabels(); + this.replaceQueryParams(false); } chooseType(value: string) { this.type.setValue(value); this.refreshChipLabels(); + this.replaceQueryParams(false); } chooseDate(option: DateSearchOption) { @@ -132,9 +143,17 @@ export class SearchDictationPage implements OnInit, OnDestroy { this.showHistory = false; this.filteredHistory = []; this.results = null; + this.appliedQuery = this.formSignature(); + this.replaceQueryParams(true); } async search() { + const trimmed = `${this.keyword.value ?? ''}`.trim(); + if (this.keyword.value !== trimmed) { + this.keyword.setValue(trimmed); + } + this.replaceQueryParams(true); + this.appliedQuery = this.formSignature(); this.results = null; const loader = await this.ionicComponentService.showLoading(); @@ -180,6 +199,142 @@ export class SearchDictationPage implements OnInit, OnDestroy { return options; } + private ensureHistory(): Promise { + if (!this.historyReady) { + this.historyReady = this.storage.get(this.SEARCH_HISTORY_KEY).then(h => { + this.history = h ? h : []; + }); + } + return this.historyReady; + } + + private async applyRouteQuery(): Promise { + if (this.applyingQuery) { + return; + } + this.applyingQuery = true; + try { + // History must be in memory before search() stores this keyword. + await this.ensureHistory(); + const incoming = this.readQuery(); + const signature = this.signature(incoming.q, incoming.suitable, incoming.type); + // Back from a dictation keeps the same URL, so keep the list and its page. + if (signature === this.appliedQuery) { + return; + } + + const hadQuery = this.appliedQuery !== null; + this.keyword.setValue(incoming.q); + this.suitableStudent.setValue(incoming.suitable); + this.type.setValue(incoming.type); + this.creator.setValue(''); + this.minDate.setValue(this.dateOptions[0]); + this.refreshChipLabels(); + this.showHistory = false; + this.filteredHistory = []; + this.appliedQuery = this.formSignature(); + this.replaceQueryParams(true); + + if (!this.inputForm.valid) { + this.keyword.markAsDirty(); + this.keyword.markAsTouched(); + this.results = null; + return; + } + if (!this.hasSearchCriteria()) { + if (hadQuery) { + this.results = null; + } + return; + } + await this.search(); + } finally { + this.applyingQuery = false; + } + } + + private readQuery(): { q: string; suitable: string; type: string } { + const params = this.route.snapshot.queryParamMap; + const suitableRaw = params.get('suitable'); + const typeRaw = params.get('type'); + return { + q: (params.get('q') ?? '').trim(), + suitable: suitableRaw && this.suitableStudentOptions.includes(suitableRaw) ? suitableRaw : 'Any', + type: typeRaw && this.typeOptions.some(option => option.value === typeRaw) ? typeRaw : 'Any', + }; + } + + private hasSearchCriteria(): boolean { + const q = `${this.keyword.value ?? ''}`.trim(); + return q.length > 0 || this.suitableStudent.value !== 'Any' || this.type.value !== 'Any'; + } + + private formSignature(): string { + return this.signature( + `${this.keyword.value ?? ''}`, + this.suitableStudent.value, + this.type.value, + ); + } + + private signature(q: string, suitable: string, type: string): string { + return `${(q ?? '').trim()}\n${this.canonicalSuitable(suitable)}\n${this.canonicalType(type)}`; + } + + private canonicalSuitable(value: string | null): string { + if (value && value !== 'Any' && this.suitableStudentOptions.includes(value)) { + return value; + } + return ''; + } + + private canonicalType(value: string | null): string { + if (value === 'Vocab' || value === 'Article') { + return value; + } + return ''; + } + + private optionalParam(value: string | null): string | null { + return value && value !== 'Any' ? value : null; + } + + private replaceQueryParams(commitKeyword: boolean) { + const queryParams: { q?: string | null; suitable: string | null; type: string | null } = { + suitable: this.optionalParam(this.suitableStudent.value), + type: this.optionalParam(this.type.value), + }; + if (commitKeyword) { + const q = `${this.keyword.value ?? ''}`.trim(); + queryParams.q = q.length > 0 ? q : null; + } + if (this.queryMatches(queryParams, commitKeyword)) { + return; + } + void this.router.navigate([], { + relativeTo: this.route, + replaceUrl: true, + queryParamsHandling: 'merge', + queryParams, + }); + } + + private queryMatches( + next: { q?: string | null; suitable: string | null; type: string | null }, + includeKeyword: boolean, + ): boolean { + const current = this.route.snapshot.queryParamMap; + const same = (key: string, expected: string | null) => { + const actual = current.get(key); + const normalized = actual == null || actual === '' ? null : actual; + return normalized === expected; + }; + if (includeKeyword && !same('q', next.q ?? null)) { + return false; + } + return same('suitable', next.suitable) && same('type', next.type); + } + private addToHistory(value: String) { if (this.history.find(h => h == value)) { return; } diff --git a/src/app/services/share.service.spec.ts b/src/app/services/share.service.spec.ts index 37145a51..f8abcb97 100644 --- a/src/app/services/share.service.spec.ts +++ b/src/app/services/share.service.spec.ts @@ -1,5 +1,6 @@ import { TestBed } from '@angular/core/testing'; +import { Dictation } from '../entity/dictation'; import { ShareService } from './share.service'; describe('ShareService', () => { @@ -13,4 +14,15 @@ describe('ShareService', () => { it('should be created', () => { expect(service).toBeTruthy(); }); + + it('shares the canonical dictation link', async () => { + const share = jasmine.createSpy('share').and.resolveTo(undefined); + Object.defineProperty(navigator, 'share', { configurable: true, value: share }); + + await service.shareDictation({ id: 42, title: 'Apple' } as Dictation); + + expect(share).toHaveBeenCalledWith(jasmine.objectContaining({ + url: 'https://www.funfunspell.com/link/dictation-view/42', + })); + }); }); From 23d3662efdcc870a37f9562a74bb89f9d43e2a4e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 00:10:24 +0000 Subject: [PATCH 2/4] Share one search-link model and skip blank history. Read, compare, and write q, suitable, and type through SearchLink. A filter-only URL still searches, and it no longer stores an empty keyword. Co-authored-by: Timmy Wong --- .../search-dictation.page.spec.ts | 49 +++++-- .../search-dictation/search-dictation.page.ts | 132 +++++++++--------- 2 files changed, 103 insertions(+), 78 deletions(-) diff --git a/src/app/pages/search-dictation/search-dictation.page.spec.ts b/src/app/pages/search-dictation/search-dictation.page.spec.ts index eb50330f..cae77c45 100644 --- a/src/app/pages/search-dictation/search-dictation.page.spec.ts +++ b/src/app/pages/search-dictation/search-dictation.page.spec.ts @@ -5,13 +5,6 @@ import {ActivatedRoute, convertToParamMap, Params, Router} from '@angular/router import {TranslateService} from '@ngx-translate/core'; import {of} from 'rxjs'; -class SearchQueryRoute { - query: Params = {}; - get snapshot() { - return { queryParamMap: convertToParamMap(this.query) }; - } -} - import {SearchDictationPage} from './search-dictation.page'; import {SharedTestModule} from '../../../testing/shared-test.module'; import {ManageVocabHistoryServiceSpy, StorageSpy} from '../../../testing/mocks-ionic'; @@ -22,6 +15,13 @@ import {DICTATION_SEARCH_MAX_RESULTS} from '../../services/dictation/dictation.s import {TestData} from '../../../testing/test-data'; import en from '../../../assets/i18n/en.json'; +class SearchQueryRoute { + query: Params = {}; + get snapshot() { + return { queryParamMap: convertToParamMap(this.query) }; + } +} + describe('SearchDictationPage', () => { let component: SearchDictationPage; let fixture: ComponentFixture; @@ -372,15 +372,19 @@ describe('SearchDictationPage', () => { it('shows the keyword error and does not search a too-short q', fakeAsync(() => { useNoResultsCopy(); const search = spyOn(component.dictationService, 'search').and.returnValue(of([])); - queryRoute.query = { q: 'ab', suitable: 'JuniorPrimary' }; + queryRoute.query = { q: 'ab', suitable: 'nope' }; openFromUrl(); fixture.detectChanges(); expect(search).not.toHaveBeenCalled(); expect(component.keyword.errors.pattern).toBeDefined(); + expect(component.suitableStudent.value).toBe('Any'); expect(fixture.nativeElement.querySelector('.error-text').textContent) .toContain('cannot less than 3 characters'); - expect(navigateSpy).not.toHaveBeenCalled(); + expect(navigateSpy).toHaveBeenCalledWith([], jasmine.objectContaining({ + replaceUrl: true, + queryParams: { q: 'ab', suitable: null, type: null }, + })); })); it('writes q, suitable, and type when search runs', fakeAsync(() => { @@ -414,6 +418,33 @@ describe('SearchDictationPage', () => { })); }); + it('does not store a blank keyword for a filter-only link', fakeAsync(() => { + const search = spyOn(component.dictationService, 'search').and.returnValue(of([])); + queryRoute.query = { type: 'Vocab' }; + openFromUrl(); + + expect(search).toHaveBeenCalledTimes(1); + expect(search.calls.mostRecent().args[0]).toEqual(jasmine.objectContaining({ + keyword: '', + type: 'Vocab', + })); + expect(component.history).toEqual(['old search history']); + })); + + it('searches the updated URL when a chip changed before leaving', fakeAsync(() => { + const search = spyOn(component.dictationService, 'search').and.returnValue(of([TestData.fillInDictation()])); + queryRoute.query = { q: 'apple' }; + openFromUrl(); + + component.chooseType('Article'); + queryRoute.query = { q: 'apple', type: 'Article' }; + openFromUrl(); + + expect(search).toHaveBeenCalledTimes(2); + expect(search.calls.mostRecent().args[0].type).toBe('Article'); + expect(component.type.value).toBe('Article'); + })); + it('a later visit with no params clears a previous filtered search', fakeAsync(() => { const search = spyOn(component.dictationService, 'search').and.returnValue(of([TestData.fillInDictation()])); queryRoute.query = { q: 'apple' }; diff --git a/src/app/pages/search-dictation/search-dictation.page.ts b/src/app/pages/search-dictation/search-dictation.page.ts index 8a37a202..f2113e64 100644 --- a/src/app/pages/search-dictation/search-dictation.page.ts +++ b/src/app/pages/search-dictation/search-dictation.page.ts @@ -14,6 +14,12 @@ export interface DateSearchOption { date?: Date; } +interface SearchLink { + q: string; + suitable: string; + type: string; +} + @Component({ selector: 'app-search-dictation', templateUrl: './search-dictation.page.html', @@ -42,8 +48,8 @@ export class SearchDictationPage implements OnInit, OnDestroy { dateChipLabel = ''; private langChange: Subscription; private historyReady: Promise | null = null; - /** Last q / suitable / type applied from the URL or a committed search. */ - private appliedQuery: string | null = null; + /** Last q / suitable / type taken from the URL or committed by search or clear. */ + private appliedLink: SearchLink | null = null; private applyingQuery = false; constructor( @@ -143,8 +149,7 @@ export class SearchDictationPage implements OnInit, OnDestroy { this.showHistory = false; this.filteredHistory = []; this.results = null; - this.appliedQuery = this.formSignature(); - this.replaceQueryParams(true); + this.rememberLink(true); } async search() { @@ -152,22 +157,8 @@ export class SearchDictationPage implements OnInit, OnDestroy { if (this.keyword.value !== trimmed) { this.keyword.setValue(trimmed); } - this.replaceQueryParams(true); - this.appliedQuery = this.formSignature(); - this.results = null; - const loader = await this.ionicComponentService.showLoading(); - - this.addToHistory(this.keyword.value); - this.dictationService.search({ - keyword: this.keyword.value, - minDate: this.minDate.value.date, - creator: this.creator.value, - suitableStudent: this.suitableStudent.value, - type: this.type.value === 'Any' ? null : this.type.value, - }).subscribe(r => { - loader.dismiss(); - this.results = r; - }, _e => loader.dismiss()); + this.rememberLink(true); + await this.fetchResults(); } createDateOptions(): DateSearchOption[] { @@ -214,16 +205,15 @@ export class SearchDictationPage implements OnInit, OnDestroy { } this.applyingQuery = true; try { - // History must be in memory before search() stores this keyword. + // History must be in memory before a search stores this keyword. await this.ensureHistory(); - const incoming = this.readQuery(); - const signature = this.signature(incoming.q, incoming.suitable, incoming.type); - // Back from a dictation keeps the same URL, so keep the list and its page. - if (signature === this.appliedQuery) { + const incoming = this.routeLink(); + // Same URL on the way back from a dictation: keep the list and its page. + if (this.appliedLink && this.sameLink(incoming, this.appliedLink)) { return; } - const hadQuery = this.appliedQuery !== null; + const hadLink = this.appliedLink !== null; this.keyword.setValue(incoming.q); this.suitableStudent.setValue(incoming.suitable); this.type.setValue(incoming.type); @@ -232,8 +222,7 @@ export class SearchDictationPage implements OnInit, OnDestroy { this.refreshChipLabels(); this.showHistory = false; this.filteredHistory = []; - this.appliedQuery = this.formSignature(); - this.replaceQueryParams(true); + this.rememberLink(true); if (!this.inputForm.valid) { this.keyword.markAsDirty(); @@ -241,76 +230,78 @@ export class SearchDictationPage implements OnInit, OnDestroy { this.results = null; return; } - if (!this.hasSearchCriteria()) { - if (hadQuery) { + if (!this.hasCriteria(incoming)) { + if (hadLink) { this.results = null; } return; } - await this.search(); + await this.fetchResults(); } finally { this.applyingQuery = false; } } - private readQuery(): { q: string; suitable: string; type: string } { - const params = this.route.snapshot.queryParamMap; - const suitableRaw = params.get('suitable'); - const typeRaw = params.get('type'); - return { - q: (params.get('q') ?? '').trim(), - suitable: suitableRaw && this.suitableStudentOptions.includes(suitableRaw) ? suitableRaw : 'Any', - type: typeRaw && this.typeOptions.some(option => option.value === typeRaw) ? typeRaw : 'Any', - }; + private async fetchResults() { + this.results = null; + const loader = await this.ionicComponentService.showLoading(); + + this.addToHistory(this.keyword.value); + this.dictationService.search({ + keyword: this.keyword.value, + minDate: this.minDate.value.date, + creator: this.creator.value, + suitableStudent: this.suitableStudent.value, + type: this.type.value === 'Any' ? null : this.type.value, + }).subscribe(r => { + loader.dismiss(); + this.results = r; + }, _e => loader.dismiss()); } - private hasSearchCriteria(): boolean { - const q = `${this.keyword.value ?? ''}`.trim(); - return q.length > 0 || this.suitableStudent.value !== 'Any' || this.type.value !== 'Any'; + private routeLink(): SearchLink { + const params = this.route.snapshot.queryParamMap; + return this.toLink(params.get('q'), params.get('suitable'), params.get('type')); } - private formSignature(): string { - return this.signature( - `${this.keyword.value ?? ''}`, - this.suitableStudent.value, - this.type.value, - ); + private formLink(): SearchLink { + return this.toLink(this.keyword.value, this.suitableStudent.value, this.type.value); } - private signature(q: string, suitable: string, type: string): string { - return `${(q ?? '').trim()}\n${this.canonicalSuitable(suitable)}\n${this.canonicalType(type)}`; + private toLink(q: string | null, suitable: string | null, type: string | null): SearchLink { + return { + q: (q ?? '').trim(), + suitable: suitable && this.suitableStudentOptions.includes(suitable) ? suitable : 'Any', + type: type && this.typeOptions.some(option => option.value === type) ? type : 'Any', + }; } - private canonicalSuitable(value: string | null): string { - if (value && value !== 'Any' && this.suitableStudentOptions.includes(value)) { - return value; - } - return ''; + private sameLink(left: SearchLink, right: SearchLink): boolean { + return left.q === right.q && left.suitable === right.suitable && left.type === right.type; } - private canonicalType(value: string | null): string { - if (value === 'Vocab' || value === 'Article') { - return value; - } - return ''; + private hasCriteria(link: SearchLink): boolean { + return link.q.length > 0 || link.suitable !== 'Any' || link.type !== 'Any'; } - private optionalParam(value: string | null): string | null { - return value && value !== 'Any' ? value : null; + private rememberLink(commitKeyword: boolean) { + this.appliedLink = this.formLink(); + this.replaceQueryParams(commitKeyword); } private replaceQueryParams(commitKeyword: boolean) { + const link = this.formLink(); const queryParams: { q?: string | null; suitable: string | null; type: string | null } = { - suitable: this.optionalParam(this.suitableStudent.value), - type: this.optionalParam(this.type.value), + suitable: link.suitable === 'Any' ? null : link.suitable, + type: link.type === 'Any' ? null : link.type, }; if (commitKeyword) { - const q = `${this.keyword.value ?? ''}`.trim(); - queryParams.q = q.length > 0 ? q : null; + queryParams.q = link.q.length > 0 ? link.q : null; } if (this.queryMatches(queryParams, commitKeyword)) { return; } + // Same-page query update. NavigationService.navigate would push a history entry. void this.router.navigate([], { relativeTo: this.route, replaceUrl: true, @@ -336,9 +327,12 @@ export class SearchDictationPage implements OnInit, OnDestroy { } private addToHistory(value: String) { - if (this.history.find(h => h == value)) { return; } + const term = `${value ?? ''}`.trim(); + if (term.length === 0 || this.history.find(h => h == term)) { + return; + } - this.history.unshift(value); + this.history.unshift(term); if (this.history.length > this.MAX_HISTORY) { this.history.pop(); } this.storage.set(this.SEARCH_HISTORY_KEY, this.history); } From cf3915fadabd725781bfd9579bb9766cda3e3484 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 13:17:50 +0000 Subject: [PATCH 3/4] Use some() when checking search history for a duplicate. SonarCloud flagged find() on the history list because the result is only used as a boolean. Co-authored-by: Timmy Wong --- src/app/pages/search-dictation/search-dictation.page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/pages/search-dictation/search-dictation.page.ts b/src/app/pages/search-dictation/search-dictation.page.ts index f2113e64..dcaa56f1 100644 --- a/src/app/pages/search-dictation/search-dictation.page.ts +++ b/src/app/pages/search-dictation/search-dictation.page.ts @@ -328,7 +328,7 @@ export class SearchDictationPage implements OnInit, OnDestroy { private addToHistory(value: String) { const term = `${value ?? ''}`.trim(); - if (term.length === 0 || this.history.find(h => h == term)) { + if (term.length === 0 || this.history.some(h => h == term)) { return; } From 2cd1700ff6f0b6633e796ee9a4baa2f4fe682ca0 Mon Sep 17 00:00:00 2001 From: thcathy Date: Tue, 13 Oct 2026 21:53:22 +0800 Subject: [PATCH 4/4] Name the saved search URL state query. Co-authored-by: Cursor --- .../search-dictation.page.html | 4 +- .../search-dictation.page.spec.ts | 4 +- .../search-dictation/search-dictation.page.ts | 117 ++++++++---------- 3 files changed, 56 insertions(+), 69 deletions(-) diff --git a/src/app/pages/search-dictation/search-dictation.page.html b/src/app/pages/search-dictation/search-dictation.page.html index e11e3fb8..09381424 100644 --- a/src/app/pages/search-dictation/search-dictation.page.html +++ b/src/app/pages/search-dictation/search-dictation.page.html @@ -60,8 +60,8 @@ - @for (state of suitableStudentOptions; track state) { - {{'SuitableStudent.' + state|translate}} + @for (option of suitableStudentOptions; track option) { + {{'SuitableStudent.' + option|translate}} } diff --git a/src/app/pages/search-dictation/search-dictation.page.spec.ts b/src/app/pages/search-dictation/search-dictation.page.spec.ts index cae77c45..f6e9020a 100644 --- a/src/app/pages/search-dictation/search-dictation.page.spec.ts +++ b/src/app/pages/search-dictation/search-dictation.page.spec.ts @@ -189,7 +189,7 @@ describe('SearchDictationPage', () => { component.chooseDate(component.dateOptions[2]); fixture.detectChanges(); - expect(component.minDate.value).toBe(component.dateOptions[2]); + expect(component.date.value).toBe(component.dateOptions[2]); expect(chipText('date')).toBe('Date: Within 3 Month'); expect(search).not.toHaveBeenCalled(); expect(navigateSpy).toHaveBeenCalledTimes(2); @@ -327,7 +327,7 @@ describe('SearchDictationPage', () => { expect(component.suitableStudent.value).toBe('JuniorPrimary'); expect(component.type.value).toBe('Vocab'); expect(component.creator.value).toBe(''); - expect(component.minDate.value).toBe(component.dateOptions[0]); + expect(component.date.value).toBe(component.dateOptions[0]); expect(search).toHaveBeenCalledTimes(1); expect(search.calls.mostRecent().args[0]).toEqual(jasmine.objectContaining({ keyword: 'apple', diff --git a/src/app/pages/search-dictation/search-dictation.page.ts b/src/app/pages/search-dictation/search-dictation.page.ts index dcaa56f1..4580b727 100644 --- a/src/app/pages/search-dictation/search-dictation.page.ts +++ b/src/app/pages/search-dictation/search-dictation.page.ts @@ -3,7 +3,7 @@ import {Subscription} from 'rxjs'; import {UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators} from '@angular/forms'; import {ActivatedRoute, Router} from '@angular/router'; import {TranslateService} from '@ngx-translate/core'; -import {Dictation, Dictations, SuitableStudentOptions} from '../../entity/dictation'; +import {Dictation, SuitableStudentOptions} from '../../entity/dictation'; import {DICTATION_SEARCH_MAX_RESULTS, DictationService} from '../../services/dictation/dictation.service'; import {ValidationUtils} from '../../utils/validation-utils'; import {IonicComponentService} from '../../services/ionic-component.service'; @@ -14,8 +14,8 @@ export interface DateSearchOption { date?: Date; } -interface SearchLink { - q: string; +interface SearchQuery { + keyword: string; suitable: string; type: string; } @@ -48,8 +48,8 @@ export class SearchDictationPage implements OnInit, OnDestroy { dateChipLabel = ''; private langChange: Subscription; private historyReady: Promise | null = null; - /** Last q / suitable / type taken from the URL or committed by search or clear. */ - private appliedLink: SearchLink | null = null; + /** q / suitable / type last read from or written to the URL. */ + private query: SearchQuery | null = null; private applyingQuery = false; constructor( @@ -74,11 +74,10 @@ export class SearchDictationPage implements OnInit, OnDestroy { } get keyword() { return this.inputForm.get('keyword'); } - get minDate() { return this.inputForm.get('minDate'); } + get date() { return this.inputForm.get('date'); } get creator() { return this.inputForm.get('creator'); } get suitableStudent() { return this.inputForm.get('suitableStudent'); } get type() { return this.inputForm.get('type'); } - get source() { return Dictations.Source; } get noResults(): boolean { return Array.isArray(this.results) && this.results.length === 0; } @@ -86,7 +85,7 @@ export class SearchDictationPage implements OnInit, OnDestroy { createForm() { this.inputForm = this.formBuilder.group({ 'keyword': new UntypedFormControl('', [Validators.pattern('.{3,}|[0-9]{1,}'), Validators.maxLength(50)]), - 'minDate': this.dateOptions[0], + 'date': this.dateOptions[0], 'creator': new UntypedFormControl('', [Validators.minLength(3), Validators.maxLength(50)]), 'suitableStudent': 'Any', 'type': 'Any', @@ -102,20 +101,20 @@ export class SearchDictationPage implements OnInit, OnDestroy { void this.ensureHistory(); } - chooseSuitable(state: string) { - this.suitableStudent.setValue(state); + chooseSuitable(option: string) { + this.suitableStudent.setValue(option); this.refreshChipLabels(); - this.replaceQueryParams(false); + this.updateSuitableAndType(); } chooseType(value: string) { this.type.setValue(value); this.refreshChipLabels(); - this.replaceQueryParams(false); + this.updateSuitableAndType(); } chooseDate(option: DateSearchOption) { - this.minDate.setValue(option); + this.date.setValue(option); this.refreshChipLabels(); } @@ -123,7 +122,7 @@ export class SearchDictationPage implements OnInit, OnDestroy { this.suitableChipLabel = this.translateService.instant('SuitableStudent.' + this.suitableStudent.value); const typeKey = this.typeOptions.find(option => option.value === this.type.value)?.label ?? ''; this.typeChipLabel = typeKey ? this.translateService.instant(typeKey) : ''; - const dateKey = this.minDate.value?.option ?? ''; + const dateKey = this.date.value?.option ?? ''; this.dateChipLabel = dateKey ? this.translateService.instant(dateKey) : ''; // The dev-mode double check skips this view, then fails when it re-reads the new label. this.cdr.markForCheck(); @@ -142,14 +141,14 @@ export class SearchDictationPage implements OnInit, OnDestroy { this.creator.setValue(''); this.suitableStudent.setValue('Any'); this.type.setValue('Any'); - this.minDate.setValue(this.dateOptions[0]); + this.date.setValue(this.dateOptions[0]); this.refreshChipLabels(); this.inputForm.markAsPristine(); this.inputForm.markAsUntouched(); this.showHistory = false; this.filteredHistory = []; this.results = null; - this.rememberLink(true); + this.saveQuery(); } async search() { @@ -157,7 +156,7 @@ export class SearchDictationPage implements OnInit, OnDestroy { if (this.keyword.value !== trimmed) { this.keyword.setValue(trimmed); } - this.rememberLink(true); + this.saveQuery(); await this.fetchResults(); } @@ -207,22 +206,22 @@ export class SearchDictationPage implements OnInit, OnDestroy { try { // History must be in memory before a search stores this keyword. await this.ensureHistory(); - const incoming = this.routeLink(); + const incoming = this.queryFromUrl(); // Same URL on the way back from a dictation: keep the list and its page. - if (this.appliedLink && this.sameLink(incoming, this.appliedLink)) { + if (this.query && this.sameQuery(incoming, this.query)) { return; } - const hadLink = this.appliedLink !== null; - this.keyword.setValue(incoming.q); + const hadQuery = this.query !== null; + this.keyword.setValue(incoming.keyword); this.suitableStudent.setValue(incoming.suitable); this.type.setValue(incoming.type); this.creator.setValue(''); - this.minDate.setValue(this.dateOptions[0]); + this.date.setValue(this.dateOptions[0]); this.refreshChipLabels(); this.showHistory = false; this.filteredHistory = []; - this.rememberLink(true); + this.saveQuery(); if (!this.inputForm.valid) { this.keyword.markAsDirty(); @@ -230,8 +229,8 @@ export class SearchDictationPage implements OnInit, OnDestroy { this.results = null; return; } - if (!this.hasCriteria(incoming)) { - if (hadLink) { + if (incoming.keyword.length === 0 && incoming.suitable === 'Any' && incoming.type === 'Any') { + if (hadQuery) { this.results = null; } return; @@ -249,7 +248,7 @@ export class SearchDictationPage implements OnInit, OnDestroy { this.addToHistory(this.keyword.value); this.dictationService.search({ keyword: this.keyword.value, - minDate: this.minDate.value.date, + minDate: this.date.value.date, creator: this.creator.value, suitableStudent: this.suitableStudent.value, type: this.type.value === 'Any' ? null : this.type.value, @@ -259,46 +258,50 @@ export class SearchDictationPage implements OnInit, OnDestroy { }, _e => loader.dismiss()); } - private routeLink(): SearchLink { + private queryFromUrl(): SearchQuery { const params = this.route.snapshot.queryParamMap; - return this.toLink(params.get('q'), params.get('suitable'), params.get('type')); + return this.toQuery(params.get('q'), params.get('suitable'), params.get('type')); } - private formLink(): SearchLink { - return this.toLink(this.keyword.value, this.suitableStudent.value, this.type.value); + private queryFromForm(): SearchQuery { + return this.toQuery(this.keyword.value, this.suitableStudent.value, this.type.value); } - private toLink(q: string | null, suitable: string | null, type: string | null): SearchLink { + private toQuery(keyword: string | null, suitable: string | null, type: string | null): SearchQuery { return { - q: (q ?? '').trim(), + keyword: (keyword ?? '').trim(), suitable: suitable && this.suitableStudentOptions.includes(suitable) ? suitable : 'Any', type: type && this.typeOptions.some(option => option.value === type) ? type : 'Any', }; } - private sameLink(left: SearchLink, right: SearchLink): boolean { - return left.q === right.q && left.suitable === right.suitable && left.type === right.type; + private sameQuery(left: SearchQuery, right: SearchQuery): boolean { + return left.keyword === right.keyword && left.suitable === right.suitable && left.type === right.type; } - private hasCriteria(link: SearchLink): boolean { - return link.q.length > 0 || link.suitable !== 'Any' || link.type !== 'Any'; + private saveQuery() { + const query = this.queryFromForm(); + this.query = query; + this.replaceUrl({ + q: query.keyword.length > 0 ? query.keyword : null, + suitable: query.suitable === 'Any' ? null : query.suitable, + type: query.type === 'Any' ? null : query.type, + }); } - private rememberLink(commitKeyword: boolean) { - this.appliedLink = this.formLink(); - this.replaceQueryParams(commitKeyword); + private updateSuitableAndType() { + const query = this.queryFromForm(); + this.replaceUrl({ + suitable: query.suitable === 'Any' ? null : query.suitable, + type: query.type === 'Any' ? null : query.type, + }); } - private replaceQueryParams(commitKeyword: boolean) { - const link = this.formLink(); - const queryParams: { q?: string | null; suitable: string | null; type: string | null } = { - suitable: link.suitable === 'Any' ? null : link.suitable, - type: link.type === 'Any' ? null : link.type, - }; - if (commitKeyword) { - queryParams.q = link.q.length > 0 ? link.q : null; - } - if (this.queryMatches(queryParams, commitKeyword)) { + private replaceUrl(queryParams: { q?: string | null; suitable: string | null; type: string | null }) { + const param = (key: string) => this.route.snapshot.queryParamMap.get(key) || null; + if ((!('q' in queryParams) || param('q') === (queryParams.q ?? null)) + && param('suitable') === queryParams.suitable + && param('type') === queryParams.type) { return; } // Same-page query update. NavigationService.navigate would push a history entry. @@ -310,22 +313,6 @@ export class SearchDictationPage implements OnInit, OnDestroy { }); } - private queryMatches( - next: { q?: string | null; suitable: string | null; type: string | null }, - includeKeyword: boolean, - ): boolean { - const current = this.route.snapshot.queryParamMap; - const same = (key: string, expected: string | null) => { - const actual = current.get(key); - const normalized = actual == null || actual === '' ? null : actual; - return normalized === expected; - }; - if (includeKeyword && !same('q', next.q ?? null)) { - return false; - } - return same('suitable', next.suitable) && same('type', next.type); - } - private addToHistory(value: String) { const term = `${value ?? ''}`.trim(); if (term.length === 0 || this.history.some(h => h == term)) {