Skip to content

Repository files navigation

Universal Model for Angular

versionDownloadsbuildcoverageQuality Gate StatusMIT LicenseFOSSA Status

Universal model is a model which can be used with any of following UI frameworks:

If you want to use multiple UI frameworks at the same time, you can use single model with [universal-model] library

Install

npm install --save universal-model-angular

Prerequisites for universal-model-react

 Angular >= 2

Clean UI Architecture

alt text

  • Model-View-Controller (https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller)
  • User triggers actions by using view or controller
  • Actions are part of model and they manipulate state that is stored
  • Actions can use services to interact with external (backend) systems
  • State changes trigger view updates
  • Selectors select and calculate a transformed version of state that causes view updates
  • Views contain NO business logic
  • There can be multiple interchangeable views that use same part of model
  • A new view can be created to represent model differently without any changes to model
  • View technology can be changed without changes to the model

Clean UI Code directory layout

UI application is divided into UI components. Common UI components should be put into common directory. Each component can consist of subcomponents. Each component has a view and optionally controller and model. Model consists of actions, state and selectors. In large scale apps, model can contain sub-store. Application has one store which is composed of each components' state (or sub-stores)

- src
|
|- common
| |- component1
| |- component2
| . |- component2_1
| . .
| . .
|- componentA
|- componentB
| |- componentB_1
| |- componentB_2
|- componentC
| |- view
| .
| .
|- componentN
| |- controller
| |- model
| | |- actions
| | |- services
| | |- state
| |- view
|- store

API

Common API (Angular/React/Svelte/Vue)

createSubState(subState);conststore=createStore(initialState,combineSelectors(selectors))const{ componentAState, componentBState }=store.getState();const{ selector1, selector2 }=store.getSelectors();const[{ componentAState },{ selector1, selector2 }]=store.getStateAndSelectors();

Angular specific API

useState(this,{ componentAState,componentBStateProp1: ()=>componentBState.prop1});useSelectors(this,{ selector1, selector2 });useStateAndSelectors(this,{ componentAState },{ selector1, selector2 });

Detailed API documentation

API Examples

Create initial states

constinitialComponentAState={prop1: 0,prop2: 0};

Create selectors

When using foreign state inside selectors, prefer creating foreign state selectors and accessing foreign state through them instead of directly accessing foreign state inside selector. This will ensure better encapsulation of component state.

constcreateComponentASelectors=<TextendsState>()=>({selector1: (state: State)=>state.componentAState.prop1+state.componentAState.prop2selector2: (state: State)=>{const{ componentBSelector1, componentBSelector2 }=createComponentBSelectors<State>();returnstate.componentAState.prop1+componentBSelector1(state)+componentBSelector2(state);}});

Create and export store in store.ts:

combineSelectors() checks if there are duplicate keys in selectors and will throw an error telling which key was duplicated. By using combineSelectors you can keep your selector names short and only namespace them if needed.

constinitialState={componentAState: createSubState(initialComponentAState),componentBState: createSubState(initialComponentBState)};exporttypeState=typeofinitialState;constcomponentAStateSelectors=createComponentAStateSelectors<State>();constcomponentBStateSelectors=createComponentBStateSelectors<State>();constselectors=combineSelectors<State,typeofcomponentAStateSelectors,typeofcomponentBStateSelectors>(componentAStateSelectors,componentBStateSelectors);exportdefaultcreateStore<State,typeofselectors>(initialState,selectors);

in large projects you should have sub-stores for components and these sub-store are combined together to a single store in store.js:

componentBSubStore.js

exportconstinitialComponentsBState={componentBState: createSubState(initialComponentBState),componentB_1State: createSubState(initialComponentB_1State),componentB_2State: createSubState(initialComponentB_2State),};constcomponentBStateSelectors=createComponentBStateSelectors<State>();constcomponentB_1StateSelectors=createComponentB_1StateSelectors<State>();constcomponentB_2StateSelectors=createComponentB_2Selectors<State>('componentB');constcomponentsBStateSelectors=combineSelectors<State,typeofcomponentBStateSelectors,typeofcomponentB_1StateSelectors,typeofcomponentB_2StateSelectors>(componentBStateSelectors,componentB_1StateSelectors,componentB_2StateSelectors);

store.js

constinitialState={
...initialComponentsAState,
...initialComponentsBState,.
...initialComponentsNState};exporttypeState=typeofinitialState;constselectors=combineSelectors<State,typeofcomponentsAStateSelectors,typeofcomponentsBStateSelectors, ... typeofcomponentsNStateSelectors>(componentsAStateSelectors,componentsBStateSelectors,.componentsNStateSelectors);exportdefaultcreateStore<State,typeofselectors>(initialState,selectors);

Access store in Actions

Don't modify other component's state directly inside action, but instead call other component's action. This will ensure encapsulation of component's own state.

exportdefaultfunctionchangeComponentAAndBState(newAValue,newBValue){const{ componentAState }=store.getState();componentAState.prop1=newAValue;// BADconst{ componentBState }=store.getState();componentBState.prop1=newBValue;// GOODchangeComponentBState(newBValue);}

Use actions, state and selectors in Views (Angular Components)

Components should use only their own state and access other components' states using selectors provided by those components. This will ensure encapsulation of each component's state. You can also access foreign state with a state getter.

exportdefaultclassAComponent{state: typeofinitialComponentAState;foreignStateProp1: number,selector1: string,selector2: number// ActionchangeComponentAState=changeComponentAStateconstructor(){const[{ componentAState, componentBState },{ selector1, selector2 }]=store.getStateAndSelectors();useStateAndSelectors(this,{state: componentAState,foreignStateProp1: ()=>componentBState.prop1},{ selector1, selector2 });}}

Example

View

app.component.ts

import{Component}from'@angular/core';
@Component({selector: 'app-root',template: ` <app-header-view></app-header-view> <app-todo-list-view></app-todo-list-view> `,styleUrls: []})exportclassAppComponent{}

header.component.ts

import{Component}from'@angular/core';importinitialHeaderStatefrom'@/header/model/state/initialHeaderState';importchangeUserNamefrom'@/header/model/actions/changeUserName';importstorefrom'@/store/store';
@Component({selector: 'app-header-view',template: ` <div> <h1>{{ headerText }}</h1> <label for="userName">User name:</label> <input #userNameInput id="userName" (change)="changeUserName(userNameInput.value)" /> </div> `,styleUrls: []})exportclassHeaderComponent{headerText: string;changeUserName=changeUserName;constructor(){const{ headerText }=store.getSelectors();store.useSelectors(this,{ headerText });}}

todolist.component.ts

import{Component,OnDestroy,OnInit}from'@angular/core';importinitialTodosState,{Todo}from'@/todolist/model/state/initialTodosState';importtoggleShouldShowOnlyDoneTodosfrom'@/todolist/model/actions/toggleShouldShowOnlyUnDoneTodos';importtoggleIsDoneTodofrom'@/todolist/model/actions/toggleIsDoneTodo';importremoveTodofrom'@/todolist/model/actions/removeTodo';importstorefrom'@/store/store';importfetchTodosfrom'@/todolist/model/actions/fetchTodos';importtodoListControllerfrom'@/todolist/controller/todoListController';
@Component({selector: 'app-todo-list-view',template: ` <div> <input id="shouldShowOnlyUnDoneTodos" type="checkbox" [checked]="todosState.shouldShowOnlyUnDoneTodos" (click)="toggleShouldShowOnlyUnDoneTodos()" /> <label for="shouldShowOnlyUnDoneTodos">Show only undone todos</label> <div *ngIf="todosState.isFetchingTodos">Fetching todos...</div> <div *ngIf="todosState.hasTodosFetchFailure; else todoList">Failed to fetch todos</div> <ng-template #todoList> <ul> <li *ngFor="let todo of shownTodos"> <input id="todo.name" type="checkbox" [checked]="todo.isDone" (click)="toggleIsDoneTodo(todo)" /> <label for="todo.name">{{ userName }}: {{ todo.name }}</label> <button (click)="removeTodo(todo)">Remove</button> </li> </ul> </ng-template> </div> `,styleUrls: []})exportclassTodoListComponentimplementsOnInit,OnDestroy{todosState: typeofinitialTodosState;shownTodos: Todo[];userName: string;toggleShouldShowOnlyUnDoneTodos=toggleShouldShowOnlyDoneTodos;toggleIsDoneTodo=toggleIsDoneTodo;removeTodo=removeTodo;constructor(){const[{ todosState },{ shownTodos, userName }]=store.getStateAndSelectors();store.useStateAndSelectors(this,{ todosState },{ shownTodos, userName });}ngOnInit(): void{// noinspection JSIgnoredPromiseFromCallfetchTodos();document.addEventListener('keydown',todoListController.handleKeyDown);}ngOnDestroy(): void{document.removeEventListener('keydown',todoListController.handleKeyDown);}}

Controller

todoListController.ts

importaddTodofrom"@/todolist/model/actions/addTodo";importremoveAllTodosfrom"@/todolist/model/actions/removeAllTodos";exportdefault{handleKeyDown(keyboardEvent: KeyboardEvent): void{if(keyboardEvent.code==='KeyA'&&keyboardEvent.ctrlKey){keyboardEvent.stopPropagation();keyboardEvent.preventDefault();addTodo();}elseif(keyboardEvent.code==='KeyR'&&keyboardEvent.ctrlKey){keyboardEvent.stopPropagation();keyboardEvent.preventDefault();removeAllTodos();}}};

Model

Store

store.ts

import{combineSelectors,createStore,createSubState}from'universal-model-angular';importinitialHeaderStatefrom'@/header/model/state/initialHeaderState';importinitialTodoListStatefrom'@/todolist/model/state/initialTodosState';importcreateTodoListStateSelectorsfrom'@/todolist/model/state/createTodoListStateSelectors';importcreateHeaderStateSelectorsfrom'@/header/model/state/createHeaderStateSelectors';constinitialState={headerState: createSubState(initialHeaderState),todosState: createSubState(initialTodoListState)};exporttypeState=typeofinitialState;constheaderStateSelectors=createHeaderStateSelectors<State>();consttodoListStateSelectors=createTodoListStateSelectors<State>();constselectors=combineSelectors<State,typeofheaderStateSelectors,typeoftodoListStateSelectors>(headerStateSelectors,todoListStateSelectors);exportdefaultcreateStore<State,typeofselectors>(initialState,selectors);

State

Initial state

initialHeaderState.ts

exportdefault{userName: 'John'};

initialTodoListState.ts

exportinterfaceTodo{id: number,name: string;isDone: boolean;}exportdefault{todos: []asTodo[],shouldShowOnlyUnDoneTodos: false,isFetchingTodos: false,hasTodosFetchFailure: false};

State selectors

createHeaderStateSelectors.ts

import{State}from'@/store/store';constcreateHeaderStateSelectors=<TextendsState>()=>({userName: (state: T)=>state.headerState.userName,headerText: (state: T)=>{const{todoCount: selectTodoCount,unDoneTodoCount: selectUnDoneTodoCount}=createTodoListStateSelectors<T>();return`${state.headerState.userName} (${selectUnDoneTodoCount(state)}/${selectTodoCount(state)})`;}});exportdefaultcreateHeaderStateSelectors;

createTodoListStateSelectors.ts

import{State}from'@/store/store';import{Todo}from'@/todolist/model/state/initialTodoListState';constcreateTodoListStateSelectors=<TextendsState>()=>({shownTodos: (state: T)=>state.todosState.todos.filter((todo: Todo)=>(state.todosState.shouldShowOnlyUnDoneTodos&&!todo.isDone)||!state.todosState.shouldShowOnlyUnDoneTodos),todoCount: (state: T)=>state.todosState.todos.length,unDoneTodoCount: (state: T)=>state.todosState.todos.filter((todo: Todo)=>!todo.isDone).length});exportdefaultcreateTodoListStateSelectors;

Service

ITodoService.ts

import{Todo}from'@/todolist/model/state/initialTodoListState';exportinterfaceITodoService{tryFetchTodos(): Promise<Todo[]>;}

FakeTodoService.ts

import{ITodoService}from'@/todolist/model/services/ITodoService';import{Todo}from'@/todolist/model/state/initialTodoListState';importConstantsfrom'@/Constants';exportdefaultclassFakeTodoServiceimplementsITodoService{tryFetchTodos(): Promise<Todo[]>{returnnewPromise<Todo[]>((resolve: (todo: Todo[])=>void,reject: ()=>void)=>{setTimeout(()=>{if(Math.random()<0.95){resolve([{id: 1,name: 'first todo',isDone: true},{id: 2,name: 'second todo',isDone: false}]);}else{reject();}},Constants.FAKE_SERVICE_LATENCY_IN_MILLIS);});}}

todoService.ts

importFakeTodoServicefrom"@/todolist/model/services/FakeTodoService";exportdefaultnewFakeTodoService();

Actions

changeUserName.ts

importstorefrom'@/store/store';exportdefaultfunctionchangeUserName(newUserName: string): void{const{ headerState }=store.getState();headerState.userName=newUserName;}

addTodo.ts

importstorefrom'@/store/store';letid=3;exportdefaultfunctionaddTodo(): void{const{ todosState }=store.getState();todosState.todos.push({ id,name: 'new todo',isDone: false});id++;}

removeTodo.ts

import store from '@/store/store';
import { Todo } from '@/todolist/model/state/initialTodoListState';
export default function removeTodo(todoToRemove: Todo): void {
const { todosState } = store.getState();
todosState.todos = todosState.todos.filter((todo: Todo) => todo !== todoToRemove);
}

removeAllTodos.ts

importstorefrom'@/store/store';exportdefaultfunctionremoveAllTodos(): void{const{ todosState }=store.getState();todosState.todos=[];}

toggleIsDoneTodo.ts

import{Todo}from'@/todolist/model/state/initialTodoListState';exportdefaultfunctiontoggleIsDoneTodo(todo: Todo): void{todo.isDone=!todo.isDone;}

toggleShouldShowOnlyUnDoneTodos.ts

importstorefrom'@/store/store';exportdefaultfunctiontoggleShouldShowOnlyUnDoneTodos(): void{const{ todosState }=store.getState();todosState.shouldShowOnlyUnDoneTodos=!todosState.shouldShowOnlyUnDoneTodos;}

fetchTodos.ts

importstorefrom'@/store/store';importtodoServicefrom'@/todolist/model/services/todoService';exportdefaultasyncfunctionfetchTodos(): Promise<void>{const{ todosState }=store.getState();todosState.isFetchingTodos=true;todosState.hasTodosFetchFailure=false;try{todosState.todos=awaittodoService.tryFetchTodos();}catch(error){todosState.hasTodosFetchFailure=true;}todosState.isFetchingTodos=false;}

Full Examples

https://github.com/universal-model/universal-model-angular-todo-app

https://github.com/universal-model/universal-model-react-todos-and-notes-app

Dependency injection

If you would like to use dependency injection (noicejs) in your app, check out this example, where DI is used to create services.

Donate/Sponsor

If you would like to help me to develop more great stuff, you can donate or sponsor. Thank you!

License

MIT License

Releases

Sponsor this project

Packages

Used by

Contributors

Languages