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