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.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 54ff71e1..f6e9020a 100644 --- a/src/app/pages/search-dictation/search-dictation.page.spec.ts +++ b/src/app/pages/search-dictation/search-dictation.page.spec.ts @@ -1,6 +1,7 @@ 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'; @@ -14,14 +15,24 @@ 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; 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(); @@ -176,9 +189,14 @@ 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); + 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,148 @@ 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.date.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: '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).toHaveBeenCalledWith([], jasmine.objectContaining({ + replaceUrl: true, + queryParams: { q: 'ab', suitable: null, type: null }, + })); + })); + + 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('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' }; + 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 +481,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..4580b727 100644 --- a/src/app/pages/search-dictation/search-dictation.page.ts +++ b/src/app/pages/search-dictation/search-dictation.page.ts @@ -1,8 +1,9 @@ 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, 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'; @@ -13,6 +14,12 @@ export interface DateSearchOption { date?: Date; } +interface SearchQuery { + keyword: string; + suitable: string; + type: string; +} + @Component({ selector: 'app-search-dictation', templateUrl: './search-dictation.page.html', @@ -40,6 +47,10 @@ export class SearchDictationPage implements OnInit, OnDestroy { typeChipLabel = ''; dateChipLabel = ''; private langChange: Subscription; + private historyReady: Promise | null = null; + /** q / suitable / type last read from or written to the URL. */ + private query: SearchQuery | null = null; + private applyingQuery = false; constructor( public formBuilder: UntypedFormBuilder, @@ -47,6 +58,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, ) { } @@ -61,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; } @@ -73,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', @@ -81,24 +93,28 @@ 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); + chooseSuitable(option: string) { + this.suitableStudent.setValue(option); this.refreshChipLabels(); + this.updateSuitableAndType(); } chooseType(value: string) { this.type.setValue(value); this.refreshChipLabels(); + this.updateSuitableAndType(); } chooseDate(option: DateSearchOption) { - this.minDate.setValue(option); + this.date.setValue(option); this.refreshChipLabels(); } @@ -106,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(); @@ -125,30 +141,23 @@ 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.saveQuery(); } async search() { - 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()); + const trimmed = `${this.keyword.value ?? ''}`.trim(); + if (this.keyword.value !== trimmed) { + this.keyword.setValue(trimmed); + } + this.saveQuery(); + await this.fetchResults(); } createDateOptions(): DateSearchOption[] { @@ -180,10 +189,137 @@ 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 a search stores this keyword. + await this.ensureHistory(); + const incoming = this.queryFromUrl(); + // Same URL on the way back from a dictation: keep the list and its page. + if (this.query && this.sameQuery(incoming, this.query)) { + return; + } + + const hadQuery = this.query !== null; + this.keyword.setValue(incoming.keyword); + this.suitableStudent.setValue(incoming.suitable); + this.type.setValue(incoming.type); + this.creator.setValue(''); + this.date.setValue(this.dateOptions[0]); + this.refreshChipLabels(); + this.showHistory = false; + this.filteredHistory = []; + this.saveQuery(); + + if (!this.inputForm.valid) { + this.keyword.markAsDirty(); + this.keyword.markAsTouched(); + this.results = null; + return; + } + if (incoming.keyword.length === 0 && incoming.suitable === 'Any' && incoming.type === 'Any') { + if (hadQuery) { + this.results = null; + } + return; + } + await this.fetchResults(); + } finally { + this.applyingQuery = false; + } + } + + 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.date.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 queryFromUrl(): SearchQuery { + const params = this.route.snapshot.queryParamMap; + return this.toQuery(params.get('q'), params.get('suitable'), params.get('type')); + } + + private queryFromForm(): SearchQuery { + return this.toQuery(this.keyword.value, this.suitableStudent.value, this.type.value); + } + + private toQuery(keyword: string | null, suitable: string | null, type: string | null): SearchQuery { + return { + keyword: (keyword ?? '').trim(), + suitable: suitable && this.suitableStudentOptions.includes(suitable) ? suitable : 'Any', + type: type && this.typeOptions.some(option => option.value === type) ? type : 'Any', + }; + } + + private sameQuery(left: SearchQuery, right: SearchQuery): boolean { + return left.keyword === right.keyword && left.suitable === right.suitable && left.type === right.type; + } + + 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 updateSuitableAndType() { + const query = this.queryFromForm(); + this.replaceUrl({ + suitable: query.suitable === 'Any' ? null : query.suitable, + type: query.type === 'Any' ? null : query.type, + }); + } + + 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. + void this.router.navigate([], { + relativeTo: this.route, + replaceUrl: true, + queryParamsHandling: 'merge', + queryParams, + }); + } + private addToHistory(value: String) { - if (this.history.find(h => h == value)) { return; } + const term = `${value ?? ''}`.trim(); + if (term.length === 0 || this.history.some(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); } 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', + })); + }); });