diff --git a/angular.json b/angular.json index 5c44dbf..26d8072 100644 --- a/angular.json +++ b/angular.json @@ -235,6 +235,46 @@ } } } + }, + "@loafer/ng-entity": { + "root": "projects/loafer/ng-entity", + "sourceRoot": "projects/loafer/ng-entity/src", + "projectType": "library", + "prefix": "lib", + "architect": { + "build": { + "builder": "@angular-devkit/build-ng-packagr:build", + "options": { + "tsConfig": "projects/loafer/ng-entity/tsconfig.lib.json", + "project": "projects/loafer/ng-entity/ng-package.json" + }, + "configurations": { + "production": { + "project": "projects/loafer/ng-entity/ng-package.prod.json" + } + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "projects/loafer/ng-entity/src/test.ts", + "tsConfig": "projects/loafer/ng-entity/tsconfig.spec.json", + "karmaConfig": "projects/loafer/ng-entity/karma.conf.js" + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": [ + "projects/loafer/ng-entity/tsconfig.lib.json", + "projects/loafer/ng-entity/tsconfig.spec.json" + ], + "exclude": [ + "**/node_modules/**" + ] + } + } + } } }, "defaultProject": "example" diff --git a/package.json b/package.json index 836a24b..c78c1fd 100644 --- a/package.json +++ b/package.json @@ -53,4 +53,4 @@ "ts-node": "~5.0.1", "tslint": "~5.9.1" } -} +} \ No newline at end of file diff --git a/projects/loafer/ng-entity/karma.conf.js b/projects/loafer/ng-entity/karma.conf.js new file mode 100644 index 0000000..79abacc --- /dev/null +++ b/projects/loafer/ng-entity/karma.conf.js @@ -0,0 +1,31 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage-istanbul-reporter'), + require('@angular-devkit/build-angular/plugins/karma') + ], + client: { + clearContext: false // leave Jasmine Spec Runner output visible in browser + }, + coverageIstanbulReporter: { + dir: require('path').join(__dirname, '../../../coverage'), + reports: ['html', 'lcovonly'], + fixWebpackSourcePaths: true + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['Chrome'], + singleRun: false + }); +}; diff --git a/projects/loafer/ng-entity/ng-package.json b/projects/loafer/ng-entity/ng-package.json new file mode 100644 index 0000000..f401552 --- /dev/null +++ b/projects/loafer/ng-entity/ng-package.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../../dist/loafer/ng-entity", + "deleteDestPath": true, + "lib": { + "entryFile": "src/public_api.ts" + } +} \ No newline at end of file diff --git a/projects/loafer/ng-entity/ng-package.prod.json b/projects/loafer/ng-entity/ng-package.prod.json new file mode 100644 index 0000000..fe65154 --- /dev/null +++ b/projects/loafer/ng-entity/ng-package.prod.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../../dist/loafer/ng-entity", + "lib": { + "entryFile": "src/public_api.ts" + } +} \ No newline at end of file diff --git a/projects/loafer/ng-entity/package.json b/projects/loafer/ng-entity/package.json new file mode 100644 index 0000000..6f763a9 --- /dev/null +++ b/projects/loafer/ng-entity/package.json @@ -0,0 +1,15 @@ +{ + "name": "@loafer/ng-entity", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "https://git.loafle.net/loafer/ng.git" + }, + "publishConfig": { + "registry": "https://nexus.loafle.net/repository/npm-loafle/" + }, + "peerDependencies": { + "@angular/common": "^6.0.0-rc.0 || ^6.0.0", + "@angular/core": "^6.0.0-rc.0 || ^6.0.0" + } +} \ No newline at end of file diff --git a/projects/loafer/ng-entity/src/lib/adapter.ts b/projects/loafer/ng-entity/src/lib/adapter.ts new file mode 100644 index 0000000..5c87010 --- /dev/null +++ b/projects/loafer/ng-entity/src/lib/adapter.ts @@ -0,0 +1,22 @@ +import { IDSelector, EntityAdapter } from './type'; +import { createInitialStateFactory } from './state'; +import { createSelectorsFactory } from './selectors'; +import { createUnsortedStateAdapter } from './unsorted_state_adapter'; + +export function createEntityAdapter(selectId?: IDSelector): EntityAdapter; +export function createEntityAdapter(selectId: IDSelector): EntityAdapter { + if (undefined === selectId) { + selectId = (e: any) => e.id; + } + + const stateFactory = createInitialStateFactory(); + const selectorsFactory = createSelectorsFactory(); + const stateAdapter = createUnsortedStateAdapter(selectId); + + return { + selectId, + ...stateFactory, + ...selectorsFactory, + ...stateAdapter, + }; +} diff --git a/projects/loafer/ng-entity/src/lib/selectors.ts b/projects/loafer/ng-entity/src/lib/selectors.ts new file mode 100644 index 0000000..2a9933d --- /dev/null +++ b/projects/loafer/ng-entity/src/lib/selectors.ts @@ -0,0 +1,62 @@ +import { EntityState, EntitySelectors, Dictionary } from './type'; +import { createSelector } from '@ngrx/store'; + + +export function createSelectorsFactory() { + function getSelectors(): EntitySelectors>; + function getSelectors(selectState: (state: S) => EntityState): EntitySelectors; + function getSelectors(selectState?: (state: any) => EntityState): EntitySelectors { + const selectIds = (state: any) => state.ids; + const selectEntities = (state: EntityState) => state.entities; + const selectAll = createSelector( + selectIds, + selectEntities, + (ids: T[], entities: Dictionary): any => ids.map((id: any) => (entities as any)[id]) + ); + const selectTotal = createSelector(selectIds, ids => ids.length); + const selectError = (state: any) => state.error; + + if (!selectState) { + return { + selectIds, + selectEntities, + selectOne: (id: string | number) => createSelector( + selectEntities, + (entities: Dictionary): T => { + if (null !== entities) { + return (entities as any)[id]; + } + return null; + } + ), + selectAll, + selectTotal, + selectError, + }; + } + + return { + selectIds: createSelector(selectState, selectIds), + selectEntities: createSelector(selectState, selectEntities), + selectOne: (id: string | number) => createSelector( + selectState, + createSelector( + selectEntities, + (entities: Dictionary): T => { + if (null !== entities) { + return (entities as any)[id]; + } + return null; + } + ) + ), + selectAll: createSelector(selectState, selectAll), + selectTotal: createSelector(selectState, selectTotal), + selectError: createSelector(selectState, selectError), + }; + } + + return { + getSelectors + }; +} diff --git a/projects/loafer/ng-entity/src/lib/state.ts b/projects/loafer/ng-entity/src/lib/state.ts new file mode 100644 index 0000000..908bb50 --- /dev/null +++ b/projects/loafer/ng-entity/src/lib/state.ts @@ -0,0 +1,21 @@ +import { EntityState } from './type'; + +export function getInitialEntityState(): EntityState { + return { + ids: [], + entities: {}, + error: null, + }; +} + +export function createInitialStateFactory() { + function getInitialState(): EntityState; + function getInitialState(additionalState: S): EntityState & S; + function getInitialState(additionalState: any = {}): any { + return Object.assign(getInitialEntityState(), additionalState); + } + + return { + getInitialState + }; +} diff --git a/projects/loafer/ng-entity/src/lib/state_adapter.ts b/projects/loafer/ng-entity/src/lib/state_adapter.ts new file mode 100644 index 0000000..08069df --- /dev/null +++ b/projects/loafer/ng-entity/src/lib/state_adapter.ts @@ -0,0 +1,45 @@ +import { EntityState } from './type'; + +export enum DidMutate { + EntitiesOnly, + Both, + Error, + None, +} + +export function createStateOperator(mutator: (arg: R, state: EntityState) => DidMutate): EntityState; +export function createStateOperator(mutator: (arg: any, state: any) => DidMutate): any { + return function operation>(arg: R, state: any): S { + const clonedEntityState: EntityState = { + ids: [...state.ids], + entities: { ...state.entities }, + error: state.error, + }; + + const didMutate = mutator(arg, clonedEntityState); + + if (didMutate === DidMutate.Both) { + clonedEntityState.error = null; + return Object.assign({}, state, clonedEntityState); + } + + if (didMutate === DidMutate.EntitiesOnly) { + return { + ...state, + entities: clonedEntityState.entities, + error: null, + }; + } + + if (didMutate === DidMutate.Error) { + return { + ...state, + ids: null, + entities: null, + error: clonedEntityState.error, + }; + } + + return state; + }; +} diff --git a/projects/loafer/ng-entity/src/lib/type.ts b/projects/loafer/ng-entity/src/lib/type.ts new file mode 100644 index 0000000..ebc5c51 --- /dev/null +++ b/projects/loafer/ng-entity/src/lib/type.ts @@ -0,0 +1,67 @@ +export type IDSelectorStr = (model: T) => string; +export type IDSelectorNum = (model: T) => number; +export type IDSelector = IDSelectorStr | IDSelectorNum; + +export interface DictionaryNum { + [id: number]: T; +} +export abstract class Dictionary implements DictionaryNum { + [id: string]: T; +} + +export interface UpdateStr { + id: string; + changes: Partial; +} + +export interface UpdateNum { + id: number; + changes: Partial; +} + +export type Update = UpdateStr | UpdateNum; + +export interface EntityState { + ids: string[] | number[]; + entities: Dictionary; + error: E | null; +} + +export interface EntityStateAdapter { + setOne>(entity: T, state: S): S; + setAll>(entities: T[], state: S): S; + + addOne>(entity: T, state: S): S; + addMany>(entities: T[], state: S): S; + + updateOne>(entity: T, state: S): S; + updateMany>(entities: T[], state: S): S; + + upsertOne>(entity: T, state: S): S; + upsertMany>(entities: T[], state: S): S; + + removeOne>(entity: T, state: S): S; + removeMany>(entities: T[], state: S): S; + removeAll>(state: S): S; + + setError>(error: E, state: S): S; +} + +export interface EntitySelectors { + selectIds: (state: S) => string[] | number[]; + selectEntities: (state: S) => Dictionary; + selectOne: (id: string | number) => (state: S) => T; + selectAll: (state: S) => T[]; + selectTotal: (state: S) => number; + selectError: (state: S) => E; +} + +export interface EntityAdapter extends EntityStateAdapter { + selectId: IDSelector; + getInitialState(): EntityState; + getInitialState(state: S): EntityState & S; + getSelectors(): EntitySelectors>; + getSelectors( + selectState: (state: S) => EntityState + ): EntitySelectors; +} diff --git a/projects/loafer/ng-entity/src/lib/unsorted_state_adapter.ts b/projects/loafer/ng-entity/src/lib/unsorted_state_adapter.ts new file mode 100644 index 0000000..129aded --- /dev/null +++ b/projects/loafer/ng-entity/src/lib/unsorted_state_adapter.ts @@ -0,0 +1,189 @@ +import { IDSelector, EntityStateAdapter, EntityState, Update } from './type'; +import { DidMutate, createStateOperator } from './state_adapter'; + +export function createUnsortedStateAdapter(selectId: IDSelector): EntityStateAdapter; +export function createUnsortedStateAdapter(selectId: IDSelector): any { + type R = EntityState; + + function addOneMutably(entity: T, state: R): DidMutate; + function addOneMutably(entity: any, state: any): DidMutate { + const id = selectId(entity); + + if (id in state.entities) { + return DidMutate.None; + } + + state.ids.push(id); + state.entities[id] = entity; + + return DidMutate.Both; + } + + function addManyMutably(entities: T[], state: R): DidMutate; + function addManyMutably(entities: any[], state: any): DidMutate { + let didMutate = false; + + for (const entity of entities) { + didMutate = addOneMutably(entity, state) !== DidMutate.None || didMutate; + } + + return didMutate ? DidMutate.Both : DidMutate.None; + } + + function setOneMutably(entity: T, state: R): DidMutate; + function setOneMutably(entity: any, state: any): DidMutate { + state.ids = []; + state.entities = {}; + + addManyMutably([entity], state); + + return DidMutate.Both; + } + + + function setAllMutably(entities: T[], state: R): DidMutate; + function setAllMutably(entities: any[], state: any): DidMutate { + state.ids = []; + state.entities = {}; + + addManyMutably(entities, state); + + return DidMutate.Both; + } + + function removeOneMutably(id: string | number, state: R): DidMutate; + function removeOneMutably(id: any, state: any): DidMutate { + return removeManyMutably([id], state); + } + + function removeManyMutably(ids: string[] | number[], state: R): DidMutate; + function removeManyMutably(ids: any[], state: any): DidMutate { + const didMutate = + ids + .filter(id => id in state.entities) + .map(id => delete state.entities[id]).length > 0; + + if (didMutate) { + state.ids = state.ids.filter((_id: any) => _id in state.entities); + } + + return didMutate ? DidMutate.Both : DidMutate.None; + } + + function removeAllMutably(state: S): DidMutate; + function removeAllMutably(state: any): DidMutate { + state.ids = []; + state.entities = {}; + + return DidMutate.Both; + } + + function takeNewKey( + keys: { [id: string]: string }, + update: Update, + state: R + ): void; + function takeNewKey( + keys: { [id: string]: any }, + update: Update, + state: any + ): boolean { + const original = state.entities[update.id]; + const updated: T = Object.assign({}, original, update.changes); + const newKey = selectId(updated); + const hasNewKey = newKey !== update.id; + + if (hasNewKey) { + keys[update.id] = newKey; + delete state.entities[update.id]; + } + + state.entities[newKey] = updated; + + return hasNewKey; + } + + function updateOneMutably(update: Update, state: R): DidMutate; + function updateOneMutably(update: any, state: any): DidMutate { + return updateManyMutably([update], state); + } + + function updateManyMutably(updates: Update[], state: R): DidMutate; + function updateManyMutably(updates: any[], state: any): DidMutate { + const newKeys: { [id: string]: string } = {}; + + updates = updates.filter(update => update.id in state.entities); + + const didMutateEntities = updates.length > 0; + + if (didMutateEntities) { + const didMutateIds = + updates.filter(update => takeNewKey(newKeys, update, state)).length > 0; + + if (didMutateIds) { + state.ids = state.ids.map((id: any) => newKeys[id] || id); + return DidMutate.Both; + } else { + return DidMutate.EntitiesOnly; + } + } + + return DidMutate.None; + } + + function upsertOneMutably(entity: T, state: R): DidMutate; + function upsertOneMutably(entity: any, state: any): DidMutate { + return upsertManyMutably([entity], state); + } + + function upsertManyMutably(entities: T[], state: R): DidMutate; + function upsertManyMutably(entities: any[], state: any): DidMutate { + const added: any[] = []; + const updated: any[] = []; + + for (const entity of entities) { + const id = selectId(entity); + if (id in state.entities) { + updated.push({ id, changes: entity }); + } else { + added.push(entity); + } + } + + const didMutateByUpdated = updateManyMutably(updated, state); + const didMutateByAdded = addManyMutably(added, state); + + switch (true) { + case didMutateByAdded === DidMutate.None && + didMutateByUpdated === DidMutate.None: + return DidMutate.None; + case didMutateByAdded === DidMutate.Both || + didMutateByUpdated === DidMutate.Both: + return DidMutate.Both; + default: + return DidMutate.EntitiesOnly; + } + } + + function setErrorMutably(error: E, state: S): DidMutate; + function setErrorMutably(error: any, state: any): DidMutate { + state.error = error; + + return DidMutate.Error; + } + + return { + setOne: createStateOperator(setOneMutably), + setAll: createStateOperator(setAllMutably), + addOne: createStateOperator(addOneMutably), + addMany: createStateOperator(addManyMutably), + updateOne: createStateOperator(updateOneMutably), + updateMany: createStateOperator(updateManyMutably), + upsertOne: createStateOperator(upsertOneMutably), + upsertMany: createStateOperator(upsertManyMutably), + removeOne: createStateOperator(removeOneMutably), + removeMany: createStateOperator(removeManyMutably), + removeAll: createStateOperator(removeAllMutably), + setError: createStateOperator(setErrorMutably), + }; +} diff --git a/projects/loafer/ng-entity/src/public_api.ts b/projects/loafer/ng-entity/src/public_api.ts new file mode 100644 index 0000000..51faeb5 --- /dev/null +++ b/projects/loafer/ng-entity/src/public_api.ts @@ -0,0 +1,6 @@ +/* + * Public API Surface of ng-entity + */ + +export { createEntityAdapter } from './lib/adapter'; +export { EntityState, EntityAdapter } from './lib/type'; diff --git a/projects/loafer/ng-entity/src/test.ts b/projects/loafer/ng-entity/src/test.ts new file mode 100644 index 0000000..e11ff1c --- /dev/null +++ b/projects/loafer/ng-entity/src/test.ts @@ -0,0 +1,22 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files + +import 'core-js/es7/reflect'; +import 'zone.js/dist/zone'; +import 'zone.js/dist/zone-testing'; +import { getTestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting +} from '@angular/platform-browser-dynamic/testing'; + +declare const require: any; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting() +); +// Then we find all the tests. +const context = require.context('./', true, /\.spec\.ts$/); +// And load the modules. +context.keys().map(context); diff --git a/projects/loafer/ng-entity/tsconfig.lib.json b/projects/loafer/ng-entity/tsconfig.lib.json new file mode 100644 index 0000000..2e5b23d --- /dev/null +++ b/projects/loafer/ng-entity/tsconfig.lib.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/lib", + "target": "es2015", + "module": "es2015", + "moduleResolution": "node", + "declaration": true, + "sourceMap": true, + "inlineSources": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "importHelpers": true, + "types": [], + "lib": [ + "dom", + "es2015" + ] + }, + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "fullTemplateTypeCheck": true, + "strictInjectionParameters": true, + "flatModuleId": "AUTOGENERATED", + "flatModuleOutFile": "AUTOGENERATED" + }, + "exclude": [ + "src/test.ts", + "**/*.spec.ts" + ] +} diff --git a/projects/loafer/ng-entity/tsconfig.spec.json b/projects/loafer/ng-entity/tsconfig.spec.json new file mode 100644 index 0000000..4acf941 --- /dev/null +++ b/projects/loafer/ng-entity/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/spec", + "types": [ + "jasmine", + "node" + ] + }, + "files": [ + "src/test.ts" + ], + "include": [ + "**/*.spec.ts", + "**/*.d.ts" + ] +} diff --git a/projects/loafer/ng-entity/tslint.json b/projects/loafer/ng-entity/tslint.json new file mode 100644 index 0000000..cfa81f5 --- /dev/null +++ b/projects/loafer/ng-entity/tslint.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tslint.json", + "rules": { + "directive-selector": [ + true, + "attribute", + "lib", + "camelCase" + ], + "component-selector": [ + true, + "element", + "lib", + "kebab-case" + ] + } +} diff --git a/projects/loafer/ng-rpc/package.json b/projects/loafer/ng-rpc/package.json index e69f5e7..03eddd7 100644 --- a/projects/loafer/ng-rpc/package.json +++ b/projects/loafer/ng-rpc/package.json @@ -1,6 +1,6 @@ { "name": "@loafer/ng-rpc", - "version": "0.0.6", + "version": "0.0.1", "repository": { "type": "git", "url": "https://git.loafle.net/loafer/ng.git" diff --git a/tsconfig.json b/tsconfig.json index f48f5d7..7df9f6a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,6 +25,9 @@ ], "@loafer/ng-rpc": [ "dist/loafer/ng-rpc" + ], + "@loafer/ng-entity": [ + "dist/loafer/ng-entity" ] } }