Skip to content

Commit 8bee5c4

Browse files
fix(author-browser): convert AuthorMatchComponent state to signals
Resolves the author manual search loading spinner that never resolved without an additional UI interaction to trigger change detection. Converted all mutable component state to signals: - searchQuery, asinQuery, selectedRegion, searching, matching, results, hasSearched → signal() - canSearch → computed() Updated template to read signals via () invocation and replaced two-way [(ngModel)] bindings with [ngModel] + (ngModelChange) to write back to signals. Updated spec to use signal accessors (signal()) and setters (signal.set()) throughout. Closes #1556 Signed-off-by: Kunta Mallik Raj <kuntamallikraj@gmail.com>
1 parent 49a7593 commit 8bee5c4

3 files changed

Lines changed: 67 additions & 68 deletions

File tree

frontend/src/app/features/author-browser/components/author-match/author-match.component.html

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,19 @@
44
<div class="search-fields">
55
<div class="field">
66
<label for="author-match-name">{{ t('authorNameLabel') }}</label>
7-
<input id="author-match-name" pInputText type="text" [(ngModel)]="searchQuery" [placeholder]="t('authorNamePlaceholder')" class="search-input"/>
7+
<input id="author-match-name" pInputText type="text" [ngModel]="searchQuery()" (ngModelChange)="searchQuery.set($event)" [placeholder]="t('authorNamePlaceholder')" class="search-input"/>
88
</div>
99
<div class="field">
1010
<label for="author-match-asin">{{ t('asinLabel') }}</label>
11-
<input id="author-match-asin" pInputText type="text" [(ngModel)]="asinQuery" [placeholder]="t('asinPlaceholder')" class="search-input"/>
11+
<input id="author-match-asin" pInputText type="text" [ngModel]="asinQuery()" (ngModelChange)="asinQuery.set($event)" [placeholder]="t('asinPlaceholder')" class="search-input"/>
1212
</div>
1313
<div class="field region-field">
1414
<label for="author-match-region">{{ t('regionLabel') }}</label>
1515
<p-select
1616
inputId="author-match-region"
1717
[options]="regionOptions"
18-
[(ngModel)]="selectedRegion"
18+
[ngModel]="selectedRegion()"
19+
(ngModelChange)="selectedRegion.set($event)"
1920
optionLabel="label"
2021
optionValue="value"
2122
class="region-dropdown">
@@ -26,14 +27,14 @@
2627
[label]="t('searchBtn')"
2728
icon="pi pi-search"
2829
(onClick)="search()"
29-
[loading]="searching"
30-
[disabled]="!canSearch">
30+
[loading]="searching()"
31+
[disabled]="!canSearch()">
3132
</p-button>
3233
</div>
3334
</div>
3435
</div>
3536

36-
@if (searching) {
37+
@if (searching()) {
3738
<div class="search-loading">
3839
<p-progressSpinner
3940
[style]="{'width': '40px', 'height': '40px'}"
@@ -45,28 +46,28 @@
4546
</div>
4647
}
4748

48-
@if (!searching && hasSearched && results.length === 0) {
49+
@if (!searching() && hasSearched() && results().length === 0) {
4950
<div class="empty-results">
5051
<i class="pi pi-info-circle"></i>
5152
<p>{{ t('noResults') }}</p>
5253
<p class="hint">{{ t('noResultsHint') }}</p>
5354
</div>
5455
}
5556

56-
@if (!searching && !hasSearched) {
57+
@if (!searching() && !hasSearched()) {
5758
<div class="ready-state">
5859
<i class="pi pi-search"></i>
5960
<p>{{ t('readyToSearch') }}</p>
6061
<p class="hint">{{ t('readyToSearchHint') }}</p>
6162
</div>
6263
}
6364

64-
@if (!searching && results.length > 0) {
65+
@if (!searching() && results().length > 0) {
6566
<div class="results-header">
66-
<span>{{ t('resultsFound', {count: results.length}) }}</span>
67+
<span>{{ t('resultsFound', {count: results().length}) }}</span>
6768
</div>
6869
<div class="results-list">
69-
@for (result of results; track result.asin) {
70+
@for (result of results(); track result.asin) {
7071
<div class="result-card">
7172
<div class="result-image">
7273
@if (result.imageUrl) {
@@ -89,7 +90,7 @@ <h4 class="result-name">{{ result.name }}</h4>
8990
[label]="t('matchBtn')"
9091
icon="pi pi-link"
9192
severity="success"
92-
[loading]="matching"
93+
[loading]="matching()"
9394
(onClick)="matchAuthor(result)">
9495
</p-button>
9596
</div>

frontend/src/app/features/author-browser/components/author-match/author-match.component.spec.ts

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -62,70 +62,70 @@ describe('AuthorMatchComponent', () => {
6262

6363
component.ngOnInit();
6464

65-
expect(component.searchQuery).toBe(' Ada Lovelace ');
66-
expect(component.canSearch).toBe(true);
65+
expect(component.searchQuery()).toBe(' Ada Lovelace ');
66+
expect(component.canSearch()).toBe(true);
6767

68-
component.searchQuery = ' ';
69-
component.asinQuery = ' ';
70-
expect(component.canSearch).toBe(false);
68+
component.searchQuery.set(' ');
69+
component.asinQuery.set(' ');
70+
expect(component.canSearch()).toBe(false);
7171

72-
component.asinQuery = ' B00TEST ';
73-
expect(component.canSearch).toBe(true);
72+
component.asinQuery.set(' B00TEST ');
73+
expect(component.canSearch()).toBe(true);
7474
});
7575

7676
it('does not search when both query inputs are blank after trimming', () => {
7777
const component = createComponent();
78-
component.searchQuery = ' ';
79-
component.asinQuery = ' ';
80-
component.results = [{source: 'seed', asin: 'old', name: 'Existing'}];
78+
component.searchQuery.set(' ');
79+
component.asinQuery.set(' ');
80+
component.results.set([{source: 'seed', asin: 'old', name: 'Existing'}]);
8181

8282
component.search();
8383

8484
expect(searchAuthorMetadata).not.toHaveBeenCalled();
85-
expect(component.searching).toBe(false);
86-
expect(component.hasSearched).toBe(false);
87-
expect(component.results).toEqual([{source: 'seed', asin: 'old', name: 'Existing'}]);
85+
expect(component.searching()).toBe(false);
86+
expect(component.hasSearched()).toBe(false);
87+
expect(component.results()).toEqual([{source: 'seed', asin: 'old', name: 'Existing'}]);
8888
});
8989

9090
it('searches with trimmed values, clears stale results, and stores returned matches', () => {
9191
const results$ = new Subject<AuthorSearchResult[]>();
9292
searchAuthorMetadata.mockReturnValue(results$);
9393

9494
const component = createComponent();
95-
component.searchQuery = ' Ada ';
96-
component.asinQuery = ' B00MATCH ';
97-
component.selectedRegion = 'uk';
98-
component.results = [{source: 'seed', asin: 'old', name: 'Existing'}];
95+
component.searchQuery.set(' Ada ');
96+
component.asinQuery.set(' B00MATCH ');
97+
component.selectedRegion.set('uk');
98+
component.results.set([{source: 'seed', asin: 'old', name: 'Existing'}]);
9999

100100
component.search();
101101

102102
expect(searchAuthorMetadata).toHaveBeenCalledWith(9, 'Ada', 'uk', 'B00MATCH');
103-
expect(component.searching).toBe(true);
104-
expect(component.hasSearched).toBe(true);
105-
expect(component.results).toEqual([]);
103+
expect(component.searching()).toBe(true);
104+
expect(component.hasSearched()).toBe(true);
105+
expect(component.results()).toEqual([]);
106106

107107
results$.next([
108108
{source: 'amazon', asin: 'B00MATCH', name: 'Ada Lovelace'},
109109
{source: 'amazon', asin: 'B00OTHER', name: 'A. Lovelace'},
110110
]);
111111
results$.complete();
112112

113-
expect(component.results).toEqual([
113+
expect(component.results()).toEqual([
114114
{source: 'amazon', asin: 'B00MATCH', name: 'Ada Lovelace'},
115115
{source: 'amazon', asin: 'B00OTHER', name: 'A. Lovelace'},
116116
]);
117-
expect(component.searching).toBe(false);
117+
expect(component.searching()).toBe(false);
118118
});
119119

120120
it('reports a translated search failure toast when metadata search errors', () => {
121121
searchAuthorMetadata.mockReturnValue(throwError(() => new Error('boom')));
122122

123123
const component = createComponent();
124-
component.searchQuery = 'Ada';
124+
component.searchQuery.set('Ada');
125125

126126
component.search();
127127

128-
expect(component.searching).toBe(false);
128+
expect(component.searching()).toBe(false);
129129
expect(translate).toHaveBeenCalledWith('authorBrowser.match.toast.searchFailedSummary');
130130
expect(translate).toHaveBeenCalledWith('authorBrowser.match.toast.searchFailedDetail');
131131
expect(messageService.add).toHaveBeenCalledWith({
@@ -150,7 +150,7 @@ describe('AuthorMatchComponent', () => {
150150
matchAuthor.mockReturnValue(match$);
151151

152152
const component = createComponent();
153-
component.selectedRegion = 'de';
153+
component.selectedRegion.set('de');
154154
const emitSpy = vi.spyOn(component.authorMatched, 'emit');
155155

156156
component.matchAuthor({
@@ -165,12 +165,12 @@ describe('AuthorMatchComponent', () => {
165165
asin: 'B00MATCH',
166166
region: 'de',
167167
});
168-
expect(component.matching).toBe(true);
168+
expect(component.matching()).toBe(true);
169169

170170
match$.next(updatedAuthor);
171171
match$.complete();
172172

173-
expect(component.matching).toBe(false);
173+
expect(component.matching()).toBe(false);
174174
expect(translate).toHaveBeenCalledWith('authorBrowser.match.toast.matchSuccessSummary');
175175
expect(translate).toHaveBeenCalledWith('authorBrowser.match.toast.matchSuccessDetail');
176176
expect(messageService.add).toHaveBeenCalledWith({
@@ -193,7 +193,7 @@ describe('AuthorMatchComponent', () => {
193193
name: 'Ada Lovelace',
194194
});
195195

196-
expect(component.matching).toBe(false);
196+
expect(component.matching()).toBe(false);
197197
expect(translate).toHaveBeenCalledWith('authorBrowser.match.toast.matchFailedSummary');
198198
expect(translate).toHaveBeenCalledWith('authorBrowser.match.toast.matchFailedDetail');
199199
expect(messageService.add).toHaveBeenCalledWith({

frontend/src/app/features/author-browser/components/author-match/author-match.component.ts

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Component, EventEmitter, inject, Input, OnInit, Output} from '@angular/core';
1+
import {Component, computed, EventEmitter, inject, Input, OnInit, Output, signal} from '@angular/core';
22
import {FormsModule} from '@angular/forms';
33
import {Button} from 'primeng/button';
44
import {InputText} from 'primeng/inputtext';
@@ -38,13 +38,15 @@ export class AuthorMatchComponent implements OnInit {
3838
private messageService = inject(MessageService);
3939
private t = inject(TranslocoService);
4040

41-
searchQuery = '';
42-
asinQuery = '';
43-
selectedRegion = 'us';
44-
searching = false;
45-
matching = false;
46-
results: AuthorSearchResult[] = [];
47-
hasSearched = false;
41+
searchQuery = signal('');
42+
asinQuery = signal('');
43+
selectedRegion = signal('us');
44+
searching = signal(false);
45+
matching = signal(false);
46+
results = signal<AuthorSearchResult[]>([]);
47+
hasSearched = signal(false);
48+
49+
canSearch = computed(() => !!this.searchQuery().trim() || !!this.asinQuery().trim());
4850

4951
regionOptions: RegionOption[] = [
5052
{label: 'US', value: 'us'},
@@ -60,29 +62,25 @@ export class AuthorMatchComponent implements OnInit {
6062
];
6163

6264
ngOnInit(): void {
63-
this.searchQuery = this.authorName;
64-
}
65-
66-
get canSearch(): boolean {
67-
return !!this.searchQuery.trim() || !!this.asinQuery.trim();
65+
this.searchQuery.set(this.authorName);
6866
}
6967

7068
search(): void {
71-
const asin = this.asinQuery.trim();
72-
const query = this.searchQuery.trim();
69+
const asin = this.asinQuery().trim();
70+
const query = this.searchQuery().trim();
7371
if (!query && !asin) return;
74-
this.searching = true;
75-
this.results = [];
76-
this.hasSearched = true;
72+
this.searching.set(true);
73+
this.results.set([]);
74+
this.hasSearched.set(true);
7775

78-
this.authorService.searchAuthorMetadata(this.authorId, query, this.selectedRegion, asin || undefined)
76+
this.authorService.searchAuthorMetadata(this.authorId, query, this.selectedRegion(), asin || undefined)
7977
.subscribe({
80-
next: (results) => {
81-
this.results = results;
82-
this.searching = false;
78+
next: (results: AuthorSearchResult[]) => {
79+
this.results.set(results);
80+
this.searching.set(false);
8381
},
8482
error: () => {
85-
this.searching = false;
83+
this.searching.set(false);
8684
this.messageService.add({
8785
severity: 'error',
8886
summary: this.t.translate('authorBrowser.match.toast.searchFailedSummary'),
@@ -94,16 +92,16 @@ export class AuthorMatchComponent implements OnInit {
9492
}
9593

9694
matchAuthor(result: AuthorSearchResult): void {
97-
this.matching = true;
95+
this.matching.set(true);
9896
const request: AuthorMatchRequest = {
9997
source: result.source,
10098
asin: result.asin,
101-
region: this.selectedRegion
99+
region: this.selectedRegion()
102100
};
103101

104102
this.authorService.matchAuthor(this.authorId, request).subscribe({
105-
next: (updatedAuthor) => {
106-
this.matching = false;
103+
next: (updatedAuthor: AuthorDetails) => {
104+
this.matching.set(false);
107105
this.messageService.add({
108106
severity: 'success',
109107
summary: this.t.translate('authorBrowser.match.toast.matchSuccessSummary'),
@@ -113,7 +111,7 @@ export class AuthorMatchComponent implements OnInit {
113111
this.authorMatched.emit(updatedAuthor);
114112
},
115113
error: () => {
116-
this.matching = false;
114+
this.matching.set(false);
117115
this.messageService.add({
118116
severity: 'error',
119117
summary: this.t.translate('authorBrowser.match.toast.matchFailedSummary'),

0 commit comments

Comments
 (0)