How to get checked users from stream incapsulated in service?

Viewed 86

There is a service UsersService contains user logic:

interaface User {
    id: number,
    name: string,
    checked?: boolean;
}

class UserService {
   public users$ = new BehaviorSubject<User[]>([]);
   public load() {
       this.http.get('users').subscribe((users: Users[] => this.users$.next(users));
   }
   
   public get users() {
       return this.users$.asObservable();
   }
}

I get data from service inside component:

public users$: Observable<User[]>;
constructor(private userService: UserService) {
    this.users$ = this.userService.users;
}

Template is:

<div> *ngFor="let user of users$ | async">
   <div>{{ user.name }}</div>
   <div><mat-checkbox [(ngModel)]="user.checked"></mat-checkbox></div>
</div>
<div>Checked users: {{ checkedusers | json }}</div>

How to get all checked users using rxjs approach {{ checkedusers | json }} from stream after updated one checkbox or all checkboxes and incapsulate it into service?

2 Answers

Use the scan operator to combine all of the sources that could modify the users list into one stream. Then in the component, instead of modifying user objects directly, send the changes to one of the subjects that is being used to modify the list:

Service

class UserService {
  readonly loadUsersSubject = new Subject();
  readonly userChangeSubject = new Subject<[userId: number, Partial<User>]>();

  readonly users$ = combineLatest([
    this.loadUsersSubject.pipe(
      switchMap(() => this.http.get('users')),
      map((users) => (s) => users))
    ),
    this.userChangeSubject.pipe(map(([userId, partialUser]) => (s) => {
      const index = s.findIndex(x => x.userId === userId);
      return [...s.slice(0, index - 1), { ... s[index], partialUser }, ...s.slice(index + 1)]
    })
  ]).pipe(
    scan((s, reducer) => reducer(s), []),
    shareReplay(1)
  )
}

Template

<div> *ngFor="let user of users$ | async">
   <div>{{ user.name }}</div>
   <div><mat-checkbox [ngModel]="user.checked" 
     (ngModelChange)="userService.userChangeSubject.next([user.id, { checked: $event.checked }])"></mat-checkbox></div>
</div>
<div>Checked users: {{ checkedusers$ | async | json }}</div>

A few comments:

  • You don't have to call next on the subject directly on the service. I did that for brevity in the example. Feel free to wrap them in methods if that's your thing.
  • The use of the tuple was again another decision made for brevity's sake.
  • Each subject in the service returns a reducer that will itself return a new copy of the users array. That is why when a single user is changed I create a brand new one. I could've just assigned the value to the element of the existing array, but I've gotten into the habit of not mutating any variables.
  • If you need to view the checkedUsers then map a filtered array from the users$ observable: users$.pipe(map(x => x.filter(y => y.checked))).

Something like this seemed to work:

import { Injectable } from '@angular/core';
import {  merge, of, Subject } from 'rxjs';
import { map, scan } from 'rxjs/operators';

export interface User {
  id: number;
  name: string;
  checked?: boolean;
}

@Injectable({ providedIn: 'root' })
export class UserService {
  readonly userChangeSubject = new Subject<User>();
  userChanged$ = this.userChangeSubject.asObservable();

  allUsers$ = of(userData);

  updateUser(user: User, checked: boolean) {
    const updatedUser = { ...user, checked: checked };
    this.userChangeSubject.next(updatedUser);
  }

  checkedUsers$ = merge(
    this.allUsers$, 
    this.userChanged$
    ).pipe(
    scan((users: User[], user: User) => users.map((u) =>  u.id === user.id ? user : u)),
    map(users => users.filter((u) => u.checked))
  );
}

export const userData: User[] = [
  { id: 1, name: 'Captain Jack', checked: false },
  { id: 2, name: 'Mal Reynolds', checked: false },
  { id: 3, name: 'NaomiNagata', checked: false },
];

Here is the updated link: https://stackblitz.com/edit/angular7-rxjs-ha6suw

  • The Subject emits a user when the user's check is changed.
  • The allUsers$ will need to be changed to call your http endpoint
  • The updateUser is called from the component when a checkbox is checked or unchecked. It creates a new user object with the appropriate check flag and emits it into the Subject.
  • The checkedUsers$ merges the original set of users with each emitted changed user. It then uses scan to retain the array of users. When an updated user is emitted, it locates that user in the array and replaces it. It then maps the resulting array to only display those that are checked.

UPDATE: As per the OP, added "check all" and "uncheck all" functionality.

@Injectable({ providedIn: 'root' })
export class UserService {
  // User or null for all users
  // Defined as a tuple with the user and checked flag
  readonly userCheckedSubject = new Subject<[User | null, boolean]>();
  userChecked$ = this.userCheckedSubject.asObservable();

  // This would be an http.get to get the users
  retrievedUsers$ = of(userData);

  allUsers$ = merge(this.retrievedUsers$, this.userChecked$).pipe(
    // Use array destructuring to access the tuple values
    scan((users: User[], [changedUser, checked]: [User | null, boolean]) =>
      this.processUsers(users, changedUser, checked)
    ),
    shareReplay(1)
  );

  checkedUsers$ = this.allUsers$.pipe(
    map((users) => users.filter((u) => u.checked)),
    shareReplay(1)
  );

  updateUser(user: User | null, checked: boolean) {
    this.userCheckedSubject.next([user, checked]);
  }

  processUsers(
    users: User[],
    changedUser: User | null,
    checked: boolean
  ): User[] {
    if (changedUser) {
      // Update the one changed item
      const updatedUser = { ...changedUser, checked: checked };
      return users.map((user) =>
        user.id === updatedUser.id ? updatedUser : user
      );
    } else {
      // Update each item
      return users.map((user) => ({ ...user, checked: checked }));
    }
  }
}

Here is the updated link: https://stackblitz.com/edit/angular7-rxjs-ahp158

Related