项目作者: universal-model

项目描述 :
Universal Model for Angular
高级语言: TypeScript
项目地址: git://github.com/universal-model/universal-model-angular.git
创建时间: 2020-01-18T13:30:01Z
项目社区:https://github.com/universal-model/universal-model-angular

开源协议:MIT License

下载


Universal Model for Angular

version
Downloads
build
coverage
Quality Gate Status
MIT License
FOSSA 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

  1. npm install --save universal-model-angular

Prerequisites for universal-model-react

  1. 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)

  1. - src
  2. |
  3. |- common
  4. | |- component1
  5. | |- component2
  6. | . |- component2_1
  7. | . .
  8. | . .
  9. |- componentA
  10. |- componentB
  11. | |- componentB_1
  12. | |- componentB_2
  13. |- componentC
  14. | |- view
  15. | .
  16. | .
  17. |- componentN
  18. | |- controller
  19. | |- model
  20. | | |- actions
  21. | | |- services
  22. | | |- state
  23. | |- view
  24. |- store

API

Common API (Angular/React/Svelte/Vue)

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

Angular specific API

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

Detailed API documentation

API Examples

Create initial states

  1. const initialComponentAState = {
  2. prop1: 0,
  3. prop2: 0
  4. };

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.

  1. const createComponentASelectors = <T extends State>() => ({
  2. selector1: (state: State) => state.componentAState.prop1 + state.componentAState.prop2
  3. selector2: (state: State) => {
  4. const { componentBSelector1, componentBSelector2 } = createComponentBSelectors<State>();
  5. return state.componentAState.prop1 + componentBSelector1(state) + componentBSelector2(state);
  6. }
  7. });

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.

  1. const initialState = {
  2. componentAState: createSubState(initialComponentAState),
  3. componentBState: createSubState(initialComponentBState)
  4. };
  5. export type State = typeof initialState;
  6. const componentAStateSelectors = createComponentAStateSelectors<State>();
  7. const componentBStateSelectors = createComponentBStateSelectors<State>();
  8. const selectors = combineSelectors<State, typeof componentAStateSelectors, typeof componentBStateSelectors>(
  9. componentAStateSelectors,
  10. componentBStateSelectors
  11. );
  12. export default createStore<State, typeof selectors>(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

  1. export const initialComponentsBState = {
  2. componentBState: createSubState(initialComponentBState),
  3. componentB_1State: createSubState(initialComponentB_1State),
  4. componentB_2State: createSubState(initialComponentB_2State),
  5. };
  6. const componentBStateSelectors = createComponentBStateSelectors<State>();
  7. const componentB_1StateSelectors = createComponentB_1StateSelectors<State>();
  8. const componentB_2StateSelectors = createComponentB_2Selectors<State>('componentB');
  9. const componentsBStateSelectors = combineSelectors<State, typeof componentBStateSelectors, typeof componentB_1StateSelectors, typeof componentB_2StateSelectors>(
  10. componentBStateSelectors,
  11. componentB_1StateSelectors,
  12. componentB_2StateSelectors
  13. );

store.js

  1. const initialState = {
  2. ...initialComponentsAState,
  3. ...initialComponentsBState,
  4. .
  5. ...initialComponentsNState
  6. };
  7. export type State = typeof initialState;
  8. const selectors = combineSelectors<State, typeof componentsAStateSelectors, typeof componentsBStateSelectors, ... typeof componentsNStateSelectors>(
  9. componentsAStateSelectors,
  10. componentsBStateSelectors,
  11. .
  12. componentsNStateSelectors
  13. );
  14. export default createStore<State, typeof selectors>(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.

  1. export default function changeComponentAAndBState(newAValue, newBValue) {
  2. const { componentAState } = store.getState();
  3. componentAState.prop1 = newAValue;
  4. // BAD
  5. const { componentBState } = store.getState();
  6. componentBState.prop1 = newBValue;
  7. // GOOD
  8. changeComponentBState(newBValue);
  9. }

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.

  1. export default class AComponent {
  2. state: typeof initialComponentAState;
  3. foreignStateProp1: number,
  4. selector1: string,
  5. selector2: number
  6. // Action
  7. changeComponentAState = changeComponentAState
  8. constructor() {
  9. const [{ componentAState, componentBState }, { selector1, selector2 }] = store.getStateAndSelectors();
  10. useStateAndSelectors(this, { state: componentAState, foreignStateProp1: () => componentBState.prop1 }, { selector1, selector2 });
  11. }
  12. }

Example

View

app.component.ts

  1. import { Component } from '@angular/core';
  2. @Component({
  3. selector: 'app-root',
  4. template: `
  5. <app-header-view></app-header-view>
  6. <app-todo-list-view></app-todo-list-view>
  7. `,
  8. styleUrls: []
  9. })
  10. export class AppComponent {}

header.component.ts

  1. import { Component } from '@angular/core';
  2. import initialHeaderState from '@/header/model/state/initialHeaderState';
  3. import changeUserName from '@/header/model/actions/changeUserName';
  4. import store from '@/store/store';
  5. @Component({
  6. selector: 'app-header-view',
  7. template: `
  8. <div>
  9. <h1>{{ headerText }}</h1>
  10. <label for="userName">User name:</label>
  11. <input #userNameInput id="userName" (change)="changeUserName(userNameInput.value)" />
  12. </div>
  13. `,
  14. styleUrls: []
  15. })
  16. export class HeaderComponent {
  17. headerText: string;
  18. changeUserName = changeUserName;
  19. constructor() {
  20. const { headerText } = store.getSelectors();
  21. store.useSelectors(this, { headerText });
  22. }
  23. }

todolist.component.ts

  1. import { Component, OnDestroy, OnInit } from '@angular/core';
  2. import initialTodosState, { Todo } from '@/todolist/model/state/initialTodosState';
  3. import toggleShouldShowOnlyDoneTodos from '@/todolist/model/actions/toggleShouldShowOnlyUnDoneTodos';
  4. import toggleIsDoneTodo from '@/todolist/model/actions/toggleIsDoneTodo';
  5. import removeTodo from '@/todolist/model/actions/removeTodo';
  6. import store from '@/store/store';
  7. import fetchTodos from '@/todolist/model/actions/fetchTodos';
  8. import todoListController from '@/todolist/controller/todoListController';
  9. @Component({
  10. selector: 'app-todo-list-view',
  11. template: `
  12. <div>
  13. <input
  14. id="shouldShowOnlyUnDoneTodos"
  15. type="checkbox"
  16. [checked]="todosState.shouldShowOnlyUnDoneTodos"
  17. (click)="toggleShouldShowOnlyUnDoneTodos()"
  18. />
  19. <label for="shouldShowOnlyUnDoneTodos">Show only undone todos</label>
  20. <div *ngIf="todosState.isFetchingTodos">Fetching todos...</div>
  21. <div *ngIf="todosState.hasTodosFetchFailure; else todoList">Failed to fetch todos</div>
  22. <ng-template #todoList>
  23. <ul>
  24. <li *ngFor="let todo of shownTodos">
  25. <input id="todo.name" type="checkbox" [checked]="todo.isDone" (click)="toggleIsDoneTodo(todo)" />
  26. <label for="todo.name">{{ userName }}: {{ todo.name }}</label>
  27. <button (click)="removeTodo(todo)">Remove</button>
  28. </li>
  29. </ul>
  30. </ng-template>
  31. </div>
  32. `,
  33. styleUrls: []
  34. })
  35. export class TodoListComponent implements OnInit, OnDestroy {
  36. todosState: typeof initialTodosState;
  37. shownTodos: Todo[];
  38. userName: string;
  39. toggleShouldShowOnlyUnDoneTodos = toggleShouldShowOnlyDoneTodos;
  40. toggleIsDoneTodo = toggleIsDoneTodo;
  41. removeTodo = removeTodo;
  42. constructor() {
  43. const [{ todosState }, { shownTodos, userName }] = store.getStateAndSelectors();
  44. store.useStateAndSelectors(this, { todosState }, { shownTodos, userName });
  45. }
  46. ngOnInit(): void {
  47. // noinspection JSIgnoredPromiseFromCall
  48. fetchTodos();
  49. document.addEventListener('keydown', todoListController.handleKeyDown);
  50. }
  51. ngOnDestroy(): void {
  52. document.removeEventListener('keydown', todoListController.handleKeyDown);
  53. }
  54. }

Controller

todoListController.ts

  1. import addTodo from "@/todolist/model/actions/addTodo";
  2. import removeAllTodos from "@/todolist/model/actions/removeAllTodos";
  3. export default {
  4. handleKeyDown(keyboardEvent: KeyboardEvent): void {
  5. if (keyboardEvent.code === 'KeyA' && keyboardEvent.ctrlKey) {
  6. keyboardEvent.stopPropagation();
  7. keyboardEvent.preventDefault();
  8. addTodo();
  9. } else if (keyboardEvent.code === 'KeyR' && keyboardEvent.ctrlKey) {
  10. keyboardEvent.stopPropagation();
  11. keyboardEvent.preventDefault();
  12. removeAllTodos();
  13. }
  14. }
  15. };

Model

Store

store.ts

  1. import { combineSelectors, createStore, createSubState } from 'universal-model-angular';
  2. import initialHeaderState from '@/header/model/state/initialHeaderState';
  3. import initialTodoListState from '@/todolist/model/state/initialTodosState';
  4. import createTodoListStateSelectors from '@/todolist/model/state/createTodoListStateSelectors';
  5. import createHeaderStateSelectors from '@/header/model/state/createHeaderStateSelectors';
  6. const initialState = {
  7. headerState: createSubState(initialHeaderState),
  8. todosState: createSubState(initialTodoListState)
  9. };
  10. export type State = typeof initialState;
  11. const headerStateSelectors = createHeaderStateSelectors<State>();
  12. const todoListStateSelectors = createTodoListStateSelectors<State>();
  13. const selectors = combineSelectors<State, typeof headerStateSelectors, typeof todoListStateSelectors>(
  14. headerStateSelectors,
  15. todoListStateSelectors
  16. );
  17. export default createStore<State, typeof selectors>(initialState, selectors);

State

Initial state

initialHeaderState.ts

  1. export default {
  2. userName: 'John'
  3. };

initialTodoListState.ts

  1. export interface Todo {
  2. id: number,
  3. name: string;
  4. isDone: boolean;
  5. }
  6. export default {
  7. todos: [] as Todo[],
  8. shouldShowOnlyUnDoneTodos: false,
  9. isFetchingTodos: false,
  10. hasTodosFetchFailure: false
  11. };

State selectors

createHeaderStateSelectors.ts

  1. import { State } from '@/store/store';
  2. const createHeaderStateSelectors = <T extends State>() => ({
  3. userName: (state: T) => state.headerState.userName,
  4. headerText: (state: T) => {
  5. const {
  6. todoCount: selectTodoCount,
  7. unDoneTodoCount: selectUnDoneTodoCount
  8. } = createTodoListStateSelectors<T>();
  9. return `${state.headerState.userName} (${selectUnDoneTodoCount(state)}/${selectTodoCount(state)})`;
  10. }
  11. });
  12. export default createHeaderStateSelectors;

createTodoListStateSelectors.ts

  1. import { State } from '@/store/store';
  2. import { Todo } from '@/todolist/model/state/initialTodoListState';
  3. const createTodoListStateSelectors = <T extends State>() => ({
  4. shownTodos: (state: T) =>
  5. state.todosState.todos.filter(
  6. (todo: Todo) =>
  7. (state.todosState.shouldShowOnlyUnDoneTodos && !todo.isDone) ||
  8. !state.todosState.shouldShowOnlyUnDoneTodos
  9. ),
  10. todoCount: (state: T) => state.todosState.todos.length,
  11. unDoneTodoCount: (state: T) => state.todosState.todos.filter((todo: Todo) => !todo.isDone).length
  12. });
  13. export default createTodoListStateSelectors;

Service

ITodoService.ts

  1. import { Todo } from '@/todolist/model/state/initialTodoListState';
  2. export interface ITodoService {
  3. tryFetchTodos(): Promise<Todo[]>;
  4. }

FakeTodoService.ts

  1. import { ITodoService } from '@/todolist/model/services/ITodoService';
  2. import { Todo } from '@/todolist/model/state/initialTodoListState';
  3. import Constants from '@/Constants';
  4. export default class FakeTodoService implements ITodoService {
  5. tryFetchTodos(): Promise<Todo[]> {
  6. return new Promise<Todo[]>((resolve: (todo: Todo[]) => void, reject: () => void) => {
  7. setTimeout(() => {
  8. if (Math.random() < 0.95) {
  9. resolve([
  10. { id: 1, name: 'first todo', isDone: true },
  11. { id: 2, name: 'second todo', isDone: false }
  12. ]);
  13. } else {
  14. reject();
  15. }
  16. }, Constants.FAKE_SERVICE_LATENCY_IN_MILLIS);
  17. });
  18. }
  19. }

todoService.ts

  1. import FakeTodoService from "@/todolist/model/services/FakeTodoService";
  2. export default new FakeTodoService();

Actions

changeUserName.ts

  1. import store from '@/store/store';
  2. export default function changeUserName(newUserName: string): void {
  3. const { headerState } = store.getState();
  4. headerState.userName = newUserName;
  5. }

addTodo.ts

  1. import store from '@/store/store';
  2. let id = 3;
  3. export default function addTodo(): void {
  4. const { todosState } = store.getState();
  5. todosState.todos.push({ id, name: 'new todo', isDone: false });
  6. id++;
  7. }

removeTodo.ts

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

removeAllTodos.ts

  1. import store from '@/store/store';
  2. export default function removeAllTodos(): void {
  3. const { todosState } = store.getState();
  4. todosState.todos = [];
  5. }

toggleIsDoneTodo.ts

  1. import { Todo } from '@/todolist/model/state/initialTodoListState';
  2. export default function toggleIsDoneTodo(todo: Todo): void {
  3. todo.isDone = !todo.isDone;
  4. }

toggleShouldShowOnlyUnDoneTodos.ts

  1. import store from '@/store/store';
  2. export default function toggleShouldShowOnlyUnDoneTodos(): void {
  3. const { todosState } = store.getState();
  4. todosState.shouldShowOnlyUnDoneTodos = !todosState.shouldShowOnlyUnDoneTodos;
  5. }

fetchTodos.ts

  1. import store from '@/store/store';
  2. import todoService from '@/todolist/model/services/todoService';
  3. export default async function fetchTodos(): Promise<void> {
  4. const { todosState } = store.getState();
  5. todosState.isFetchingTodos = true;
  6. todosState.hasTodosFetchFailure = false;
  7. try {
  8. todosState.todos = await todoService.tryFetchTodos();
  9. } catch (error) {
  10. todosState.hasTodosFetchFailure = true;
  11. }
  12. todosState.isFetchingTodos = false;
  13. }

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