I should call it a quit now. One more thing to experiment with. Couple of weeks ago I came down to making a state service of IList
and found out that we recreated all functionalities just to accommodate the sub property of matches
and total
. Today, I am going to make that part of the state class. It will prove a failure if using it for a simple array with no pagination proved to be unnecessarily complicated.
anchorMoving the total and hasMore to the list state
We begin at the end. The Transactions observable is now responsible for total
and hasMore
props, thus no need to watch Params in the template.
<!-- Watching the main observer at higher level -->
<ng-container *ngIf="nTx$ | async as txs">
<div class="bthin spaced">
// it should contain its own total
Total {{ txs.total }} items
</div>
<ul class="rowlist spaced">
// and the matches are the iterable prop
<li *ngFor="let tx of txs.matches;">
<div class="card">
<span class="rbreath a" (click)="delete(tx)">🚮</span>
<div class="content">
<div class="small light">{{tx.date | date}}</div>
{{tx.label }}
<div class="smaller lighter">{{ tx.category }}</div>
</div>
<div class="tail"><strong>{{ tx.amount }}</strong></div>
</div>
</li>
</ul>
<button class="btn" (click)="add()">Add new</button>
// and the hasMore is part of it too
<div class="txt-c" *ngIf="txs.hasMore">
<button class="btn" (click)="next()">More</button>
</div>
</ng-container>
In the component
ngOnInit(): void {
// back to nTx ;)
this.nTx$ = this.paramState.stateItem$.pipe(
distinctUntilKeyChanged('page'),
switchMap((state) => this.txService.GetTransactions(state)),
switchMap((txs) => {
// calculating hasMore from param state
const _hasMore = hasMore(txs.total, this.paramState.currentItem.size, this.paramState.currentItem.page);
// Now this, is new, it should set list and append new
return this.txState.appendList({...txs, hasMore: _hasMore})}),
}
// empty list everytime we visit this page
this.txState.emptyList();
// setoff state for first time, simplified with no total or hasMore
this.paramState.SetState({
page: 1,
size: 5
});
}
The first simplification we face: total
now is being taken care of inside the state class
// the add function now is slightly reduced
add(): void {
this.txService.CreateTransaction(newSample()).subscribe({
next: (newTx) => {
// no need to update param state, simply add item, it should take care of total
this.txState.addItem(newTx);
}
});
}
delete(tx: ITransaction): void {
this.txService.DeleteTransaction(tx).subscribe({
next: () => {
// this should now take care of total
this.txState.removeItem(tx);
}
});
}
The state class then looks like this (notice how heavier it looks than original, that should be a downside)
// First lets change the IState model to IListItem
export interface IListItem {
id: string;
}
// and let me create an IList model to hold matches array, total and hasMore
export interface IList<T extends IListItem> {
total: number;
matches: T[];
hasMore?: boolean;
}
// then our ListStateService would assume an observable of the IList, rather than an array
export class ListStateService<T extends IListItem> {
// instantiate with empty array and total 0
protected stateList: BehaviorSubject<IList<T>> = new BehaviorSubject({ matches: [], total: 0 });
stateList$: Observable<IList<T>> = this.stateList.asObservable();
// the getter
get currentList(): IList<T> {
return this.stateList.getValue();
}
// the append list should now set and append list and return an observable of IList
appendList(list: IList<T>): Observable<IList<T>> {
// append to internal matches array
const newMatches = [...this.currentList.matches, ...list.matches];
//aaargh! progress current state, with the incoming list then return
this.stateList.next({ ...this.currentList, ...list, matches: newMatches });
return this.stateList$;
}
// new: empty initial state list and total
emptyList() {
this.stateList.next({ matches: [], total: 0 });
}
addItem(item: T): void {
this.stateList.next({
// always must carry forward the current state
...this.currentList,
matches: [...this.currentList.matches, item],
// update total
total: this.currentList.total + 1
});
}
editItem(item: T): void {
const currentMatches = [...this.currentList.matches];
const index = currentMatches.findIndex(n => n.id === item.id);
if (index > -1) {
currentMatches[index] = clone(item);
// again, need to carry forward the current state
this.stateList.next({ ...this.currentList, matches: currentMatches });
}
}
removeItem(item: T): void {
this.stateList.next({
// and carry forward the current state
...this.currentList,
matches: this.currentList.matches.filter(n => n.id !== item.id),
// update total
total: this.currentList.total - 1
});
}
}
The first issue is to set the initial state with empty array, and zero matches. That is fixed with the new method emptyList()
.
The second issue is that since we have to take care of the object and the array, we need to carry forward the current state props in every operation. So it is like two in one! One dream, twice as many nightmares! It is not a big deal but when you start getting bugs you always question that part first.
Now to the test. Let's setup a component that gets an array of categories, with an add feature.
// the final result should look like this
<ng-container *ngIf="cats$ | async as cats">
<ul *ngFor="let item of cats.matches">
<li>
{{ item.name }}
</li>
</ul>
<div>
<button class="btn-rev" (click)="add()">Add category</button>
</div>
</ng-container>
Setting up the category state, and model:
export interface ICat {
name: string;
id: string; // required
}
@Injectable({ providedIn: 'root' })
export class CatState extends ListStateService<ICat> {
}
Also create a service to get categories and add category. The service should return an array of categories, not a list (no matches, and total props included). For brevity, I will leave that part out.
In our component
cats$: Observable<IList<ICat>>;
constructor(private catService: CatService, private catState: CatState) {
// imagine CatService :)
}
ngOnInit(): void {
this.cats$ = this.catService.GetCats().pipe(
// here goes: to appendList, we have to wrap in a proper IList<ICat> model
switchMap((data) => this.catState.appendList({matches: data, total: data.length}))
);
}
add() {
// add dummy cat without service to prove a point
const d = {name: 'new category', id: uuid()};
// dummy add
this.catState.addItem(d)
}
Running this works fine. So the only added complexity is having to wrap the returned array in a pseudo model with matches
property, and a useless total
property.
anchorSide effects
So doing a sub array added complexity in the state itself, and made us aware of the IList model where it is not needed. Though the complexity is not huge, and for most of the Get List operations that usually are paginated, it should be a benefit, I... however... dislike it. For two reasons:
- Wrapping the returned array in a model of no use seems too contrived
- Open wound, the list state class has a lot of wounds that could easily be infected and eventually blow up in our faces.
anchorFinal verdict
To live true to our target of simplicity, I removed the IList implementation. Find the final state service on Stackblitz. Please do let me know if something was not clear, or was buggy and overlooked, or you have a better (simpler) idea. Thanks for coming this far, and to reward you for your patience, here is a joke:
And the bartender says, "Success, but you're not ready!"
So a JavaScript function walks into a bar.
anchorThanks 🙂
Resources:
- The state class is part of Cricket Angular seed
- CSS framework used is Shut
- The example app is on Stackblitz
- These articles are also on Sekrab Garage
- Find me on twitter@sekrabbin