Introduction
This articles guides us through how to go about building a minimal reactive Angular Todo application with the Firefly Semantics Slice State Manager.
We have also created a Todo Application Youtube Tutorial.
The application allows us to add Todo
instances / entities and also Slice the rendering of the instances using the categories All
, Completed
, and Active
.
If the All
filter is selected then all the Todo
instances will be rendered regardless of whether they have been marked as Completed
or not.
If the Completed
filter is selected then only Completed
Todo
instances are rendered, and if their checkbox
is unchecked, then the Todo instance will be hidden reactively.
If the Active
filter is selected then only Active
Todo
instances are rendered, and if their checkbox
is checked, then the Todo
instance will be hidden reactively.
Also Finito!
is rendered at the bottom of the application when all of the Todo
instances have been marked as completed.
Here is the Stackblitz Demo of what we will be creating:
UX and Styling
We will be using MaterializeCSS for styling.
In our Stackblitz we have added the following to our index.html
:
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0-beta/css/materialize.min.css">
Dependencies
First install Firefly Semantics Slice and the peer dependencies:
npm i @fireflysemantics/slice @types/nanoid nanoid
In order to unsubscribe from our component observable state we will use the package @ngneat/until-destroy.
Since Angular already includes RxJS we don’t need to install it.
Model
Visibility Filter
The VISIBILITY_FILTER
enum
below is used to provide the filter.component.ts
with values that are used to reactively filter the list of Todo
instances.
The code is contained within model/todo-filter.enum.ts
:
export enum VISIBILITY_FILTER {
SHOW_COMPLETED = 'Completed',
SHOW_ACTIVE = 'Active',
SHOW_ALL = 'All'
}
Visibility Filter Values
In order to get the selectable values used to populate the select dropdown control
we will create model/todo-filter-values.function.ts
:
import { VISIBILITY_FILTER } from './todo-filter.enum';
export function VISIBILITY_FILTER_VALUES(): string[] {
return Object.keys(VISIBILITY_FILTER).map((k) =>
VISIBILITY_FILTER[k]);
}
Slices
The Entity
slices for separating complete
and incomplete
Todo
instances are modeled in model/todo-slices.enum.ts
:
/**
* The Slice Keys
*/
export const enum TodoSliceEnum {
COMPLETE = 'Complete',
INCOMPLETE = 'Incomplete'
}
We use this to initialize the Slice predicates of the entity store like this:
this.todoStore.addSlice((todo) => todo.completed,
TodoSliceEnum.COMPLETE);
this.todoStore.addSlice((todo) => !todo.completed,
TodoSliceEnum.INCOMPLETE);
Todo Class
The Todo
class is modeled in model/Todo.class
:
/**
* The Todo Model
*/
export class Todo {
gid: string;
constructor(public title: string, public completed: boolean) {}
}
State Service
We will create our application state service in services/state.service.ts
:
import { Injectable } from '@angular/core';
import { combineLatest, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import {
KeyObsValueReset,
ObsValueReset,
OStore,
EStore,
OStoreStart,
} from '@fireflysemantics/slice';
import { VISIBILITY_FILTER } from '../model/todo-filter.enum';
import { Todo } from '../model/todo.class';
import { TodoSliceEnum } from '../model/todo-slices.enum';
interface ISTART extends KeyObsValueReset {
ACTIVE_FILTER_KEY: ObsValueReset;
}
@Injectable({
providedIn: 'root',
})
export class StateService {
//============================================
// Define the Object Store
//============================================
START: OStoreStart = {
ACTIVE_FILTER_KEY: {
value: VISIBILITY_FILTER.SHOW_ALL,
reset: VISIBILITY_FILTER.SHOW_ALL,
},
};
public OS: OStore<ISTART> = new OStore(this.START);
//============================================
// Observe the Active Filter
//============================================
public activeFilter$: Observable<VISIBILITY_FILTER> = this.OS.observe(
this.START.ACTIVE_FILTER_KEY
);
//============================================
// Define the Todo Entity Store
//============================================
public todoStore: EStore<Todo> = new EStore<Todo>();
//============================================
// Create an Observable Reference
// to the current set of Todo instances
//============================================
public todos$: Observable<Todo[]> = this.todoStore.observe();
//============================================
// References for our remaining Observable state
//============================================
public completeTodos$: Observable<Todo[]>;
public incompleteTodos$: Observable<Todo[]>;
public selectedTodos$: Observable<Todo[]>;
//============================================
// Observable state for when all Todo instances
// have been marked as completed
//============================================
public finito$: Observable<boolean>;
constructor() {
//============================================
// Initialize Entity Store Slices
//============================================
this.todoStore.addSlice((todo) => todo.completed, TodoSliceEnum.COMPLETE);
this.todoStore.addSlice(
(todo) => !todo.completed,
TodoSliceEnum.INCOMPLETE
);
//============================================
// Observe Entity Store Slices
//============================================
this.completeTodos$ = this.todoStore
.getSlice(TodoSliceEnum.COMPLETE)
.observe();
this.incompleteTodos$ = this.todoStore
.getSlice(TodoSliceEnum.INCOMPLETE)
.observe();
//============================================
// Observe the Selected Todo category
//============================================
this.selectedTodos$ = combineLatest([
this.activeFilter$,
this.completeTodos$,
this.incompleteTodos$,
this.todos$,
]).pipe(
map((arr) => {
return this.applyFilter(arr[0], arr[1], arr[2], arr[3]);
})
);
//============================================
// Observe whether all Todo instances
// have been completed
//============================================
this.finito$ = combineLatest([this.completeTodos$, this.todos$]).pipe(
map((arr) => {
return this.isComplete(arr[0], arr[1]);
})
);
}
public applyFilter(filter, completeTodos, incompleteTodos, todos): Todo[] {
switch (filter) {
case VISIBILITY_FILTER.SHOW_COMPLETED:
return completeTodos;
case VISIBILITY_FILTER.SHOW_ACTIVE:
return incompleteTodos;
default:
return todos;
}
}
isComplete(completeTodos: Todo[], todos: Todo[]): boolean {
if (todos.length > 0) {
return completeTodos.length == todos.length ? true : false;
}
return false;
}
//============================================
// State API Methods
//============================================
complete(todo: Todo) {
this.todoStore.put(todo);
}
add(title: string) {
const todo = new Todo(title, false);
this.todoStore.post(todo);
}
delete(todo: Todo) {
this.todoStore.delete(todo);
}
}
Object Store
The object store is used to model the state of the currently active filter.
We initialize the store like this:
START: OStoreStart = {
ACTIVE_FILTER_KEY: {
value: VISIBILITY_FILTER.SHOW_ALL,
reset: VISIBILITY_FILTER.SHOW_ALL,
},
};
public OS: OStore<ISTART> = new OStore(this.START);
//============================================
// Observe the Active Filter
//============================================
public activeFilter$: Observable<VISIBILITY_FILTER> = this.OS.observe(
this.START.ACTIVE_FILTER_KEY
);
And we create an Observable
of the currently active filter value like this:
public activeFilter$: Observable<VISIBILITY_FILTER> = this.OS.observe(
this.START.ACTIVE_FILTER_KEY
);
For more info on how this works see Introduction to the Firefly Semantics Slice Reactive Object Store.
Entity Store
The Todo
entity store is initialized like this:
public todoStore: EStore<Todo> = new EStore<Todo>();
And the slices used to observe active and complete Todo entities are initialized like this:
this.todoStore.addSlice(
(todo) => todo.completed,
TodoSliceEnum.COMPLETE);
this.todoStore.addSlice(
(todo) => !todo.completed,
TodoSliceEnum.INCOMPLETE
);
For more details on the Entity Store (EStore) API see Introduction to the Firefly Semantics Slice Reactive Entity Store.
Observable Todo State
We create an observable for all the Todo
instances in the store like this:
public todos$: Observable<Todo[]> = this.todoStore.observe()
The completed Todo
instance are observed by getting the corresponding slice and observing it like this:
this.completeTodos$ = this.todoStore
.getSlice(TodoSliceEnum.COMPLETE).observe();
The active Todo instance are observed by getting the corresponding slice and observing it like this:
this.incompleteTodos$ = this.todoStore .getSlice(TodoSliceEnum.INCOMPLETE).observe();
In order to be able to react to and render the Todo
instances based on the filter selection we listen for filter selection events using combineLatest:
this.selectedTodos$ = combineLatest([
this.activeFilter$,
this.completeTodos$,
this.incompleteTodos$,
this.todos$,
]).pipe(
map((arr) => {
return this.applyFilter(arr[0], arr[1], arr[2], arr[3]);
})
);
Finally in order to notify when all Todo instances have been marked as completed we observe the finito$
observable:
this.finito$ = combineLatest(
[this.completeTodos$, this.todos$]).
pipe(map( (arr) => {
return this.isComplete(arr[0], arr[1]);
})
);
The isComplete
function checks whether the length of the todo array containing all the Todo instances equals the length of the Todo
instances in the slice that contains Todo
instances marked as completed
.
The remaining methods ( add
, delete
, and complete
are used by our components to update application state.
Components
Add Component
The components/add.component.ts
is used to add Todo
instances to the entity store:
The input control is bound to the keydown.enter
event and when the user presses enter addTodo()
on the state service is called with the value of the input control.
addTodo() {
this.s.add(this.titleControl.value);
this.titleControl.reset();
}
Filter Component
The filter component components/filter.component.ts
is used to select the current Todo
filter:
The control
is initialized by the currently active filter value stored in the state service:
this.active = this.s.OS.snapshot(this.s.OS.S.ACTIVE_FILTER_KEY);
this.control = new FormControl(this.active);
The state service is updated with user selected values like this:
this.control.valueChanges.pipe(untilDestroyed(this)).subscribe((c) => {
this.s.OS.put(this.s.OS.S.ACTIVE_FILTER_KEY, c);
});
Todos Component
The components/todos.component.ts
renders all the Todo
instances in the entity store based on the current filter selection:
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { StateService } from '../services/state.service';
@Component({
selector: 'app-todos',
template: `
<div class="collection with-header">
<h4 class="collection-header">Todos:</h4>
<app-todo *ngFor="let todo of s.selectedTodos$ | async;"
class="collection-item"
[todo]="todo"></app-todo>
</div>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TodosComponent {
constructor(public s: StateService) {}
}
The template accesses the selected Todo
instances directly from the state service:
<app-todo *ngFor="let todo of s.selectedTodos$ | async;"
class="collection-item"[todo]="todo">
</app-todo>
Todo Component
The components/todo.component.ts
is used to render each Todo
instances contained in the entity store. It also contains a delete
button and allows the Todo
instance to be marked as completed
:
The component receives the Todo
instance it renders via the todo:Todo
input property.
The control
checkbox is initialized with the completed
property of the Todo
instance bound to the control
via the todo
input property.
The control
checkbox is subscribed to and the value of the completed
property of the Todo
instance is updated when the user clicks on the control:
this.control.valueChanges.pipe(untilDestroyed(this)).subscribe(
(completed: boolean) => {
this.todo.completed = completed;
this.complete();
});
App Component
In app.component.ts
we inject the state service in order to be able to detect when all the Todo
instances have been marked as completed:
export class AppComponent {
constructor(public s: StateService) {}
}
Finally we put it all together in app.component.html
:
<div style="margin: 2rem;">
<app-add-todo></app-add-todo>
<app-todos-filter></app-todos-filter>
<app-todos></app-todos>
<div *ngIf="s.finito$ | async">Finito!</div>
</div>