Skip to content

Commit

Permalink
feat(with-history): creat withHistory Function
Browse files Browse the repository at this point in the history
  • Loading branch information
mzkmnk committed Jan 9, 2025
1 parent 1a7091e commit a10261e
Show file tree
Hide file tree
Showing 11 changed files with 285 additions and 165 deletions.
11 changes: 10 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
"indentStyle": "tab",
"lineWidth": 120
},
"organizeImports": {
"enabled": true
Expand All @@ -20,6 +21,14 @@
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noConsole": {
"level": "warn",
"options": {
"allow": ["warn", "error"]
}
}
},
"complexity": {
"noBannedTypes": {
"level": "off"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import { patchStateWithImmer } from '@/projects/ngrx-extension/src/lib/patch-state-with-immer/patch-state-with-immer';
import {
type AppState,
generateProductsItem,
initialAppState,
} from '@/testsData/model';
import { type AppState, generateProductsItem, initialAppState } from '@/testsData/model';
import { TestBed } from '@angular/core/testing';
import { getState, signalStore, withMethods, withState } from '@ngrx/signals';

Expand Down
112 changes: 112 additions & 0 deletions projects/ngrx-extension/src/lib/with-history/with-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { effect } from '@angular/core';
import { getState, patchState, signalStoreFeature, withHooks, withMethods } from '@ngrx/signals';

export const STATE_HISTORY = Symbol('STATE_HISTORY');

export type TStateHistory<State> = {
stateVersions: State[];
currentVersionIndex: number;
};

export type Config = {
maxLength?: number;
sync?: boolean;
};

// todo next reference
// historiesChangeDetFn?: (
// store: Prettify<StateSignals<Input['state']> & Input['props'] & Input['methods'] & WritableSignal<Input['state']>>,
// histories: State[],
// ) => void;

export function withHistory<State extends object>({ maxLength = 100, sync = true }: Config) {
/** このオブジェクトにstateの変更履歴を保存する */
const stateHistory: { [STATE_HISTORY]: TStateHistory<State> } = {
[STATE_HISTORY]: {
stateVersions: [],
currentVersionIndex: 0,
},
};

/** この関数内でstateを書き換え場合trueとする */
let dirty = false;

return signalStoreFeature(
withMethods((store) => ({
/** この関数を呼び出すことでstateの変更履歴を一つ前に戻す */
undo() {
// currentVersionIndexが1の時は何もしない
if (stateHistory[STATE_HISTORY].currentVersionIndex <= 1) {
return;
}

// 現在のバージョンのインデックスを一つ前に戻す
stateHistory[STATE_HISTORY].currentVersionIndex--;

const { stateVersions, currentVersionIndex } = stateHistory[STATE_HISTORY];

// ストアの更新
dirty = true;

patchState(store, stateVersions[currentVersionIndex - 1]);
},

/** この関数を呼び出すことでstateの変更履歴を一つ進める */
redo() {
// currentVersionIndexがstateVersionsの長さと同等なら最新なため何もしない
if (stateHistory[STATE_HISTORY].currentVersionIndex >= stateHistory[STATE_HISTORY].stateVersions.length) {
return;
}

stateHistory[STATE_HISTORY].currentVersionIndex++;

const { stateVersions, currentVersionIndex } = stateHistory[STATE_HISTORY];

// ストアの更新
dirty = true;

patchState(store, stateVersions[currentVersionIndex - 1]);
},

/** 履歴を削除する */
clearHistories() {
stateHistory[STATE_HISTORY].stateVersions = stateHistory[STATE_HISTORY].stateVersions.filter(
(_, index) => index + 1 === stateHistory[STATE_HISTORY].currentVersionIndex,
);
stateHistory[STATE_HISTORY].currentVersionIndex = 1;
},
})),

withHooks({
onInit(store) {
if (sync) {
effect(() =>
((state) => {
//
if (dirty) {
dirty = false;
return;
}

// バージョンの管理を行う。
const { stateVersions, currentVersionIndex } = stateHistory[STATE_HISTORY];

// currentVersionIndexが末尾でない場合は、currentVersionIndex以降の履歴を削除し新たに追加を行う
if (stateVersions.length !== currentVersionIndex) {
stateHistory[STATE_HISTORY].stateVersions.splice(currentVersionIndex, stateVersions.length - 1);
}

// 最大長を超えた場合は先頭を削除
if (currentVersionIndex >= maxLength) {
stateHistory[STATE_HISTORY].stateVersions.shift();
}

stateHistory[STATE_HISTORY].stateVersions.push(state as State);
stateHistory[STATE_HISTORY].currentVersionIndex = stateHistory[STATE_HISTORY].stateVersions.length;
})(getState(store)),
);
}
},
}),
);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import {
type AppState,
generateProductsItem,
initialAppState,
} from '@/testsData/model';
import { type AppState, generateProductsItem, initialAppState } from '@/testsData/model';
import { TestBed } from '@angular/core/testing';
import { getState, patchState, signalStore, withState } from '@ngrx/signals';
import { withStorageSync } from './with-storage-sync';
Expand All @@ -13,15 +9,9 @@ describe('withStorageSync', () => {
});

it('should retrieve values from storage during initialization and update the store state based on keys', () => {
const expectedProductsItems = [
generateProductsItem(),
generateProductsItem(),
];
const expectedProductsItems = [generateProductsItem(), generateProductsItem()];

localStorage.setItem(
'products-items',
JSON.stringify(expectedProductsItems),
);
localStorage.setItem('products-items', JSON.stringify(expectedProductsItems));

TestBed.runInInjectionContext(() => {
const Store = signalStore(
Expand Down Expand Up @@ -210,9 +200,7 @@ describe('withStorageSync', () => {
...initialAppState,
products: {
...initialAppState.products,
items: JSON.parse(
localStorage.getItem(`${prefix}-products-items`) ?? '[]',
),
items: JSON.parse(localStorage.getItem(`${prefix}-products-items`) ?? '[]'),
},
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,7 @@ export type TConfig = {
* @param config Optional settings (if `sync` is set to true, any state change is automatically written to Storage).
* @returns An NgRx Signals store feature object providing methods and hooks for state synchronization.
*/
export function withStorageSync({
storage,
nodes,
prefix,
sync,
}: TConfig): SignalStoreFeature<
export function withStorageSync({ storage, nodes, prefix, sync }: TConfig): SignalStoreFeature<
EmptyFeatureResult,
{
state: {};
Expand All @@ -48,29 +43,21 @@ export function withStorageSync({
writeToStorage(): void {
const currentState = getState(store) as Record<string, unknown>;

writeDfs(
currentState,
nodes,
prefix,
(key, fullKeyPath, objectState) => {
// If the store does not have the specified key
if (!Object.hasOwn(objectState as Record<string, object>, key)) {
throw new Error(`[${key}] ${key} not found`);
}

// The store has the key, but it is undefined
// todo: Instead of throwing an error, returning early might be preferable
if (
typeof (objectState as Record<string, object>)[key] ===
'undefined'
) {
throw new Error(`state[${key}] type is undefined`);
}

const value: object = (objectState as Record<string, object>)[key];
storage.setItem(fullKeyPath, JSON.stringify(value));
},
);
writeDfs(currentState, nodes, prefix, (key, fullKeyPath, objectState) => {
// If the store does not have the specified key
if (!Object.hasOwn(objectState as Record<string, object>, key)) {
throw new Error(`[${key}] ${key} not found`);
}

// The store has the key, but it is undefined
// todo: Instead of throwing an error, returning early might be preferable
if (typeof (objectState as Record<string, object>)[key] === 'undefined') {
throw new Error(`state[${key}] type is undefined`);
}

const value: object = (objectState as Record<string, object>)[key];
storage.setItem(fullKeyPath, JSON.stringify(value));
});
},

// Reads data from the storage and saves it into the store
Expand All @@ -82,15 +69,8 @@ export function withStorageSync({
return;
}

const slicedKeys: string[] = fullKeyPath
.split('-')
.filter((x) => x !== prefix);
const recordState = createObject(
jsonString,
slicedKeys,
slicedKeys.length - 1,
{},
);
const slicedKeys: string[] = fullKeyPath.split('-').filter((x) => x !== prefix);
const recordState = createObject(jsonString, slicedKeys, slicedKeys.length - 1, {});

patchState(store, (prevState) => {
return R.mergeDeep(prevState, recordState);
Expand Down Expand Up @@ -155,11 +135,7 @@ function writeDfs(
* @param prefix A combined prefix string from parent nodes, etc.
* @param callback A callback that receives the final key (fullKeyPath).
*/
function readDfs(
nodes: TNodeItem[],
prefix: string,
callback: (fullKeyPath: string) => void,
): void {
function readDfs(nodes: TNodeItem[], prefix: string, callback: (fullKeyPath: string) => void): void {
for (const node of nodes) {
if (typeof node === 'string') {
const fullPathKey = prefix === '' ? node : `${prefix}-${node}`;
Expand Down
11 changes: 2 additions & 9 deletions src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
import {
type ApplicationConfig,
provideZoneChangeDetection,
} from '@angular/core';
import { type ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';

import { provideStore } from '@ngrx/store';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideStore(),
],
providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideStore()],
};
16 changes: 9 additions & 7 deletions src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,21 @@ export const routes: Routes = [
{
path: 'with-storage-sync',
loadComponent: () =>
import('./pages/with-storage-sync/with-storage-sync.component').then(
(M) => M.WithStorageSyncComponent,
),
import('./pages/with-storage-sync/with-storage-sync.component').then((M) => M.WithStorageSyncComponent),
},
{
path: 'patch-state-with-immer',
loadComponent: () =>
import(
'./pages/patch-state-with-immer/patch-state-with-immer.component'
).then((M) => M.PatchStateWithImmerComponent),
import('./pages/patch-state-with-immer/patch-state-with-immer.component').then(
(M) => M.PatchStateWithImmerComponent,
),
},
{
path: 'with-history',
loadComponent: () => import('./pages/with-history/with-history.component').then((M) => M.WithHistoryComponent),
},
{
path: '**',
redirectTo: 'patch-state-with-immer',
redirectTo: 'with-history',
},
];
58 changes: 58 additions & 0 deletions src/app/pages/with-history/with-history.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { patchStateWithImmer } from '@/projects/ngrx-extension/src/lib/patch-state-with-immer/patch-state-with-immer';
import { withHistory } from '@/projects/ngrx-extension/src/lib/with-history/with-history';
import { Component, effect, inject } from '@angular/core';
import { faker } from '@faker-js/faker';
import { signalStore, withMethods, withState } from '@ngrx/signals';

export type TUser = {
name: string;
age: number;
};

export type TUserState = {
user: TUser;
};

export const UserSignalStore = signalStore(
withState<TUserState>({ user: { name: 'John Doe', age: 30 } }),
withHistory({}),
withMethods((store) => ({
editName(name: string): void {
patchStateWithImmer(store, (state) => {
state.user.name = name;
});
},
})),
);

@Component({
selector: 'app-with-history',
providers: [UserSignalStore],
template: `
<div>
<h1 class="text-3xl">With History</h1>
<p>{{ userSignalStore.user.name() }}</p>
<p>{{ userSignalStore.user.age() }}</p>
<div class="flex flex-row gap-3 ">
<button (click)="editName()" class="bg-green-500 rounded-lg p-2 text-white">change name</button>
<button (click)="userSignalStore.undo()" class="bg-red-500 rounded-lg p-2 text-white">undo</button>
<button (click)="userSignalStore.redo()" class="bg-blue-500 rounded-lg p-2 text-white">redo</button>
<button (click)="userSignalStore.clearHistories()" class="bg-purple-500 rounded-lg p-2 text-white">clear
</button>
</div>
</div>
`,
})
export class WithHistoryComponent {
userSignalStore = inject(UserSignalStore);

constructor() {
effect(() => {
// console.log(this.userSignalStore.)
});
}

editName(): void {
this.userSignalStore.editName(faker.person.firstName());
}
}
6 changes: 1 addition & 5 deletions src/app/pages/with-storage-sync/shop.signal-store.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import {
type AppState,
type UserState,
initialAppState,
} from '@/testsData/model';
import { type AppState, type UserState, initialAppState } from '@/testsData/model';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { withStorageSync } from 'ngrx-extension';

Expand Down
Loading

0 comments on commit a10261e

Please sign in to comment.