diff --git a/README.md b/README.md index 490f7262..28f16677 100644 --- a/README.md +++ b/README.md @@ -290,12 +290,12 @@ function* handleFetchLandAmountRequest(action: FetchLandAmountRequestAction) { ## Storage -The storage module allows you to save parts of the redux store in localStorage to make them persistent. -This module is required to use other modules like `Transaction` and `Translation`. +The storage module allows you to save parts of the redux store in localStorage to make them persistent and migrate it from different versions without loosing it. +This module is required to use other modules like `Transaction`, `Translation` and `Wallet`. ### Installation -You need to add a middleware and a two reducers to your dApp. +You need to add a middleware and two reducers to your dApp. **Middleware**: @@ -305,15 +305,17 @@ You will need to create a `storageMiddleware` and add apply it along with your o // store.ts import { applyMiddleware, compose, createStore } from 'redux' import { createStorageMiddleware } from 'decentraland-dapps/dist/modules/storage/middleware' +import { migrations } from './migrations' const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose -const { storageMiddleware, loadStorageMiddleware } = createStorageMiddleware( - 'storage-key', // this is the key used to save the state in localStorage (required) - [], // array of paths from state to be persisted (optional) - [] // array of actions types that will trigger a SAVE (optional) -) +const { storageMiddleware, loadStorageMiddleware } = createStorageMiddleware({ + storageKey: 'storage-key' // this is the key used to save the state in localStorage (required) + paths: [] // array of paths from state to be persisted (optional) + actions: [] // array of actions types that will trigger a SAVE (optional) + migrations: migrations // migration object that will migrate your localstorage (optional) +}) const middleware = applyMiddleware( // your other middlewares @@ -325,6 +327,29 @@ const store = createStore(rootReducer, enhancer) loadStorageMiddleware(store) ``` +**Migrations**: + +`migrations` looks like + +`migrations.ts`: + +```ts +export const migrations = { + 2: migrateToVersion2(data), + 3: migrateToVersion3(data) +} +``` + +Where every `key` represent a migration and every `method` should return the new localstorage data: + +```ts +function migrateToVersion2(data) { + return omit(data, 'translations') +} +``` + +You don't need to care about updating the version of the migration because it will be set automatically. + **Reducer**: You will need to add `storageReducer` as `storage` to your `rootReducer` and then wrap the whole reducer with `storageReducerWrapper` @@ -347,7 +372,7 @@ export const rootReducer = storageReducerWrapper( ### Advanced Usage -This module is necessary to use other modules like `Transaction` or `Translation`, but you can also use it to make other parts of your dApp's state persistent +This module is necessary to use other modules like `Transaction`, `Translation` and `Wallet`, but you can also use it to make other parts of your dApp's state persistent
Learn More

diff --git a/package.json b/package.json index 72ca614e..df1a12ad 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "scripts": { "prebuild": "rimraf dist", "build": "tsc --project tsconfig.json", - "test": "nyc mocha --require ts-node/register src/**/*.spec.ts", + "test": "nyc mocha --require ts-node/register src/**/*.spec.ts src/**/**/*.spec.ts", "test:watch": "npm test -- --watch --watch-extensions ts", "semantic-release": "semantic-release", "commitmsg": "validate-commit-msg" diff --git a/src/lib/localStorage.spec.ts b/src/lib/localStorage.spec.ts new file mode 100644 index 00000000..90484255 --- /dev/null +++ b/src/lib/localStorage.spec.ts @@ -0,0 +1,73 @@ +import { expect } from 'chai' +import { Migrations } from './types' +import { + hasLocalStorage, + migrateStorage, + getLocalStorage +} from './localStorage' +declare var global: any +let fakeStore = {} +global.window = {} + +describe('localStorage', function() { + const migrations: Migrations = { + 2: (data: any) => ({ ...data, data: 'new version' }) + } + + beforeEach(function() { + fakeStore = {} + global.window['localStorage'] = { + getItem: (key: string) => fakeStore[key], + setItem: (key: string, value: string) => (fakeStore[key] = value), + removeItem: (key: string) => delete fakeStore[key] + } + }) + + describe('hasLocalStorage', function() { + it('should return false if localStorage is not available', function() { + delete global.window['localStorage'] + expect(hasLocalStorage()).to.equal(false) + }) + it('should return true if localStorage is available', function() { + expect(hasLocalStorage()).to.equal(true) + }) + }) + + describe('migrateStorage', function() { + it('should migrate', function() { + const key = 'key' + const localStorage = getLocalStorage() + localStorage.setItem(key, JSON.stringify('{}')) + let data = JSON.parse(localStorage.getItem(key) as string) + expect(data.storage).to.equal(undefined) + migrateStorage(key, migrations) + data = JSON.parse(localStorage.getItem(key) as string) + expect(data.storage.version).to.equal(2) + expect(data.data).to.equal('new version') + }) + + it('should set corrent version', function() { + const key = 'key' + const localStorage = getLocalStorage() + + localStorage.setItem(key, JSON.stringify('{ storage: { version: null }}')) + migrateStorage(key, migrations) + let data = JSON.parse(localStorage.getItem(key) as string) + expect(data.storage.version).to.equal(2) + }) + + it('should not migrate if there is no migrations left', function() { + const key = 'key' + const localStorage = getLocalStorage() + localStorage.setItem(key, JSON.stringify('{}')) + let data = JSON.parse(localStorage.getItem(key) as string) + expect(data.storage).to.equal(undefined) + migrateStorage(key, migrations) + data = JSON.parse(localStorage.getItem(key) as string) + expect(data.storage.version).to.equal(2) + + migrateStorage(key, migrations) + expect(data.storage.version).to.equal(2) + }) + }) +}) diff --git a/src/lib/localStorage.ts b/src/lib/localStorage.ts index a59fba03..f4521d93 100644 --- a/src/lib/localStorage.ts +++ b/src/lib/localStorage.ts @@ -1,3 +1,5 @@ +import { Migrations, LocalStorage } from './types' + export function hasLocalStorage(): boolean { try { // https://gist.github.com/paulirish/5558557 @@ -11,6 +13,39 @@ export function hasLocalStorage(): boolean { } } -export const localStorage = hasLocalStorage() - ? window.localStorage - : { getItem: () => null, setItem: () => null, removeItem: () => null } +export function getLocalStorage(): LocalStorage { + return hasLocalStorage() + ? window.localStorage + : { + getItem: () => null, + setItem: () => null, + removeItem: () => null + } +} + +export function migrateStorage(key: string, migrations: Migrations) { + let version = 1 + const localStorage = getLocalStorage() + const dataString = localStorage.getItem(key) + + if (dataString) { + const data = JSON.parse(dataString as string) + + if (data.storage && data.storage.version) { + version = parseInt(data.storage.version, 10) + } + let nextVersion = version + 1 + + while (migrations[nextVersion]) { + const newData = migrations[nextVersion](data) + localStorage.setItem( + key, + JSON.stringify({ + ...(newData as Object), + storage: { version: nextVersion } + }) + ) + nextVersion++ + } + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 4c6a7bd2..1e7f4d1a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -14,3 +14,13 @@ export type ModelByAddress = DataByKey export type Omit = Pick> export type Overwrite = Pick> & T2 + +export interface Migrations { + [key: string]: (data: T) => T +} + +export interface LocalStorage { + getItem: (key?: string) => string | null + setItem: (key?: string, value?: string) => void | null + removeItem: (key?: string) => void | null +} diff --git a/src/modules/storage/middleware.ts b/src/modules/storage/middleware.ts index 4b58b49c..2ca2d1ea 100644 --- a/src/modules/storage/middleware.ts +++ b/src/modules/storage/middleware.ts @@ -1,9 +1,10 @@ import * as storage from 'redux-storage' import createStorageEngine from 'redux-storage-engine-localstorage' import filter from 'redux-storage-decorator-filter' -import { hasLocalStorage } from '../../lib/localStorage' +import { hasLocalStorage, migrateStorage } from '../../lib/localStorage' import { disabledMiddleware } from '../../lib/disabledMiddleware' import { STORAGE_LOAD } from './actions' +import { StorageMiddleware } from './types' import { CHANGE_LOCALE, FETCH_TRANSLATIONS_REQUEST, @@ -19,11 +20,9 @@ import { const disabledLoad = (store: any) => setTimeout(() => store.dispatch({ type: STORAGE_LOAD, payload: {} })) -export function createStorageMiddleware( - storageKey: string, - paths: string[] | string[][] = [], - actions: string[] = [] -) { +export function createStorageMiddleware(options: StorageMiddleware) { + const { storageKey, migrations = {}, paths = [], actions = [] } = options + if (!hasLocalStorage()) { return { storageMiddleware: disabledMiddleware as any, @@ -31,11 +30,14 @@ export function createStorageMiddleware( } } + migrateStorage(storageKey, migrations) + const storageEngine = filter(createStorageEngine(storageKey), [ 'transaction', 'translation', ['wallet', 'data', 'locale'], ['wallet', 'data', 'derivationPath'], + ['storage', 'version'], ...paths ]) const storageMiddleware: any = storage.createMiddleware( diff --git a/src/modules/storage/reducer.ts b/src/modules/storage/reducer.ts index e4d664b2..18f6ce95 100644 --- a/src/modules/storage/reducer.ts +++ b/src/modules/storage/reducer.ts @@ -3,10 +3,12 @@ import * as storage from 'redux-storage' import { STORAGE_LOAD } from './actions' export type StorageState = { + version: number loading: boolean } export const INITIAL_STATE: StorageState = { + version: 1, loading: true } @@ -21,6 +23,7 @@ export function storageReducer(state = INITIAL_STATE, action: AnyAction) { switch (action.type) { case STORAGE_LOAD: return { + ...state, loading: false } default: diff --git a/src/modules/storage/types.ts b/src/modules/storage/types.ts new file mode 100644 index 00000000..0624f617 --- /dev/null +++ b/src/modules/storage/types.ts @@ -0,0 +1,8 @@ +import { Migrations } from '../../lib/types' + +export interface StorageMiddleware { + storageKey: string + paths: string[] | string[][] + actions: string[] + migrations: Migrations +}