rasx
rasx

Reputation: 5338

RxJS and angular.io: an Observable<Array<T>> where T contains an observable array

Indeed, I did try to find a similar question to this one as I find it hard to accept that it has not been asked before (even though it might be of an anti-pattern). Let us start with this component:

@Component({...})
export class MyParent {
    myGroups: Observable<Array<MyItemGroup>>;
}

where MyItemGroup is:

export class MyItemGroup {
    groupDisplayName: string;
    group: Observable<Array<MyGroupedItem>>;
}

First of all, is this arrangement some kind anti-pattern? Is there another grouping design that is regarded as correct in angular.io? To avoid the classic answer, "it depends," let us go further and see that:

export class MyGroupedItem {
    title: string;
    uri: string;
}

At the MyParent level, I would like to filter MyGroupedItem by title and, when this filter reduces the count of MyItemGroup.group to zero, I would like to filter out MyItemGroup entirely. This strongly suggests to me that I must add mySubject to MyParent:

@Component({...})
export class MyParent {
    myGroups: Observable<Array<MyItemGroup>>;
    mySubject: Subject<string> = new Subject<string>();

    filterMyGroups(particle: string): void {
        this.mySubject.next(particle);
    }
}

It is here that I assume I need to go to town with RxJS, combining myGroups and mySubject. How does one do that? The UI would refer to mySubject through this markup:

<input #myFilter (keyup)="filterMyGroups(myFilter.value)" />

I can only hope the hope of the captive that my questions are understood and clear.

Upvotes: 2

Views: 542

Answers (1)

Meligy
Meligy

Reputation: 36594

You don't need to set the items as an observables.

Here's something you can do:

  • You combineLatest of the data API observable and the search filter input changes. Then you run observable map on it.
    The map will take a string (the filter), and an array of groups.
  • Inside that observable map, you need basic array mapping and filtering.
    You get an array of groups as I mentioned, so, you can:
    • call the array map function to return new array of groups, each group is the same, except its items array is filtered using the filter string
    • then take the result of that and call array filter on it so you return only the groups where items.length > 0
  • Then your group component just deals with a simple group object, passed to it, nothing related to observables there. Optionally you can split the item into its own component but you don't have to

Here's some code for the 2nd step:

@Component({
  selector: 'app-parent',
  template: `
  <p>
    Start editing to see some magic happen :)
  </p>

  <form [formGroup]="form">
    <label>
      Search
      <input type="search" formControlName="search" />
    </label>
  </form>

  <h2>Items</h2>
  <app-group 
    *ngFor="let group of groups$ | async"
    [group]="group" ></app-group>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ParentComponent {
  constructor(
    private service: GroupsService
  ) { }

  groupData$ = this.service.getGroups();

  form = new FormGroup({
    search: new FormControl('')
  });

  searchFilter$ = this.form.valueChanges.pipe(
    // the map gets an arg in shape of `{search: 'value of search input'}`
    map((newFormValue: { search: string }) => newFormValue.search)
  );

  // `combineLatest` returns values from observables passed to it * as array *
  groups$ = combineLatest(
    this.groupData$,
    // We need to show data without waiting for filter change
    this.searchFilter$.pipe(startWith(''))
  ).pipe(
    map(([groups, searchFilter]) =>
      groups
        .map(group => this.filterItemsInGroup(group, searchFilter))
        .filter(group => group.items.length > 0)
    )
    );

  private filterItemsInGroup(group: ItemGroup, searchFilter: string) {
    if (!searchFilter) {
      return group;
    }

    return {
      // Copy all properties of group
      ...group,
      // This is NOT observable filter, this is basic array filtering
      items: group.items.filter(item =>
        item.title.toLowerCase().indexOf(searchFilter.toLowerCase())
        >= 0
      )
    };
  }
}

Here's a full working sample with all pieces:

https://stackblitz.com/edit/angular-geujrq?embed=1&file=app/app.module.ts&hideExplorer=1&hideNavigation=1

The sample adds a few optimizations (like separating item into its own component and using OnPush change tracking for slightly faster output. More optizations could be added, like using trackBy for example.

Update

The original poster came on Twitter and explained his situation was more complex.

He wanted

  • the groups to be created dynamically from the items list
  • a drop down to choose which field to group the items based on
  • the items in each group to be hidden by default, and visible when you click the group name

This actually makes the problem of hiding groups easier, because you can filter the items BEFORE you group them.

Also the hiding of items does not have to be part of this problem at all. If your group is in its own component, you can have a boolean in that component that hides the items by default, and you flip it on group name click.

Let's see the code for this bit first because it's the easiest:
(styles and other bits are omitted, but I'll link to updated full sample below)

@Component({
  selector: 'app-group',
  template: `
      <h3 
        class="group-name"
        (click)="showItems = ! showItems"
        >{{group.displayName}}</h3>
      <ng-container *ngIf="showItems">
        <app-item 
          *ngFor="let item of group.items" 
          [item]="item"
          ></app-item>
      </ng-container>
  `
})
export class GroupComponent {
  @Input() group: ItemGroup;

  showItems = false;
}

Now let's go back to the listing problem. What we need to do is:

  • Add a new grouping kind dropdown to the form that had the filter
  • Use switchMap or whatever to connect the form to the service, and get one observable with items, search filter, and grouping kind
  • Map each array we get from the result of calling array or form change to a new array that is filtered by search filter input value
  • Map the filtered array of items to an array of groups using the value from grouping kind dropdown and, and hand that off to UI template
    • The specific logic of grouping can be complex if done by hand (like I did in the sample), but can be much reduced using something like Lodash

Let's look at the whole code:

Note how simple the setting of the groups$ property it. The only complexity was in applying grouping really.

@Component({
  selector: 'app-parent',
  template: `
  <p>
    Start editing to see some magic happen :)
  </p>

  <form [formGroup]="form">
    <label>
      Group
      <select formControlName="groupingKind">
        <option 
          *ngFor="let grouping of GroupingKinds"
          value="{{grouping}}">{{grouping}}</option>
      </select>
    </label>
    <label>
      Filter
      <input type="search" formControlName="search" />
    </label>
  </form>

  <h2>Items</h2>
  <app-group 
    *ngFor="let group of groups$ | async"
    [group]="group" ></app-group>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ParentComponent {
  constructor(
    private service: GroupsService
  ) { }

  // To make the template see it
  readonly GroupingKinds = Object.keys(GroupingKind)
    .map(key => GroupingKind[key]);

  form = new FormGroup({
    search: new FormControl(''),
    // Defaults to ByDate
    groupingKind: new FormControl(GroupingKind.ByDate)
  });

  // This will make a new HTTP request with every form change
  // If you don't want that, just switch the order
  //     of `this.form.valueChanges` and `this.service.getItems()`
  groups$ = this.form.valueChanges.pipe(
    // initial load
    startWith(this.form.value as {search: string, groupingKind: GroupingKind}),
    switchMap((form) =>
       this.service.getItems().pipe(
         // Take every array the `getItems()` returns
         //   (which is why we use observable `map` not observable `filter`)
         // And then transform it into another array
         //   that happenes to be the same array but filtered
         map(items => this.filterItems(items, form.search)),
         // Then map the result into group
         map(items => this.createGroups(items, form.groupingKind))
      )
    ),
  );

  private filterItems(items: ItemGroupItem[], searchFilter: string) {
    return items.filter(item =>
        item.Title.toLowerCase().indexOf(searchFilter.toLowerCase())
        >= 0
      );
  }

  private createGroups(src: ItemGroupItem[], groupingKind: GroupingKind) {
    const groupsList = [] as ItemGroup[];

    src.reduce((groupsObject, item) => {
      // Topic groups values are an array, date grouping value is a string, 
      // So we convert date grouping value to array also for simplicity
      const groupNames = groupingKind == GroupingKind.ByTopic
        ? item.ItemCategory.topics
        : [ item.ItemCategory.dateGroup ];

      for(const groupName of groupNames) {

        if(!groupsObject[groupName]) {
          const newGroup: ItemGroup = {
            displayName: groupName,
            items: []
          };
          groupsObject[groupName] = newGroup;      
          groupsList.push(newGroup);    
        }

        groupsObject[groupName].items.push(item);
      }

      return groupsObject;
    }, {} as { [name:string]: ItemGroup} );

    return groupsList;
  }
}

And as promised, here's the full updated sample:

https://stackblitz.com/edit/angular-19z4f1?embed=1&file=app/app.module.ts&hideExplorer=1&hideNavigation=1

Upvotes: 4

Related Questions