diff --git a/README.md b/README.md index bd41ae1..7df2547 100755 --- a/README.md +++ b/README.md @@ -107,6 +107,8 @@ Vue.use(VueCompositionAPI); [![Demo](https://img.shields.io/badge/demo-🚀-yellow.svg)](https://microcipcip.github.io/vue-use-kit/?path=/story/side-effects-usebeforeunload--demo) - [`useCookie`](./src/components/useCookie/stories/useCookie.md) — provides way to read, update and delete a cookie. [![Demo](https://img.shields.io/badge/demo-🚀-yellow.svg)](https://microcipcip.github.io/vue-use-kit/?path=/story/side-effects-usecookie--demo) + - [`useLocalStorage`](./src/components/useLocalStorage/stories/useLocalStorage.md) — provides way to read, update and delete a localStorage key. + [![Demo](https://img.shields.io/badge/demo-🚀-yellow.svg)](https://microcipcip.github.io/vue-use-kit/?path=/story/side-effects-uselocalstorage--demo) - UI - [`useClickAway`](./src/components/useClickAway/stories/useClickAway.md) — triggers callback when user clicks outside target area. [![Demo](https://img.shields.io/badge/demo-🚀-yellow.svg)](https://microcipcip.github.io/vue-use-kit/?path=/story/ui-useclickaway--demo) diff --git a/src/components/useCookie/stories/UseCookieDemo.vue b/src/components/useCookie/stories/UseCookieDemo.vue index 5b46943..ed0113b 100755 --- a/src/components/useCookie/stories/UseCookieDemo.vue +++ b/src/components/useCookie/stories/UseCookieDemo.vue @@ -52,7 +52,9 @@ export default Vue.extend({ cookie: jsonCookie, setCookie: jsonSetCookie, removeCookie: jsonRemoveCookie - } = useCookie('jsonCookie') + } = useCookie('jsonCookie', { + isParsing: true + }) let counter = 0 const handleSetCookie = () => { diff --git a/src/components/useCookie/stories/useCookie.md b/src/components/useCookie/stories/useCookie.md index 86ec705..5466028 100755 --- a/src/components/useCookie/stories/useCookie.md +++ b/src/components/useCookie/stories/useCookie.md @@ -4,10 +4,18 @@ Vue function that provides way to read, set and remove a cookie. ## Reference +```typescript +interface UseCookieOptions { + isParsing: boolean + serializer?: SerializerFunction + deserializer?: DeserializerFunction +} +``` + ```typescript function useCookie( cookieName: string, - enableParseJSON?: boolean, + options?: UseCookieOptions, runOnMount?: boolean ): { cookie: Ref @@ -20,7 +28,10 @@ function useCookie( ### Parameters - `cookieName: string` the cookie name you wish to get/set/remove -- `enableParseJSON: boolean` whether to enable JSON parsing or not, `false` by default +- `options: UseCookieOptions` + - `isParsing: boolean` whether to enable parsing the cookie value or not, `false` by default + - `serializer: SerializerFunction` a custom serializer, `JSON.stringify` by default + - `deserializer: DeserializerFunction` a custom deserializer, `JSON.parse` by default - `runOnMount: boolean` whether to get the cookie on mount or not, `true` by default ### Returns @@ -38,8 +49,7 @@ function useCookie( ```html ``` diff --git a/src/components/useCookie/useCookie.spec.ts b/src/components/useCookie/useCookie.spec.ts index b0c06aa..1464603 100755 --- a/src/components/useCookie/useCookie.spec.ts +++ b/src/components/useCookie/useCookie.spec.ts @@ -9,7 +9,7 @@ afterEach(() => { const testComponent = ( cookieName = 'cookieName', cookieValue: any = '', - parseJson = false, + opts = { isParsing: false } as any, onMount = true ) => ({ template: ` @@ -26,7 +26,7 @@ const testComponent = ( setup() { const { cookie, getCookie, setCookie, removeCookie } = useCookie( cookieName, - parseJson, + opts, onMount ) @@ -72,10 +72,12 @@ describe('useCookie', () => { expect(document.cookie).not.toContain(cookieValue) }) - it('should correctly get and set the parseToJson object', async () => { + it('should correctly get and set the isParsing object', async () => { const cookieName = 'cookieName' const cookieValue = { value1: 'testValue1', value2: 'testValue2' } - const wrapper = mount(testComponent(cookieName, cookieValue, true)) + const wrapper = mount( + testComponent(cookieName, cookieValue, { isParsing: true }) + ) wrapper.find('#setCookie').trigger('click') await wrapper.vm.$nextTick() expect(wrapper.find('#cookieJson').html()).toContain(cookieValue.value1) @@ -87,4 +89,86 @@ describe('useCookie', () => { expect(wrapper.find('#cookieJson').html()).toContain(cookieValue.value1) expect(wrapper.find('#cookieJson').html()).toContain(cookieValue.value2) }) + + it('should correctly get using the deserializer', async () => { + const cookieName = 'cookieName' + const cookieValue = { value1: 'testValue1', value2: 'testValue2' } + const deserializerVal = { value1: 'gatto', value2: 'topo' } + const wrapper = mount( + testComponent(cookieName, cookieValue, { + isParsing: true, + deserializer: () => deserializerVal + }) + ) + await wrapper.vm.$nextTick() + expect(wrapper.find('#cookieJson').html()).toContain(deserializerVal.value1) + expect(wrapper.find('#cookieJson').html()).toContain(deserializerVal.value2) + }) + + it('should ignore deserializer when isParsing is false', async () => { + const cookieName = 'cookieName' + const cookieValue = { value1: 'testValue1', value2: 'testValue2' } + const deserializerVal = { value1: 'gatto', value2: 'topo' } + const wrapper = mount( + testComponent(cookieName, cookieValue, { + isParsing: false, + deserializer: () => deserializerVal + }) + ) + await wrapper.vm.$nextTick() + expect(wrapper.find('#cookie').html()).toContain(cookieValue.value1) + expect(wrapper.find('#cookie').html()).toContain(cookieValue.value2) + expect(wrapper.find('#cookieJson').html()).not.toContain( + deserializerVal.value1 + ) + expect(wrapper.find('#cookieJson').html()).not.toContain( + deserializerVal.value2 + ) + }) + + it('should correctly set the object using the serializer', async () => { + const cookieName = 'cookieName' + const cookieValue = { value1: 'testValue1', value2: 'testValue2' } + const serializerVal = { value1: 'testValue1+1', value2: 'testValue2+1' } + const wrapper = mount( + testComponent(cookieName, cookieValue, { + isParsing: true, + serializer: (obj: any) => + JSON.stringify({ + value1: `${obj.value1}+1`, + value2: `${obj.value2}+1` + }) + }) + ) + wrapper.find('#setCookie').trigger('click') + wrapper.find('#getCookie').trigger('click') + await wrapper.vm.$nextTick() + expect(wrapper.find('#cookieJson').html()).toContain(serializerVal.value1) + expect(wrapper.find('#cookieJson').html()).toContain(serializerVal.value2) + }) + + it('should ignore serializer when isParsing is false', async () => { + const cookieName = 'cookieName' + const cookieValue = { value1: 'testValue1', value2: 'testValue2' } + const serializerVal = { value1: 'testValue1+1', value2: 'testValue2+1' } + const wrapper = mount( + testComponent(cookieName, cookieValue, { + isParsing: false, + serializer: (obj: any) => ({ + value1: `${obj.value1}+1`, + value2: `${obj.value2}+1` + }) + }) + ) + wrapper.find('#setCookie').trigger('click') + wrapper.find('#getCookie').trigger('click') + await wrapper.vm.$nextTick() + expect(wrapper.find('#cookie').html()).toContain('[object Object]') + expect(wrapper.find('#cookieJson').html()).not.toContain( + serializerVal.value1 + ) + expect(wrapper.find('#cookieJson').html()).not.toContain( + serializerVal.value2 + ) + }) }) diff --git a/src/components/useCookie/useCookie.ts b/src/components/useCookie/useCookie.ts index 702e7f6..9d667d7 100755 --- a/src/components/useCookie/useCookie.ts +++ b/src/components/useCookie/useCookie.ts @@ -1,28 +1,57 @@ import Cookies from 'cookie-universal' import { CookieSerializeOptions } from 'cookie' +import { + createSerializer, + createDeserializer, + SerializerFunction, + DeserializerFunction, + trySerialize, + tryDeserialize, + isNullOrUndefined +} from '@src/utils' import { ref, onMounted, Ref } from '@src/api' +export interface UseCookieOptions { + isParsing: boolean + serializer?: SerializerFunction + deserializer?: DeserializerFunction +} + +const defaultOptions = { + isParsing: false +} + export function useCookie( cookieName: string, - enableParseJSON = false, + options?: UseCookieOptions, runOnMount = true ) { - const cookieLib = Cookies(undefined, undefined, enableParseJSON) + const { isParsing, ...opts } = Object.assign({}, defaultOptions, options) + const serializer = createSerializer(opts.serializer) + const deserializer = createDeserializer(opts.deserializer) + + const cookieLib = Cookies(undefined, undefined, false) const cookie: Ref = ref(null) const getCookie = () => { - const cookieVal = cookieLib.get(cookieName) - if (typeof cookieVal !== 'undefined') cookie.value = cookieVal + const cookieVal = tryDeserialize( + cookieLib.get(cookieName), + deserializer, + isParsing + ) + if (!isNullOrUndefined(cookieVal)) cookie.value = cookieVal } const setCookie = ( - // The user may pass a 'string', a 'number', or a valid JSON object/array + // The user may pass a 'string', a 'number', a valid JSON object/array + // or even a custom object when serializer/deserializer are defined // so it is better to set allowed cookie value as 'any' newVal: any, options?: CookieSerializeOptions ) => { - cookieLib.set(cookieName, newVal, options) - cookie.value = newVal + const newCookieVal = trySerialize(newVal, serializer, isParsing) + cookieLib.set(cookieName, newCookieVal, options) + cookie.value = tryDeserialize(newCookieVal, deserializer, isParsing) } const removeCookie = (options?: CookieSerializeOptions) => { diff --git a/src/components/useGeolocation/useGeolocation.ts b/src/components/useGeolocation/useGeolocation.ts index 4a283db..6ebec03 100755 --- a/src/components/useGeolocation/useGeolocation.ts +++ b/src/components/useGeolocation/useGeolocation.ts @@ -23,7 +23,7 @@ export function useGeolocation( options: PositionOptions = {}, runOnMount = true ) { - options = Object.assign(defaultOpts, options) + options = Object.assign({}, defaultOpts, options) // Note: surprisingly the watchId can be 0 (not positive) so // we have to check if watchId !== null every time diff --git a/src/components/useLocalStorage/index.ts b/src/components/useLocalStorage/index.ts new file mode 100755 index 0000000..f089557 --- /dev/null +++ b/src/components/useLocalStorage/index.ts @@ -0,0 +1 @@ +export * from './useLocalStorage' diff --git a/src/components/useLocalStorage/stories/UseLocalStorageDemo.vue b/src/components/useLocalStorage/stories/UseLocalStorageDemo.vue new file mode 100755 index 0000000..cd411b8 --- /dev/null +++ b/src/components/useLocalStorage/stories/UseLocalStorageDemo.vue @@ -0,0 +1,84 @@ + + + diff --git a/src/components/useLocalStorage/stories/useLocalStorage.md b/src/components/useLocalStorage/stories/useLocalStorage.md new file mode 100755 index 0000000..901f865 --- /dev/null +++ b/src/components/useLocalStorage/stories/useLocalStorage.md @@ -0,0 +1,77 @@ +# useLocalStorage + +Vue function that provides way to read, update and delete a localStorage key + +## Reference + +```typescript +interface UseLocalStorageOptions { + isParsing: boolean + serializer?: SerializerFunction + deserializer?: DeserializerFunction +} +``` + +```typescript +function useLocalStorage( + key: string, + options?: UseLocalStorageOptions, + runOnMount?: boolean +): { + item: Ref + getItem: () => void + setItem: (newVal: any) => void + removeItem: () => void +} +``` + +### Parameters + +- `key: string` the localstorage key you wish to get/set/remove +- `options: UseLocalStorageOptions` + - `isParsing: boolean` whether to enable parsing the localstorage key value or not, `false` by default + - `serializer: SerializerFunction` a custom serializer, `JSON.stringify` by default + - `deserializer: DeserializerFunction` a custom deserializer, `JSON.parse` by default +- `runOnMount: boolean` whether to get the localstorage key on mount or not, `true` by default + +### Returns + +- `item: Ref` the localstorage key value, it can be null, a string or a JSON object/array +- `getItem: Function` get the localstorage key value +- `setItem: Function` set the localstorage key value + - `newVal: any`: the value to set, can be a string or an object/array +- `removeItem: Function` delete the localstorage key + +## Usage + +```html + + + +``` diff --git a/src/components/useLocalStorage/stories/useLocalStorage.story.ts b/src/components/useLocalStorage/stories/useLocalStorage.story.ts new file mode 100755 index 0000000..7f59732 --- /dev/null +++ b/src/components/useLocalStorage/stories/useLocalStorage.story.ts @@ -0,0 +1,28 @@ +import { storiesOf } from '@storybook/vue' +import path from 'path' +import StoryTitle from '@src/helpers/StoryTitle.vue' +import UseLocalStorageDemo from './UseLocalStorageDemo.vue' + +const functionName = 'useLocalStorage' +const functionPath = path.resolve(__dirname, '..') +const notes = require(`./${functionName}.md`).default + +const basicDemo = () => ({ + components: { StoryTitle, demo: UseLocalStorageDemo }, + template: ` +
+ + + + + +
` +}) + +storiesOf('side effects|useLocalStorage', module) + .addParameters({ notes }) + .add('Demo', basicDemo) diff --git a/src/components/useLocalStorage/useLocalStorage.spec.ts b/src/components/useLocalStorage/useLocalStorage.spec.ts new file mode 100755 index 0000000..ebf3c42 --- /dev/null +++ b/src/components/useLocalStorage/useLocalStorage.spec.ts @@ -0,0 +1,188 @@ +import { mount } from '@src/helpers/test' +import { useLocalStorage } from '@src/vue-use-kit' + +const localStorageMock = () => { + let store = {} as any + return { + getItem(key: any) { + return store[key] + }, + setItem(key: any, value: any) { + store[key] = String(value) + }, + clear() { + store = {} + }, + removeItem(key: any) { + delete store[key] + } + } +} + +beforeEach(() => { + ;(window as any).localStorage = localStorageMock() +}) + +afterEach(() => { + jest.clearAllMocks() +}) + +const testComponent = ( + key = 'key', + itemValue: any = '', + opts = { isParsing: false } as any, + onMount = true +) => ({ + template: ` +
+
{{ item }}
+
+ {{ item && item.value1 }} - {{ item && item.value2 }} +
+ + + +
+ `, + setup() { + const { item, getItem, setItem, removeItem } = useLocalStorage( + key, + opts, + onMount + ) + + return { + item, + getItem, + setItem, + removeItem, + itemValue + } + } +}) + +describe('useItem', () => { + it('should get a item with the given value', async () => { + const itemKey = 'itemKey' + const itemValue = 'itemValue' + ;(window as any).localStorage.setItem(itemKey, itemValue) + const wrapper = mount(testComponent(itemKey)) + await wrapper.vm.$nextTick() + expect(wrapper.html()).toContain(itemValue) + }) + + it('should set a item with the given value', async () => { + const itemKey = 'itemKey' + const itemValue = 'itemValue' + const wrapper = mount(testComponent(itemKey, itemValue)) + await wrapper.vm.$nextTick() + expect(localStorage.getItem(itemKey)).toContain(itemValue) + }) + + it('should remove a item with the given value', async () => { + const itemKey = 'itemKey' + const itemValue = 'itemValue' + const wrapper = mount(testComponent(itemKey, itemValue)) + await wrapper.vm.$nextTick() + expect(localStorage.getItem(itemKey)).toContain(itemValue) + wrapper.find('#removeItem').trigger('click') + await wrapper.vm.$nextTick() + expect(localStorage.getItem(itemKey)).toBeFalsy() + }) + + it('should correctly get and set the isParsing object', async () => { + const itemKey = 'itemKey' + const itemValue = { value1: 'testValue1', value2: 'testValue2' } + const wrapper = mount( + testComponent(itemKey, itemValue, { isParsing: true }) + ) + wrapper.find('#setItem').trigger('click') + await wrapper.vm.$nextTick() + expect(wrapper.find('#itemJson').html()).toContain(itemValue.value1) + expect(wrapper.find('#itemJson').html()).toContain(itemValue.value2) + + // Also check that when clicking #getItem we still get a proper JSON object + wrapper.find('#getItem').trigger('click') + await wrapper.vm.$nextTick() + expect(wrapper.find('#itemJson').html()).toContain(itemValue.value1) + expect(wrapper.find('#itemJson').html()).toContain(itemValue.value2) + }) + + it('should correctly get using the deserializer', async () => { + const itemKey = 'itemKey' + const itemValue = { value1: 'testValue1', value2: 'testValue2' } + const deserializerVal = { value1: 'gatto', value2: 'topo' } + const wrapper = mount( + testComponent(itemKey, itemValue, { + isParsing: true, + deserializer: () => deserializerVal + }) + ) + await wrapper.vm.$nextTick() + expect(wrapper.find('#itemJson').html()).toContain(deserializerVal.value1) + expect(wrapper.find('#itemJson').html()).toContain(deserializerVal.value2) + }) + + it('should ignore deserializer when isParsing is false', async () => { + const itemKey = 'itemKey' + const itemValue = { value1: 'testValue1', value2: 'testValue2' } + const deserializerVal = { value1: 'gatto', value2: 'topo' } + const wrapper = mount( + testComponent(itemKey, itemValue, { + isParsing: false, + deserializer: () => deserializerVal + }) + ) + await wrapper.vm.$nextTick() + expect(wrapper.find('#item').html()).toContain(itemValue.value1) + expect(wrapper.find('#item').html()).toContain(itemValue.value2) + expect(wrapper.find('#itemJson').html()).not.toContain( + deserializerVal.value1 + ) + expect(wrapper.find('#itemJson').html()).not.toContain( + deserializerVal.value2 + ) + }) + + it('should correctly set the object using the serializer', async () => { + const itemKey = 'itemKey' + const itemValue = { value1: 'testValue1', value2: 'testValue2' } + const serializerVal = { value1: 'testValue1+1', value2: 'testValue2+1' } + const wrapper = mount( + testComponent(itemKey, itemValue, { + isParsing: true, + serializer: (obj: any) => + JSON.stringify({ + value1: `${obj.value1}+1`, + value2: `${obj.value2}+1` + }) + }) + ) + wrapper.find('#setItem').trigger('click') + wrapper.find('#getItem').trigger('click') + await wrapper.vm.$nextTick() + expect(wrapper.find('#itemJson').html()).toContain(serializerVal.value1) + expect(wrapper.find('#itemJson').html()).toContain(serializerVal.value2) + }) + + it('should ignore serializer when isParsing is false', async () => { + const itemKey = 'itemKey' + const itemValue = { value1: 'testValue1', value2: 'testValue2' } + const serializerVal = { value1: 'testValue1+1', value2: 'testValue2+1' } + const wrapper = mount( + testComponent(itemKey, itemValue, { + isParsing: false, + serializer: (obj: any) => ({ + value1: `${obj.value1}+1`, + value2: `${obj.value2}+1` + }) + }) + ) + wrapper.find('#setItem').trigger('click') + wrapper.find('#getItem').trigger('click') + await wrapper.vm.$nextTick() + expect(wrapper.find('#item').html()).toContain('[object Object]') + expect(wrapper.find('#itemJson').html()).not.toContain(serializerVal.value1) + expect(wrapper.find('#itemJson').html()).not.toContain(serializerVal.value2) + }) +}) diff --git a/src/components/useLocalStorage/useLocalStorage.ts b/src/components/useLocalStorage/useLocalStorage.ts new file mode 100755 index 0000000..3631122 --- /dev/null +++ b/src/components/useLocalStorage/useLocalStorage.ts @@ -0,0 +1,79 @@ +import { + createSerializer, + createDeserializer, + SerializerFunction, + DeserializerFunction, + trySerialize, + tryDeserialize, + isNullOrUndefined +} from '@src/utils' +import { ref, onMounted, Ref } from '@src/api' + +export interface UseLocalStorageOptions { + isParsing: boolean + serializer?: SerializerFunction + deserializer?: DeserializerFunction +} + +const defaultOptions = { + isParsing: false +} + +export function useLocalStorage( + key: string, + options?: UseLocalStorageOptions, + runOnMount = true +) { + const { isParsing, ...opts } = Object.assign({}, defaultOptions, options) + const serializer = createSerializer(opts.serializer) + const deserializer = createDeserializer(opts.deserializer) + + const item: Ref = ref(null) + + const getItem = () => { + try { + const itemVal = tryDeserialize( + localStorage.getItem(key), + deserializer, + isParsing + ) + if (!isNullOrUndefined(itemVal)) item.value = itemVal + } catch (error) { + // If user is in private mode or has storage restriction localStorage can throw + } + } + + const setItem = ( + // The user may pass a 'string', a 'number', a valid JSON object/array + // or even a custom object when serializer/deserializer are defined + // so it is better to set allowed newItemVal as 'any' + newVal: any + ) => { + try { + const newItemVal = trySerialize(newVal, serializer, isParsing) + localStorage.setItem(key, newItemVal) + item.value = tryDeserialize(newItemVal, deserializer, isParsing) + } catch (error) { + // If user is in private mode or has storage restriction localStorage can throw + } + } + + const removeItem = () => { + try { + localStorage.removeItem(key) + item.value = null + } catch (error) { + // If user is in private mode or has storage restriction localStorage can throw + item.value = null + } + } + + onMounted(() => runOnMount && getItem()) + + return { + item, + getItem, + setItem, + removeItem + } +} diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts deleted file mode 100755 index 4f14c2c..0000000 --- a/src/helpers/utils.ts +++ /dev/null @@ -1,5 +0,0 @@ -const checkType = (typeToCheck: any) => - Object.prototype.toString.call(typeToCheck) - -export const isObj = (objToCheck: any) => - checkType(objToCheck) === '[object Object]' diff --git a/src/utils.ts b/src/utils.ts index 61cd5fe..0ba62fb 100755 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,21 @@ +const checkType = (typeToCheck: any) => + Object.prototype.toString.call(typeToCheck) + +export const isString = (varToCheck: any) => + checkType(varToCheck) === '[object String]' + +export const isObj = (varToCheck: any) => + checkType(varToCheck) === '[object Object]' + +export const isNull = (varToCheck: any) => + checkType(varToCheck) === '[object Null]' + +export const isUndefined = (varToCheck: any) => + checkType(varToCheck) === '[object Undefined]' + +export const isNullOrUndefined = (varToCheck: any) => + isNull(varToCheck) || isUndefined(varToCheck) + // The history methods 'pushState' and 'replaceState' by default do not fire an event // unless it is coming from user interaction with the browser navigation bar, // so we are adding a patch to make them detectable @@ -25,3 +43,40 @@ export const normalizeEntriesData = (data: [any, any][]) => acc[key] = val return acc }, {} as { [key: string]: any }) + +export type SerializerFunction = (value: any) => string + +export type DeserializerFunction = (value: string) => any + +export const createSerializer = (serializer?: SerializerFunction) => + serializer || JSON.stringify + +export const createDeserializer = (deserializer?: DeserializerFunction) => + deserializer || JSON.parse + +const fallbackToString = (val: any) => (isString(val) ? val : String(val)) +export const trySerialize = ( + val: any, + serializer: SerializerFunction, + shouldSerialize?: boolean +) => { + if (!shouldSerialize) return fallbackToString(val) + try { + return fallbackToString(serializer(val)) + } catch (error) { + return fallbackToString(val) + } +} + +export const tryDeserialize = ( + val: any, + deserializer: DeserializerFunction, + shouldDeserialize?: boolean +) => { + if (!shouldDeserialize) return val + try { + return deserializer(val) + } catch (error) { + return val + } +} diff --git a/src/vue-use-kit.ts b/src/vue-use-kit.ts index abe589e..98aac17 100755 --- a/src/vue-use-kit.ts +++ b/src/vue-use-kit.ts @@ -25,3 +25,4 @@ export * from './components/useTimeout' // Site Effects export * from './components/useBeforeUnload' export * from './components/useCookie' +export * from './components/useLocalStorage'