refactor: migrate field widget components from RxJS to Angular signals#1581
Conversation
Replace RxJS patterns (Subject, Observable, subscribe, pipe operators) with Angular signals (signal, computed, toSignal, effect) in 5 field widget components: language, country, phone, foreign-key edit, and foreign-key filter. Also replace any types with proper interfaces throughout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR refactors several field widget components to replace RxJS-based streams/subscriptions with Angular signals (signal, computed, toSignal, effect) and tightens types by replacing any with more specific interfaces.
Changes:
- Migrated autocomplete/filtering logic from
Observable+asyncpipe to signals +computed()in phone/language/country widgets. - Refactored foreign-key edit/filter widgets to use signals for state (
suggestions,fetching, debounced search term) andfirstValueFromfor async fetching. - Updated unit tests to accommodate signal-based state and async initialization.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.ts | Replaces filteredCountries$ observable with filteredCountries computed signal derived from FormControl.valueChanges. |
| frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.html | Switches to @for + signal invocation (filteredCountries()) and @if blocks. |
| frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.spec.ts | Adjusts typings/casts for updated FormControl value types and widget structure typing. |
| frontend/src/app/components/ui-components/record-edit-fields/language/language.component.ts | Replaces RxJS pipeline with toSignal + computed; adds an effect to keep the flag in sync. |
| frontend/src/app/components/ui-components/record-edit-fields/language/language.component.html | Uses filteredLanguages() instead of `filteredLanguages |
| frontend/src/app/components/ui-components/record-edit-fields/country/country.component.ts | Converts country autocomplete and flag selection logic to signals/computed, removing RxJS. |
| frontend/src/app/components/ui-components/record-edit-fields/country/country.component.html | Uses signals (selectedCountryFlag(), showFlag(), filteredCountries()) and @for/@if. |
| frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.ts | Migrates suggestions/fetching state to signals and introduces debounced search via signals + rxjs-interop. |
| frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.html | Uses fetching()/suggestions() signals and @if/@for syntax. |
| frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.spec.ts | Updates assertions and setup for signal-based state and async component init. |
| frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.ts | Same signal-based migration for the filter variant (debounced search + signal state). |
| frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.html | Uses fetching()/suggestions() and @if/@for syntax. |
| frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.spec.ts | Updates assertions and init flow for signal-based state and async init. |
Comments suppressed due to low confidence (1)
frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.html:19
- The template disables options when
suggestion.displayString === 'No matches', but the component actually sets the empty-result message toNo field starts with "..." in foreign entity.. As a result, the "no results" option won't get the disabled styling/behavior. Align the check with the actual message (or add a dedicated boolean likesuggestion.isPlaceholder).
<mat-option
[ngClass]="{'disabled': suggestion.displayString === 'No matches'}"
[value]="suggestion.displayString">
{{suggestion.displayString}}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| effect(() => { | ||
| const _term = this._debouncedTerm(); | ||
| if (this.currentDisplayedString === '') this.onFieldChange.emit(null); | ||
| this.fetchSuggestions(); | ||
| }); |
There was a problem hiding this comment.
The effect() in the constructor runs immediately, before Angular sets @Input()s like relations/widgetStructure and before ngOnInit initializes fkRelations/currentDisplayedString. As written, it calls fetchSuggestions() right away, which can dereference this.fkRelations while it's still null/undefined and trigger a failing fetch with an undefined search term. Move this effect setup to ngOnInit after fkRelations is set, or guard the effect (if (!this.fkRelations) return; and ensure currentDisplayedString defaults to '').
| console.log(this.label + 'this.currentFieldValue'); | ||
| console.log(this.currentFieldValue); | ||
| this.onFieldChange.emit(this.currentFieldValue); |
There was a problem hiding this comment.
Leftover debug logging in fetchSuggestions() (console.log(...)) will spam the console during normal autocomplete usage and can leak values into logs. Remove these logs or replace with a properly gated logger/debug flag if needed.
| this.currentFieldQueryParams = this.suggestions().find( | ||
| (suggestion) => suggestion.displayString === e.option.value, | ||
| ).primaryKeys; |
There was a problem hiding this comment.
updateRelatedLink assumes find(...) always returns a suggestion and immediately dereferences .primaryKeys. If the selected value isn't found (or if the "no results" placeholder is selected), this will throw at runtime. Add a null-check around the find result and only update currentFieldQueryParams when primaryKeys is present.
| this.currentFieldQueryParams = this.suggestions().find( | |
| (suggestion) => suggestion.displayString === e.option.value, | |
| ).primaryKeys; | |
| const selectedSuggestion = this.suggestions().find( | |
| (suggestion) => suggestion.displayString === e.option.value, | |
| ); | |
| if (selectedSuggestion && selectedSuggestion.primaryKeys) { | |
| this.currentFieldQueryParams = selectedSuggestion.primaryKeys; | |
| } |
| effect(() => { | ||
| const _term = this._debouncedTerm(); | ||
| if (this.currentDisplayedString === '') this.onFieldChange.emit(null); | ||
| this.fetchSuggestions(); | ||
| }); |
There was a problem hiding this comment.
The effect() in the constructor runs immediately, before Angular sets @Input()s like relations and before ngOnInit runs. It calls fetchSuggestions() right away, which can dereference this.relations while it's still undefined and trigger a failing fetch with an undefined search term. Move this effect setup to ngOnInit after inputs are available, or guard the effect (if (!this.relations) return; and ensure currentDisplayedString defaults to '').
| </mat-option> | ||
| @for (suggestion of suggestions(); track suggestion.fieldValue) { | ||
| <mat-option | ||
| [ngClass]="{'disabled': suggestion.displayString === 'No matches'}" |
There was a problem hiding this comment.
The template disables options when suggestion.displayString === 'No matches', but the component sets the empty-result message to No field starts with "..." in foreign entity.. This means the "no results" option won't be disabled/styled as intended. Align the check with the actual message or add an explicit flag on the suggestion.
| [ngClass]="{'disabled': suggestion.displayString === 'No matches'}" | |
| [ngClass]="{'disabled': suggestion.displayString?.startsWith('No field starts with')}" |
| this.currentFieldQueryParams = this.suggestions().find( | ||
| (suggestion) => suggestion.displayString === e.option.value, | ||
| ).primaryKeys; |
There was a problem hiding this comment.
updateRelatedLink assumes find(...) always returns a suggestion and immediately dereferences .primaryKeys. If the selected value isn't found (or if the "no results" placeholder is selected), this will throw at runtime. Add a null-check around the find result and only update currentFieldQueryParams when primaryKeys is present.
| this.currentFieldQueryParams = this.suggestions().find( | |
| (suggestion) => suggestion.displayString === e.option.value, | |
| ).primaryKeys; | |
| const selectedSuggestion = this.suggestions().find( | |
| (suggestion) => suggestion.displayString === e.option.value, | |
| ); | |
| if (selectedSuggestion && selectedSuggestion.primaryKeys) { | |
| this.currentFieldQueryParams = selectedSuggestion.primaryKeys; | |
| } |
| @@ -185,20 +186,21 @@ describe('ForeignKeyFilterComponent', () => { | |||
| ]); | |||
| }); | |||
|
|
|||
| it('should fill initial dropdown values when identity_column is not set', () => { | |||
| it('should fill initial dropdown values when identity_column is not set', async () => { | |||
| vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork)); | |||
|
|
|||
| component.connectionID = '12345678'; | |||
|
|
|||
| component.value = '33'; // Must be truthy to trigger currentDisplayedString setting | |||
|
|
|||
| fixture.detectChanges(); // This triggers ngOnInit | |||
| await component.ngOnInit(); | |||
| fixture.detectChanges(); | |||
|
|
|||
There was a problem hiding this comment.
Tests are manually calling ngOnInit() and then calling fixture.detectChanges(). Since detectChanges() also runs lifecycle hooks, ngOnInit can execute twice, causing duplicate fetchTable calls and making tests flaky. Prefer fixture.detectChanges(); await fixture.whenStable(); without manual ngOnInit(), or avoid calling detectChanges() after a manual init.
| ); | ||
| } | ||
|
|
||
| displayCountryFn(country: CountryCode): string { |
There was a problem hiding this comment.
displayCountryFn is used as mat-autocomplete's displayWith, and the form control value can be a string while the user is typing (the control is typed as CountryCode | string | null). With the current signature, Angular Material may call displayCountryFn with a string, causing runtime errors when reading country.flag/name/dialCode. Update the function to accept CountryCode | string | null and return the string value unchanged when a string is provided.
| displayCountryFn(country: CountryCode): string { | |
| displayCountryFn(country: CountryCode | string | null): string { | |
| if (typeof country === 'string') { | |
| return country; | |
| } |
|
|
||
| component.ngOnInit(); | ||
| await component.ngOnInit(); | ||
| fixture.detectChanges(); |
There was a problem hiding this comment.
Tests are manually calling ngOnInit() and then calling fixture.detectChanges(). detectChanges() will also run Angular lifecycle hooks, so ngOnInit can end up executing twice, leading to duplicate fetchTable calls and brittle assertions. Prefer triggering init via fixture.detectChanges(); await fixture.whenStable(); (and remove the explicit ngOnInit() call), or avoid calling detectChanges() after a manual ngOnInit() call.
| fixture.detectChanges(); |
Replace auto-generated credentials documentation with setup page flow, as RocketAdmin now redirects to a setup page for initial admin account creation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…d for flag derivation The selectedFlag was a writable signal updated via effect(), but it's purely derived from the form control value — computed() is the correct primitive here. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace RxJS patterns (Subject, Observable, subscribe, pipe operators) with Angular signals (signal, computed, toSignal, effect) in 5 field widget components: language, country, phone, foreign-key edit, and foreign-key filter. Also replace any types with proper interfaces throughout.