From 8e5f46810e92a1cc7b0217156b89d92f66b2ec72 Mon Sep 17 00:00:00 2001 From: Nic McPhee Date: Tue, 24 Sep 2024 09:02:27 -0500 Subject: [PATCH] Incorporate Michael Small's improvements These bring in a host of improvements that @michael-small provided for the iteration template. Most of these get us so we're using signals properly, but there are some other small cleanups as well. --- client/cypress/e2e/add-user.cy.ts | 9 +- client/src/app/app.component.html | 2 +- client/src/app/app.component.scss | 4 +- .../company-card/company-card.component.html | 8 +- .../company-list/company-list.component.html | 4 +- .../company-list/company-list.component.ts | 8 +- client/src/app/users/user-card.component.html | 26 +-- .../src/app/users/user-card.component.spec.ts | 31 ++- client/src/app/users/user-card.component.ts | 8 +- client/src/app/users/user-list.component.html | 18 +- client/src/app/users/user-list.component.scss | 2 +- .../src/app/users/user-list.component.spec.ts | 40 ++-- client/src/app/users/user-list.component.ts | 195 +++++++++--------- .../src/app/users/user-profile.component.html | 8 +- .../app/users/user-profile.component.spec.ts | 22 +- .../src/app/users/user-profile.component.ts | 81 +++----- 16 files changed, 212 insertions(+), 254 deletions(-) diff --git a/client/cypress/e2e/add-user.cy.ts b/client/cypress/e2e/add-user.cy.ts index eb54e4c..f57d86e 100644 --- a/client/cypress/e2e/add-user.cy.ts +++ b/client/cypress/e2e/add-user.cy.ts @@ -92,8 +92,11 @@ describe('Add user', () => { page.addUser(user); - // New URL should end in the 24 hex character Mongo ID of the newly added user - cy.url() + // New URL should end in the 24 hex character Mongo ID of the newly added user. + // We'll wait up to 10 seconds for this these `should()` assertions to succeed. + // Hopefully that long timeout will help ensure that our Cypress tests pass in + // GitHub Actions, where we're often running on slow VMs. + cy.url({ timeout: 10000 }) .should('match', /\/users\/[0-9a-fA-F]{24}$/) .should('not.match', /\/users\/new$/); @@ -115,7 +118,7 @@ describe('Add user', () => { age: 30, company: null, // The company being set to null means nothing will be typed for it email: 'test@example.com', - role: 'editor' + role: 'editor', }; page.addUser(user); diff --git a/client/src/app/app.component.html b/client/src/app/app.component.html index 9f4415a..8b1b4e4 100644 --- a/client/src/app/app.component.html +++ b/client/src/app/app.component.html @@ -29,7 +29,7 @@ {{title}} diff --git a/client/src/app/app.component.scss b/client/src/app/app.component.scss index 9a1b991..62d3504 100644 --- a/client/src/app/app.component.scss +++ b/client/src/app/app.component.scss @@ -6,11 +6,11 @@ width: 256px; } -.sidenav .mat-toolbar { +.sidenav mat-toolbar { background: inherit; } -.mat-toolbar.mat-primary { +mat-toolbar.mat-primary { position: sticky; top: 0; z-index: 1; diff --git a/client/src/app/company-card/company-card.component.html b/client/src/app/company-card/company-card.component.html index 6410b2b..448b5d2 100644 --- a/client/src/app/company-card/company-card.component.html +++ b/client/src/app/company-card/company-card.component.html @@ -1,14 +1,14 @@ -@if (this.company) { +@if (company) { - {{ this.company._id }} + {{ company._id }} - ({{ this.company.count }} {{ (this.company.count === 1) ? 'employee' : 'employees' }}) + ({{ company.count }} {{ (company.count === 1) ? 'employee' : 'employees' }})
    - @for (user of this.company.users; track user._id) { + @for (user of company.users; track user._id) {
  • {{ user.name }}
  • }
diff --git a/client/src/app/company-list/company-list.component.html b/client/src/app/company-list/company-list.component.html index 34458df..8eba3db 100644 --- a/client/src/app/company-list/company-list.component.html +++ b/client/src/app/company-list/company-list.component.html @@ -2,10 +2,10 @@

Companies

- @if (this.companies()) { + @if (companies()) {
- @for (company of this.companies(); track company._id) { + @for (company of companies(); track company._id) { }
diff --git a/client/src/app/company-list/company-list.component.ts b/client/src/app/company-list/company-list.component.ts index 3bacffa..4fa33cc 100644 --- a/client/src/app/company-list/company-list.component.ts +++ b/client/src/app/company-list/company-list.component.ts @@ -1,4 +1,4 @@ -import { Component, Signal } from '@angular/core'; +import { Component, inject, Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { CompanyCardComponent } from '../company-card/company-card.component'; import { UserService } from '../users/user.service'; @@ -12,9 +12,7 @@ import { Company } from './company'; styleUrl: './company-list.component.scss' }) export class CompanyListComponent { - companies: Signal; + private userService = inject(UserService); - constructor(private userService: UserService) { - this.companies = toSignal(this.userService.getCompanies()); - } + companies: Signal = toSignal(this.userService.getCompanies()); } diff --git a/client/src/app/users/user-card.component.html b/client/src/app/users/user-card.component.html index bb09810..9e06d04 100644 --- a/client/src/app/users/user-card.component.html +++ b/client/src/app/users/user-card.component.html @@ -1,13 +1,13 @@ -@if (this.user) { +@if (user()) { - @if (this.user.avatar) { - Avatar for {{this.user.name}} + @if (user().avatar) { + Avatar for {{user().name}} } - {{ this.user.name }} - {{ this.user.company }} + {{ user().name }} + {{ user().company }} - @if (!this.simple) { + @if (!simple()) { @@ -17,27 +17,27 @@ We had to put both the `role` and `age` entries on a single line. Otherwise we got an annoying leading space in front of the values. --> -

{{ this.user.role }}

+

{{ user().role }}

schedule

Age

-

{{ this.user.age }}

+

{{ user().age }}

- mail + mail

Email

- {{this.user.email}} - open_in_new + {{user().email}} + open_in_new
} - @if (this.simple) { + @if (simple()) { - + }
diff --git a/client/src/app/users/user-card.component.spec.ts b/client/src/app/users/user-card.component.spec.ts index 1f34d2f..b12c1c8 100644 --- a/client/src/app/users/user-card.component.spec.ts +++ b/client/src/app/users/user-card.component.spec.ts @@ -3,6 +3,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { UserCardComponent } from './user-card.component'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { MatCardModule } from '@angular/material/card'; +import { input } from '@angular/core'; describe('UserCardComponent', () => { let component: UserCardComponent; @@ -10,27 +11,25 @@ describe('UserCardComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [ - BrowserAnimationsModule, - MatCardModule, - UserCardComponent - ] -}) - .compileComponents(); + imports: [BrowserAnimationsModule, MatCardModule, UserCardComponent], + }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(UserCardComponent); component = fixture.componentInstance; - component.user = { - _id: 'chris_id', - name: 'Chris', - age: 25, - company: 'UMM', - email: 'chris@this.that', - role: 'admin', - avatar: 'https://gravatar.com/avatar/8c9616d6cc5de638ea6920fb5d65fc6c?d=identicon' - }; + TestBed.runInInjectionContext(() => { + component.user = input({ + _id: 'chris_id', + name: 'Chris', + age: 25, + company: 'UMM', + email: 'chris@this.that', + role: 'admin', + avatar: + 'https://gravatar.com/avatar/8c9616d6cc5de638ea6920fb5d65fc6c?d=identicon', + }); + }); fixture.detectChanges(); }); diff --git a/client/src/app/users/user-card.component.ts b/client/src/app/users/user-card.component.ts index 392a6d6..8122441 100644 --- a/client/src/app/users/user-card.component.ts +++ b/client/src/app/users/user-card.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from '@angular/core'; +import { Component, input } from '@angular/core'; import { User } from './user'; import { RouterLink } from '@angular/router'; import { MatButtonModule } from '@angular/material/button'; @@ -15,8 +15,6 @@ import { MatIconModule } from '@angular/material/icon'; imports: [MatCardModule, MatButtonModule, MatListModule, MatIconModule, RouterLink] }) export class UserCardComponent { - - @Input() user: User; - @Input() simple?: boolean = false; - + user = input.required(); + simple = input(false); } diff --git a/client/src/app/users/user-list.component.html b/client/src/app/users/user-list.component.html index 6197ba4..88838b6 100644 --- a/client/src/app/users/user-list.component.html +++ b/client/src/app/users/user-list.component.html @@ -13,14 +13,14 @@ Name + [(ngModel)]="userName"> Filtered on client Company + [(ngModel)]="userCompany"> Filtered on client
@@ -31,13 +31,13 @@ Age + min="0" max="200" [(ngModel)]="userAge"> Filtered on server Role - + -- Admin Editor @@ -62,15 +62,15 @@
- @if (serverFilteredUsers) { + @if (serverFilteredUsers()) {
- @switch (viewType) { + @switch (viewType()) { @case ('card') {
- @for (user of filteredUsers; track user._id) { + @for (user of filteredUsers(); track user._id) { }
@@ -81,9 +81,9 @@

Users

- @for (user of this.filteredUsers; track user._id) { + @for (user of filteredUsers(); track user._id) { - @if (this.user.avatar) { + @if (user.avatar) { Avatar for {{ user.name }} } {{user.name}} diff --git a/client/src/app/users/user-list.component.scss b/client/src/app/users/user-list.component.scss index 356eaba..c977b1b 100644 --- a/client/src/app/users/user-list.component.scss +++ b/client/src/app/users/user-list.component.scss @@ -12,7 +12,7 @@ margin-bottom: 10px; } -.mat-radio-button { +mat-radio-button { margin: 0 12px; } diff --git a/client/src/app/users/user-list.component.spec.ts b/client/src/app/users/user-list.component.spec.ts index 14379ea..b816723 100644 --- a/client/src/app/users/user-list.component.spec.ts +++ b/client/src/app/users/user-list.component.spec.ts @@ -51,7 +51,7 @@ describe('User list', () => { imports: [COMMON_IMPORTS, UserListComponent, UserCardComponent], // providers: [ UserService ] // NO! Don't provide the real service! // Provide a test-double instead - providers: [{ provide: UserService, useValue: new MockUserService() }] + providers: [{ provide: UserService, useValue: new MockUserService() }], }); }); @@ -79,23 +79,23 @@ describe('User list', () => { })); it('contains all the users', () => { - expect(userList.serverFilteredUsers.length).toBe(3); + expect(userList.serverFilteredUsers().length).toBe(3); }); it('contains a user named \'Chris\'', () => { - expect(userList.serverFilteredUsers.some((user: User) => user.name === 'Chris')).toBe(true); + expect(userList.serverFilteredUsers().some((user: User) => user.name === 'Chris')).toBe(true); }); it('contain a user named \'Jamie\'', () => { - expect(userList.serverFilteredUsers.some((user: User) => user.name === 'Jamie')).toBe(true); + expect(userList.serverFilteredUsers().some((user: User) => user.name === 'Jamie')).toBe(true); }); it('doesn\'t contain a user named \'Santa\'', () => { - expect(userList.serverFilteredUsers.some((user: User) => user.name === 'Santa')).toBe(false); + expect(userList.serverFilteredUsers().some((user: User) => user.name === 'Santa')).toBe(false); }); it('has two users that are 37 years old', () => { - expect(userList.serverFilteredUsers.filter((user: User) => user.age === 37).length).toBe(2); + expect(userList.serverFilteredUsers().filter((user: User) => user.age === 37).length).toBe(2); }); }); @@ -125,7 +125,7 @@ describe('Misbehaving User List', () => { imports: [COMMON_IMPORTS, UserListComponent], // providers: [ UserService ] // NO! Don't provide the real service! // Provide a test-double instead - providers: [{ provide: UserService, useValue: userServiceStub }] + providers: [{ provide: UserService, useValue: userServiceStub }], }); }); @@ -139,22 +139,16 @@ describe('Misbehaving User List', () => { }); })); - it('generates an error if we don\'t set up a UserListService', () => { - const mockedMethod = spyOn(userList, 'getUsersFromServer').and.callThrough(); - // Since calling either getUsers() or getUsersFiltered() return - // Observables that then throw exceptions, we don't expect the component - // to be able to get a list of users, and serverFilteredUsers should - // be undefined. - expect(userList.serverFilteredUsers) - .withContext('service can\'t give values to the list if it\'s not there') - .toBeUndefined(); - expect(userList.getUsersFromServer) - .withContext('will generate the right error if we try to getUsersFromServer') - .toThrow(); - expect(mockedMethod) - .withContext('will be called') - .toHaveBeenCalled(); - expect(userList.errMsg) + it("generates an error if we don't set up a UserListService", () => { + // If the service fails, we expect the `serverFilteredUsers` signal to + // be an empty array of users. + expect(userList.serverFilteredUsers()) + .withContext("service can't give values to the list if it's not there") + .toEqual([]); + // We also expect the `errMsg` signal to contain the "Problem contacting…" + // error message. (It's arguably a bit fragile to expect something specific + // like this; maybe we just want to expect it to be non-empty?) + expect(userList.errMsg()) .withContext('the error message will be') .toContain('Problem contacting the server – Error Code:'); console.log(userList.errMsg); diff --git a/client/src/app/users/user-list.component.ts b/client/src/app/users/user-list.component.ts index 8790f3e..4efb33a 100644 --- a/client/src/app/users/user-list.component.ts +++ b/client/src/app/users/user-list.component.ts @@ -1,6 +1,6 @@ -import { Component, OnInit, OnDestroy } from '@angular/core'; +import { Component, signal, inject, computed } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { Subject, takeUntil } from 'rxjs'; +import { catchError, combineLatest, of, switchMap, tap } from 'rxjs'; import { User, UserRole } from './user'; import { UserService } from './user.service'; import { MatIconModule } from '@angular/material/icon'; @@ -17,6 +17,7 @@ import { FormsModule } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatCardModule } from '@angular/material/card'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; /** * A component that displays a list of users, either as a grid @@ -29,106 +30,108 @@ import { MatCardModule } from '@angular/material/card'; * makes the most sense to do the filtering. */ @Component({ - selector: 'app-user-list-component', - templateUrl: 'user-list.component.html', - styleUrls: ['./user-list.component.scss'], - providers: [], - standalone: true, - imports: [MatCardModule, MatFormFieldModule, MatInputModule, FormsModule, MatSelectModule, MatOptionModule, MatRadioModule, UserCardComponent, MatListModule, RouterLink, MatButtonModule, MatTooltipModule, MatIconModule] + selector: 'app-user-list-component', + templateUrl: 'user-list.component.html', + styleUrls: ['./user-list.component.scss'], + providers: [], + standalone: true, + imports: [ + MatCardModule, + MatFormFieldModule, + MatInputModule, + FormsModule, + MatSelectModule, + MatOptionModule, + MatRadioModule, + UserCardComponent, + MatListModule, + RouterLink, + MatButtonModule, + MatTooltipModule, + MatIconModule, + ], }) +export class UserListComponent { + private userService = inject(UserService); + private snackBar = inject(MatSnackBar); -export class UserListComponent implements OnInit, OnDestroy { - // These are public so that tests can reference them (.spec.ts) - public serverFilteredUsers: User[]; - public filteredUsers: User[]; + userName = signal(undefined); + userAge = signal(undefined); + userRole = signal(undefined); + userCompany = signal(undefined); - public userName: string; - public userAge: number; - public userRole: UserRole; - public userCompany: string; - public viewType: 'card' | 'list' = 'card'; + viewType = signal<'card' | 'list'>('card'); - errMsg = ''; - private ngUnsubscribe = new Subject(); + errMsg = signal(undefined); + // The `Observable`s used in the definition of `serverFilteredUsers` below need + // observables to react to, i.e., they need to know what kinds of changes to respond to. + // We want to do the age and role filtering on the server side, so if either of those + // text fields change we want to re-run the filtering. That means we have to convert both + // of those _signals_ to _observables_ using `toObservable()`. Those are then used in the + // definition of `serverFilteredUsers` below to trigger updates to the `Observable` there. + private userRole$ = toObservable(this.userRole); + private userAge$ = toObservable(this.userAge); - /** - * This constructor injects both an instance of `UserService` - * and an instance of `MatSnackBar` into this component. - * `UserService` lets us interact with the server. - * - * @param userService the `UserService` used to get users from the server - * @param snackBar the `MatSnackBar` used to display feedback - */ - constructor(private userService: UserService, private snackBar: MatSnackBar) { - // Nothing here – everything is in the injection parameters. - } + // We ultimately `toSignal` this to be able to access it synchronously, but we do all the RXJS operations + // "inside" the `toSignal()` call processing and transforming the observables there. + serverFilteredUsers = + // This `combineLatest` call takes the most recent values from these two observables (both built from + // signals as described above) and passes them into the following `.pipe()` call. If either of the + // `userRole` or `userAge` signals change (because their text fields get updated), then that will trigger + // the corresponding `userRole$` and/or `userAge$` observables to change, which will cause `combineLatest()` + // to send a new pair down the pipe. + toSignal( + combineLatest([this.userRole$, this.userAge$]).pipe( + // `switchMap` maps from one observable to another. In this case, we're taking `role` and `age` and passing + // them as arguments to `userService.getUsers()`, which then returns a new observable that contains the + // results. + switchMap(([role, age]) => + this.userService.getUsers({ + role, + age, + }) + ), + // `catchError` is used to handle errors that might occur in the pipeline. In this case `userService.getUsers()` + // can return errors if, for example, the server is down or returns an error. This catches those errors, and + // sets the `errMsg` signal, which allows error messages to be displayed. + catchError((err) => { + if (err.error instanceof ErrorEvent) { + this.errMsg.set( + `Problem in the client – Error: ${err.error.message}` + ); + } else { + this.errMsg.set( + `Problem contacting the server – Error Code: ${err.status}\nMessage: ${err.message}` + ); + } + this.snackBar.open(this.errMsg(), 'OK', { duration: 6000 }); + // `catchError` needs to return the same type. `of` makes an observable of the same type, and makes the array still empty + return of([]); + }), + // Tap allows you to perform side effects if necessary + tap(() => { + // A common side effect is printing to the console. + // You don't want to leave code like this in the + // production system, but it can be useful in debugging. + // console.log('Users were filtered on the server') + }) + ) + ); - /** - * Get the users from the server, filtered by the role and age specified - * in the GUI. - */ - getUsersFromServer(): void { - // A user-list-component is paying attention to userService.getUsers - // (which is an Observable) - // (for more on Observable, see: https://reactivex.io/documentation/observable.html) - // and we are specifically watching for role and age whenever the User[] gets updated - this.userService.getUsers({ - role: this.userRole, - age: this.userAge - }).pipe( - takeUntil(this.ngUnsubscribe) - ).subscribe({ - // Next time we see a change in the Observable, - // refer to that User[] as returnedUsers here and do the steps in the {} - next: (returnedUsers) => { - // First, update the array of serverFilteredUsers to be the User[] in the observable - this.serverFilteredUsers = returnedUsers; - // Then update the filters for our client-side filtering as described in this method - this.updateFilter(); - }, - // If we observe an error in that Observable, put that message in a snackbar so we can learn more - error: (err) => { - if (err.error instanceof ErrorEvent) { - this.errMsg = `Problem in the client – Error: ${err.error.message}`; - } else { - this.errMsg = `Problem contacting the server – Error Code: ${err.status}\nMessage: ${err.message}`; - } - this.snackBar.open( - this.errMsg, - 'OK', - // The message will disappear after 6 seconds. - { duration: 6000 }); - }, - // Once the observable has completed successfully - // complete: () => console.log('Users were filtered on the server') + // No need for fancy RXJS stuff. We do the fancy RXJS stuff where we call `toSignal`, i.e., up in + // the definition of `serverFilteredUsers` above. + // `computed()` takes the value of one or more signals (`serverfilteredUsers` in this case) and + // _computes_ the value of a new signal (`filteredUsers`). Angular recognizes when any signals + // in the function passed to `computed()` change, and will then call that function to generate + // the new value of the computed signal. + // In this case, whenever `serverFilteredUsers` changes (e.g., because we change `userName`), then `filteredUsers` + // will be updated by rerunning the function we're passing to `computed()`. + filteredUsers = computed(() => { + const serverFilteredUsers = this.serverFilteredUsers(); + return this.userService.filterUsers(serverFilteredUsers, { + name: this.userName(), + company: this.userCompany(), }); - } - - /** - * Called when the filtering information is changed in the GUI so we can - * get an updated list of `filteredUsers`. - */ - public updateFilter(): void { - this.filteredUsers = this.userService.filterUsers( - this.serverFilteredUsers, { name: this.userName, company: this.userCompany }); - } - - /** - * Starts an asynchronous operation to update the users list - * - */ - ngOnInit(): void { - this.getUsersFromServer(); - } - - /** - * When this component is destroyed, we should unsubscribe to any - * outstanding requests. - */ - ngOnDestroy() { - this.ngUnsubscribe.next(); - this.ngUnsubscribe.complete(); - } - + }); } diff --git a/client/src/app/users/user-profile.component.html b/client/src/app/users/user-profile.component.html index a456957..c63e4cc 100644 --- a/client/src/app/users/user-profile.component.html +++ b/client/src/app/users/user-profile.component.html @@ -1,18 +1,18 @@
@if (user) { - + } @else if (error) {

- {{ error.help }} + {{ error().help }}

- {{ error.message }} + {{ error().message }}

- {{ error.httpResponse }} + {{ error().httpResponse }}

diff --git a/client/src/app/users/user-profile.component.spec.ts b/client/src/app/users/user-profile.component.spec.ts index 8f0cde5..7451dcd 100644 --- a/client/src/app/users/user-profile.component.spec.ts +++ b/client/src/app/users/user-profile.component.spec.ts @@ -26,14 +26,14 @@ describe('UserProfileComponent', () => { imports: [ RouterTestingModule, MatCardModule, - UserProfileComponent, UserCardComponent + UserProfileComponent, + UserCardComponent ], providers: [ { provide: UserService, useValue: mockUserService }, { provide: ActivatedRoute, useValue: activatedRoute } ] -}) - .compileComponents(); + }).compileComponents(); })); beforeEach(() => { @@ -52,7 +52,7 @@ describe('UserProfileComponent', () => { // to update. Our `UserProfileComponent` subscribes to that, so // it should update right away. activatedRoute.setParamMap({ id: expectedUser._id }); - expect(component.user).toEqual(expectedUser); + expect(component.user()).toEqual(expectedUser); }); it('should navigate to correct user when the id parameter changes', () => { @@ -61,12 +61,12 @@ describe('UserProfileComponent', () => { // to update. Our `UserProfileComponent` subscribes to that, so // it should update right away. activatedRoute.setParamMap({ id: expectedUser._id }); - expect(component.user).toEqual(expectedUser); + expect(component.user()).toEqual(expectedUser); // Changing the paramMap should update the displayed user profile. expectedUser = MockUserService.testUsers[1]; activatedRoute.setParamMap({ id: expectedUser._id }); - expect(component.user).toEqual(expectedUser); + expect(component.user()).toEqual(expectedUser); }); it('should have `null` for the user for a bad ID', () => { @@ -75,12 +75,10 @@ describe('UserProfileComponent', () => { // If the given ID doesn't map to a user, we expect the service // to return `null`, so we would expect the component's user // to also be `null`. - expect(component.user).toBeNull(); + expect(component.user()).toBeNull(); }); it('should set error data on observable error', () => { - activatedRoute.setParamMap({ id: chrisId }); - const mockError = { message: 'Test Error', error: { title: 'Error Title' } }; // const errorResponse = { status: 500, message: 'Server error' }; @@ -91,11 +89,9 @@ describe('UserProfileComponent', () => { .and .returnValue(throwError(() => mockError)); - // component.user = throwError(() => mockError) as Observable; - - component.ngOnInit(); + activatedRoute.setParamMap({ id: chrisId }); - expect(component.error).toEqual({ + expect(component.error()).toEqual({ help: 'There was a problem loading the user – try again.', httpResponse: mockError.message, message: mockError.error.title, diff --git a/client/src/app/users/user-profile.component.ts b/client/src/app/users/user-profile.component.ts index e01c21b..70e88cd 100644 --- a/client/src/app/users/user-profile.component.ts +++ b/client/src/app/users/user-profile.component.ts @@ -1,13 +1,13 @@ -import { Component, OnInit, OnDestroy } from '@angular/core'; +import { Component, signal, inject } from '@angular/core'; import { ActivatedRoute, ParamMap } from '@angular/router'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { User } from './user'; import { UserService } from './user.service'; -import { Subject } from 'rxjs'; -import { map, switchMap, takeUntil } from 'rxjs/operators'; +import { of, Subject } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; import { UserCardComponent } from './user-card.component'; import { MatCardModule } from '@angular/material/card'; +import { toSignal } from '@angular/core/rxjs-interop'; @Component({ selector: 'app-user-profile', @@ -16,32 +16,12 @@ import { MatCardModule } from '@angular/material/card'; standalone: true, imports: [UserCardComponent, MatCardModule] }) -export class UserProfileComponent implements OnInit, OnDestroy { - user: User; - error: { help: string, httpResponse: string, message: string }; +export class UserProfileComponent { + private snackBar = inject(MatSnackBar); + private route = inject(ActivatedRoute); + private userService = inject(UserService); - // This `Subject` will only ever emit one (empty) value when - // `ngOnDestroy()` is called, i.e., when this component is - // destroyed. That can be used ot tell any subscriptions to - // terminate, allowing the system to free up their resources (like memory). - private ngUnsubscribe = new Subject(); - - constructor(private snackBar: MatSnackBar, private route: ActivatedRoute, private userService: UserService) { } - - ngOnInit(): void { - // The `map`, `switchMap`, and `takeUntil` are all RXJS operators, and - // each represents a step in the pipeline built using the RXJS `pipe` - // operator. - // The map step takes the `ParamMap` from the `ActivatedRoute`, which - // is typically the URL in the browser bar. - // The result from the map step is the `id` string for the requested - // `User`. - // That ID string gets passed (by `pipe`) to `switchMap`, which transforms - // it into an Observable, i.e., all the (zero or one) `User`s - // that have that ID. - // The `takeUntil` operator allows this pipeline to continue to emit values - // until `this.ngUnsubscribe` emits a value, saying to shut the pipeline - // down and clean up any associated resources (like memory). + user = toSignal( this.route.paramMap.pipe( // Map the paramMap into the id map((paramMap: ParamMap) => paramMap.get('id')), @@ -49,41 +29,28 @@ export class UserProfileComponent implements OnInit, OnDestroy { // which will emit zero or one values depending on whether there is a // `User` with that ID. switchMap((id: string) => this.userService.getUserById(id)), - // Allow the pipeline to continue to emit values until `this.ngUnsubscribe` - // returns a value, which only happens when this component is destroyed. - // At that point we shut down the pipeline, allowed any - // associated resources (like memory) are cleaned up. - takeUntil(this.ngUnsubscribe) - ).subscribe({ - next: user => { - this.user = user; - return user; - }, - error: _err => { - this.error = { + catchError((_err) => { + this.error.set({ help: 'There was a problem loading the user – try again.', httpResponse: _err.message, message: _err.error?.title, - }; - } + }); + return of(); + }) /* - * You can uncomment the line that starts with `complete` below to use that console message + * You can uncomment the line that starts with `finalize` below to use that console message * as a way of verifying that this subscription is completing. * We removed it since we were not doing anything interesting on completion * and didn't want to clutter the console log */ - // complete: () => console.log('We got a new user, and we are done!'), - }); - } + // finalize(() => console.log('We got a new user, and we are done!')) + ) + ); + error = signal({ help: '', httpResponse: '', message: '' }); - ngOnDestroy() { - // When the component is destroyed, we'll emit an empty - // value as a way of saying that any active subscriptions should - // shut themselves down so the system can free up any associated - // resources, like memory. - this.ngUnsubscribe.next(); - // Calling `complete()` says that this `Subject` is done and will - // never send any further values. - this.ngUnsubscribe.complete(); - } + // This `Subject` will only ever emit one (empty) value when + // `ngOnDestroy()` is called, i.e., when this component is + // destroyed. That can be used ot tell any subscriptions to + // terminate, allowing the system to free up their resources (like memory). + private ngUnsubscribe = new Subject(); }