Reputation: 5338
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
Reputation: 36594
You don't need to set the items as an observables.
Here's something you can do:
combineLatest
of the data API observable and the search filter input changes. Then you run observable map
on it.map
will take a string (the filter), and an array of groups.map
, you need basic array mapping and filtering.map
function to return new array of groups, each group is the same, except its items
array is filtered using the filter stringfilter
on it so you return only the groups where items.length > 0
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:
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.
The original poster came on Twitter and explained his situation was more complex.
He wanted
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:
switchMap
or whatever to connect the form to the service, and get one observable with items, search filter, and grouping kindLet'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:
Upvotes: 4