Skip to content

Instantly share code, notes, and snippets.

@nilscox
Created April 11, 2023 09:07
Show Gist options
  • Save nilscox/34ab1a740baf77fdb16983e2fbf2c5f8 to your computer and use it in GitHub Desktop.
Save nilscox/34ab1a740baf77fdb16983e2fbf2c5f8 to your computer and use it in GitHub Desktop.
Browser storage abstraction
import { Storage, StorageApi, storedString, storedValue } from './storage'
class MockStorageApi implements StorageApi {
public items = new Map<string, string>()
getItem(key: string): string | null {
return this.items.get(key) ?? null
}
setItem(key: string, value: string): void {
this.items.set(key, value)
}
removeItem(key: string): void {
this.items.delete(key)
}
}
describe('storage', () => {
it('reads a stored value', () => {
const mockStorage = new MockStorageApi()
const storage = new Storage(mockStorage)
mockStorage.items.set('empty', '')
mockStorage.items.set('key', 'value')
mockStorage.items.set('json', '{"answer": 42}')
expect(storage.read('nope')).toBeUndefined()
expect(storage.read('empty')).toEqual('')
expect(storage.read('key')).toEqual('value')
expect(storage.read('json', JSON.parse)).toEqual({ answer: 42 })
})
it('writes a value to the storage', () => {
const mockStorage = new MockStorageApi()
const storage = new Storage(mockStorage)
storage.write('empty', '')
storage.write('key', 'value')
storage.write('json', { answer: 42 }, JSON.stringify)
expect(mockStorage.items.get('empty')).toEqual('')
expect(mockStorage.items.get('key')).toEqual('value')
expect(mockStorage.items.get('json')).toEqual('{"answer":42}')
})
it('deletes a value from the storage', () => {
const mockStorage = new MockStorageApi()
const storage = new Storage(mockStorage)
mockStorage.items.set('key', 'value')
storage.remove('key')
expect(mockStorage.items.has('key')).toBe(false)
})
})
describe('storage value', () => {
it('reads, writes and deletes a stored string', () => {
const mockStorage = new MockStorageApi()
const value = storedString(new Storage(mockStorage), 'key')
value.write('value')
expect(value.read()).toEqual('value')
value.remove()
expect(value.read()).toBeUndefined()
})
it('reads, writes and deletes a stored object', () => {
const mockStorage = new MockStorageApi()
const value = storedValue(new Storage(mockStorage), 'key')
value.write({ answer: 42 })
expect(value.read()).toEqual({ answer: 42 })
value.remove()
expect(value.read()).toBeUndefined()
})
it('fallback to default value', () => {
const mockStorage = new MockStorageApi()
const value = storedString(new Storage(mockStorage), 'key', 'default')
expect(value.read()).toEqual('default')
value.write('value')
expect(value.read()).not.toEqual('default')
value.remove()
expect(value.read()).toEqual('default')
})
})
/**
* Local storage and session storage abstraction
*
* @example usage of the localStorage and sessionStorage exported variables
* import { localStorage } from './storage';
*
* localStorage.read('key') // returns the local storage's value
* localStorage.read('key', JSON.parse) // parse the value before returning it
*
* localStorage.write('key', 'value') // stores the string 'value'
* localStorage.write('key', { enable: true }, JSON.stringify) // stores an object in JSON format
*
* localStorage.remove('key') // deletes the local storage's value
*
* @example usage of the storedString and storedValue exported functions
* import { storedValue } from './storage';
*
* const flag = storedValue(localStorage, 'my-flag');
*
* flag.read(); // returns the (parsed) local storage's value for the key 'my-flag'
* flag.write({ enable: true }); // sets a (json) value
* flag.remove(); // you know what this does, don't you?
*/
type Parse<T> = (serialized: string) => T
type Serialize<T> = (value: T) => string
export interface StorageApi {
getItem(key: string): string | null
setItem(key: string, value: string): void
removeItem(key: string): void
}
export class Storage {
private readonly storage?: StorageApi
// storybook's transpiler (babel) does not support class attributes assignment from constructor parameters
// a better solution would be to mock the browser storages in storybook, somehow
constructor(storage: StorageApi | undefined) {
this.storage = storage
}
read(key: string): string | undefined
read<T>(key: string, parse: Parse<T>): T | undefined
read<T>(key: string, parse?: Parse<T>): string | T | undefined {
if (!this.storage) {
return
}
const value = this.storage.getItem(key)
if (value === null) {
return
}
if (!parse) {
return value
}
try {
return parse(value)
} catch (error) {
console.warn('cannot parse local storage item', { key, value })
}
}
write(key: string, value: string): void
write<T>(key: string, value: T, serialize: Serialize<T>): void
write<T>(key: string, value: T | string, serialize?: Serialize<T>): void {
if (!this.storage) {
return
}
const serialized = serialize ? serialize(value as T) : (value as string)
this.storage.setItem(key, serialized)
}
remove(key: string): void {
if (!this.storage) {
return
}
this.storage.removeItem(key)
}
}
class StoredValue<T> {
private readonly storage: Storage
private readonly key: string
private readonly defaultValue?: T
private readonly parse: Parse<T>
private readonly serialize: Serialize<T>
private listeners: Array<(value: T | undefined) => void> = []
// see Storage's constructor
constructor(
storage: Storage,
key: string,
defaultValue: T | undefined,
parse: Parse<T>,
serialize: Serialize<T>
) {
this.storage = storage
this.key = key
this.defaultValue = defaultValue
this.parse = parse
this.serialize = serialize
}
addListener(onValueChange: (value: T | undefined) => void) {
const index = this.listeners.push(onValueChange) - 1
return () => {
this.listeners.splice(index, 1)
}
}
private triggerListeners(value: T | undefined) {
for (const listener of this.listeners) {
listener(value)
}
}
read() {
return this.storage.read(this.key, this.parse) ?? this.defaultValue
}
write(value: T) {
this.storage.write(this.key, value, this.serialize)
this.triggerListeners(value)
}
remove() {
this.storage.remove(this.key)
this.triggerListeners(undefined)
}
}
const identity = (input: string) => input
export const storedString = (storage: Storage, key: string, defaultValue?: string) => {
return new StoredValue(storage, key, defaultValue, identity, identity)
}
export const storedValue = <T>(storage: Storage, key: string, defaultValue?: T) => {
return new StoredValue<T>(storage, key, defaultValue, JSON.parse, JSON.stringify)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment