Commit d808cf4d authored by Daniel Tian's avatar Daniel Tian Committed by Andrew Fontaine

Serialize and deserialize by default for LocalStorageSync component

Changelog: changed
MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84159
parent 96a8605e
......@@ -57,6 +57,7 @@ export default {
:value="sortDirection"
:storage-key="storageKey"
:persist="persistSortOrder"
as-string
@input="setDiscussionSortDirection({ direction: $event })"
/>
<gl-dropdown :text="dropdownText" class="js-dropdown-text full-width-mobile">
......
......@@ -99,7 +99,6 @@ export default {
<local-storage-sync
storage-key="package_registry_list_sorting"
:value="sorting"
as-json
@input="updateSorting"
>
<url-sync>
......
......@@ -273,6 +273,7 @@ export default {
<local-storage-sync
:storage-key="$options.viewTypeKey"
:value="currentViewType"
as-string
@input="updateViewType"
>
<graph-view-selector
......
......@@ -144,7 +144,6 @@ export default {
<local-storage-sync
v-model="autoDevopsEnabledAlertDismissedProjects"
:storage-key="$options.autoDevopsEnabledAlertStorageKey"
as-json
/>
<user-callout-dismisser
......
......@@ -112,7 +112,6 @@ export default {
v-model="mergeRequestMeta"
:storage-key="$options.storageKey"
:clear="clearStorage"
as-json
/>
<edit-meta-controls
ref="editMetaControls"
......
......@@ -37,7 +37,7 @@ export default {
<template>
<div v-show="showAlert">
<local-storage-sync v-model="isDismissed" :storage-key="storageKey" as-json />
<local-storage-sync v-model="isDismissed" :storage-key="storageKey" />
<gl-alert v-if="showAlert" @dismiss="dismissFeedbackAlert">
<slot></slot>
</gl-alert>
......
<script>
import { isEqual } from 'lodash';
import { isEqual, isString } from 'lodash';
/**
* This component will save and restore a value to and from localStorage.
* The value will be saved only when the value changes; the initial value won't be saved.
*
* By default, the value will be saved using JSON.stringify(), and retrieved back using JSON.parse().
*
* If you would like to save the raw string instead, you may set the 'asString' prop to true, though be aware that this is a
* legacy prop to maintain backwards compatibility.
*
* For new components saving data for the first time, it's recommended to not use 'asString' even if you're saving a string; it will still be
* saved and restored properly using JSON.stringify()/JSON.parse().
*/
export default {
props: {
storageKey: {
......@@ -12,7 +24,7 @@ export default {
required: false,
default: '',
},
asJson: {
asString: {
type: Boolean,
required: false,
default: false,
......@@ -30,6 +42,8 @@ export default {
},
watch: {
value(newVal) {
if (!this.persist) return;
this.saveValue(this.serialize(newVal));
},
clear(newVal) {
......@@ -67,15 +81,22 @@ export default {
}
},
saveValue(val) {
if (!this.persist) return;
localStorage.setItem(this.storageKey, val);
},
serialize(val) {
return this.asJson ? JSON.stringify(val) : val;
if (!isString(val) && this.asString) {
// eslint-disable-next-line no-console
console.warn(
`[gitlab] LocalStorageSync is saving`,
val,
`to the key "${this.storageKey}", but it is not a string and the 'asString' prop is true. This will save and restore the stringified value rather than the original value. If this is not intended, please remove or set the 'asString' prop to false.`,
);
}
return this.asString ? val : JSON.stringify(val);
},
deserialize(val) {
return this.asJson ? JSON.parse(val) : val;
return this.asString ? val : JSON.parse(val);
},
},
render() {
......
......@@ -43,7 +43,7 @@ export default {
</script>
<template>
<local-storage-sync :storage-key="storageKey" :value="selected" @input="setSelected">
<local-storage-sync :storage-key="storageKey" :value="selected" as-string @input="setSelected">
<gl-dropdown :text="dropdownText" lazy>
<gl-dropdown-item
v-for="option in parsedOptions"
......
......@@ -314,6 +314,7 @@ export default {
<local-storage-sync
storage-key="gl-web-ide-button-selected"
:value="selection"
as-string
@input="select"
/>
<gl-modal
......
......@@ -157,8 +157,8 @@ export default {
</script>
<template>
<div>
<local-storage-sync v-model="sortBy" :storage-key="$options.sortByStorageKey" as-json />
<local-storage-sync v-model="sortDesc" :storage-key="$options.sortDescStorageKey" as-json />
<local-storage-sync v-model="sortBy" :storage-key="$options.sortByStorageKey" />
<local-storage-sync v-model="sortDesc" :storage-key="$options.sortDescStorageKey" />
<h4>{{ tableHeader }}</h4>
<gl-table
:fields="tableHeaderFields"
......
......@@ -140,8 +140,8 @@ export default {
</script>
<template>
<div>
<local-storage-sync v-model="sortBy" :storage-key="$options.sortByStorageKey" as-json />
<local-storage-sync v-model="sortDesc" :storage-key="$options.sortDescStorageKey" as-json />
<local-storage-sync v-model="sortBy" :storage-key="$options.sortByStorageKey" />
<local-storage-sync v-model="sortDesc" :storage-key="$options.sortDescStorageKey" />
<gl-table
:fields="tableHeaderFields"
:items="enabledNamespaces"
......
......@@ -74,7 +74,6 @@ export default {
<local-storage-sync
v-model="userManuallyCollapsed"
:storage-key="$options.MR_APPROVALS_PROMO_DISMISSED"
as-json
/>
<template v-if="isReady">
<p class="gl-mb-0 gl-text-gray-500">
......
<script>
import { GlToggle } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { parseBoolean } from '~/lib/utils/common_utils';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
export default {
......@@ -20,9 +19,6 @@ export default {
onToggle(val) {
this.setShowLabels(val);
},
onStorageUpdate(val) {
this.setShowLabels(parseBoolean(val));
},
},
};
</script>
......@@ -30,9 +26,9 @@ export default {
<template>
<div class="board-labels-toggle-wrapper gl-display-flex gl-align-items-center gl-ml-3">
<local-storage-sync
:value="isShowingLabels"
storage-key="gl-show-board-labels"
:value="JSON.stringify(isShowingLabels)"
@input="onStorageUpdate"
@input="setShowLabels"
/>
<gl-toggle
:value="isShowingLabels"
......
......@@ -324,7 +324,6 @@ export default {
<gl-form novalidate @submit.prevent="onSubmit()">
<local-storage-sync
v-if="!isEdit"
as-json
:storage-key="storageKey"
:clear="clearStorage"
:value="formFieldValues"
......
<script>
import { difference } from 'lodash';
import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils';
import { getCookie, setCookie } from '~/lib/utils/common_utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { translateScannerNames } from '~/security_configuration/utils';
......@@ -86,8 +86,8 @@ export default {
setCookie(this.$options.autoFixUserCalloutCookieName, 'true');
this.shouldShowAutoFixUserCallout = false;
},
setScannerAlertDismissed(value) {
this.scannerAlertDismissed = parseBoolean(value);
dismissScannerAlert() {
this.scannerAlertDismissed = true;
},
},
projectVulnerabilitiesQuery,
......@@ -101,15 +101,14 @@ export default {
<div v-else>
<local-storage-sync
:value="String(scannerAlertDismissed)"
v-model="scannerAlertDismissed"
:storage-key="$options.SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY"
@input="setScannerAlertDismissed"
/>
<security-scanner-alert
v-if="shouldShowScannersAlert"
:not-enabled-scanners="notEnabledSecurityScanners"
:no-pipeline-run-scanners="noPipelineRunSecurityScanners"
@dismiss="setScannerAlertDismissed('true')"
@dismiss="dismissScannerAlert"
/>
<auto-fix-user-callout
......
......@@ -262,7 +262,6 @@ export default {
<local-storage-sync
v-if="shouldShowPageSizeSelector"
v-model="pageSize"
as-json
:storage-key="$options.PAGE_SIZE_STORAGE_KEY"
>
<page-size-selector v-model="pageSize" class="gl-absolute gl-right-0" />
......
......@@ -83,7 +83,7 @@ export default {
</script>
<template>
<local-storage-sync v-model="surveyShowDate" :storage-key="storageKey">
<local-storage-sync v-model="surveyShowDate" :storage-key="storageKey" as-string>
<gl-banner
v-if="shouldShowSurvey"
:title="title"
......
......@@ -373,10 +373,7 @@ describe('Vulnerability list GraphQL component', () => {
findPageSizeSelector().vm.$emit('input', pageSize);
await nextTick();
expect(findLocalStorageSync().props()).toMatchObject({
asJson: true,
value: pageSize,
});
expect(findLocalStorageSync().props('value')).toBe(pageSize);
});
});
});
......
......@@ -15,6 +15,7 @@ describe('Shared Survey Banner component', () => {
let wrapper;
const findGlBanner = () => wrapper.findComponent(GlBanner);
const findAskLaterButton = () => wrapper.findByTestId('ask-later-button');
const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
const getOffsetDateString = (days) => {
const date = new Date();
......@@ -60,6 +61,7 @@ describe('Shared Survey Banner component', () => {
expect(findGlBanner().html()).toContain(description);
expect(findAskLaterButton().exists()).toBe(true);
expect(findLocalStorageSync().props('asString')).toBe(true);
expect(findGlBanner().props()).toMatchObject({
title,
buttonText,
......
......@@ -38,8 +38,8 @@ describe('Sort Discussion component', () => {
createComponent();
});
it('has local storage sync', () => {
expect(findLocalStorageSync().exists()).toBe(true);
it('has local storage sync with the correct props', () => {
expect(findLocalStorageSync().props('asString')).toBe(true);
});
it('calls setDiscussionSortDirection when update is emitted', () => {
......
......@@ -73,7 +73,6 @@ describe('Package Search', () => {
mountComponent();
expect(findLocalStorageSync().props()).toMatchObject({
asJson: true,
storageKey: 'package_registry_list_sorting',
value: {
orderBy: LIST_KEY_CREATED_AT,
......
......@@ -30,6 +30,7 @@ import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
import * as parsingUtils from '~/pipelines/components/parsing_utils';
import getPipelineHeaderData from '~/pipelines/graphql/queries/get_pipeline_header_data.query.graphql';
import * as sentryUtils from '~/pipelines/utils';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { mockRunningPipelineHeaderData } from '../mock_data';
import { mapCallouts, mockCalloutsResponse, mockPipelineResponse } from './mock_data';
......@@ -55,6 +56,7 @@ describe('Pipeline graph wrapper', () => {
wrapper.find(StageColumnComponent).findAll('[data-testid="stage-column-group"]');
const getViewSelector = () => wrapper.find(GraphViewSelector);
const getViewSelectorTrip = () => getViewSelector().findComponent(GlAlert);
const getLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
const createComponent = ({
apolloProvider,
......@@ -376,6 +378,10 @@ describe('Pipeline graph wrapper', () => {
localStorage.clear();
});
it('sets the asString prop on the LocalStorageSync component', () => {
expect(getLocalStorageSync().props('asString')).toBe(true);
});
it('reads the view type from localStorage when available', () => {
const viewSelectorNeedsSegment = wrapper
.find(GlButtonGroup)
......
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
const STORAGE_KEY = 'key';
describe('Local Storage Sync', () => {
let wrapper;
const createComponent = ({ props = {}, slots = {} } = {}) => {
const createComponent = ({ value, asString = false, slots = {} } = {}) => {
wrapper = shallowMount(LocalStorageSync, {
propsData: props,
propsData: { storageKey: STORAGE_KEY, value, asString },
slots,
});
};
const setStorageValue = (value) => localStorage.setItem(STORAGE_KEY, value);
const getStorageValue = (value) => localStorage.getItem(STORAGE_KEY, value);
afterEach(() => {
if (wrapper) {
wrapper.destroy();
}
wrapper = null;
wrapper.destroy();
localStorage.clear();
});
it('is a renderless component', () => {
const html = '<div class="test-slot"></div>';
createComponent({
props: {
storageKey: 'key',
},
slots: {
default: html,
},
......@@ -35,233 +33,136 @@ describe('Local Storage Sync', () => {
});
describe('localStorage empty', () => {
const storageKey = 'issue_list_order';
it('does not emit input event', () => {
createComponent({
props: {
storageKey,
value: 'ascending',
},
});
expect(wrapper.emitted('input')).toBeFalsy();
});
it.each('foo', 3, true, ['foo', 'bar'], { foo: 'bar' })(
'saves updated value to localStorage',
async (newValue) => {
createComponent({
props: {
storageKey,
value: 'initial',
},
});
wrapper.setProps({ value: newValue });
createComponent({ value: 'ascending' });
await nextTick();
expect(localStorage.getItem(storageKey)).toBe(String(newValue));
},
);
it('does not save default value', () => {
const value = 'ascending';
expect(wrapper.emitted('input')).toBeUndefined();
});
createComponent({
props: {
storageKey,
value,
},
});
it('does not save initial value if it did not change', () => {
createComponent({ value: 'ascending' });
expect(localStorage.getItem(storageKey)).toBe(null);
expect(getStorageValue()).toBeNull();
});
});
describe('localStorage has saved value', () => {
const storageKey = 'issue_list_order_by';
const savedValue = 'last_updated';
beforeEach(() => {
localStorage.setItem(storageKey, savedValue);
setStorageValue(savedValue);
createComponent({ asString: true });
});
it('emits input event with saved value', () => {
createComponent({
props: {
storageKey,
value: 'ascending',
},
});
expect(wrapper.emitted('input')[0][0]).toBe(savedValue);
});
it('does not overwrite localStorage with prop value', () => {
createComponent({
props: {
storageKey,
value: 'created',
},
});
expect(localStorage.getItem(storageKey)).toBe(savedValue);
it('does not overwrite localStorage with initial prop value', () => {
expect(getStorageValue()).toBe(savedValue);
});
it('updating the value updates localStorage', async () => {
createComponent({
props: {
storageKey,
value: 'created',
},
});
const newValue = 'last_updated';
wrapper.setProps({
value: newValue,
});
await wrapper.setProps({ value: newValue });
await nextTick();
expect(localStorage.getItem(storageKey)).toBe(newValue);
expect(getStorageValue()).toBe(newValue);
});
});
describe('persist prop', () => {
it('persists the value by default', async () => {
const persistedValue = 'persisted';
createComponent({ asString: true });
// Sanity check to make sure we start with nothing saved.
expect(getStorageValue()).toBeNull();
createComponent({
props: {
storageKey,
},
});
await wrapper.setProps({ value: persistedValue });
wrapper.setProps({ value: persistedValue });
await nextTick();
expect(localStorage.getItem(storageKey)).toBe(persistedValue);
expect(getStorageValue()).toBe(persistedValue);
});
it('does not save a value if persist is set to false', async () => {
const value = 'saved';
const notPersistedValue = 'notPersisted';
createComponent({ asString: true });
// Save some value so we can test that it's not overwritten.
await wrapper.setProps({ value });
createComponent({
props: {
storageKey,
},
});
expect(getStorageValue()).toBe(value);
wrapper.setProps({ persist: false, value: notPersistedValue });
await nextTick();
expect(localStorage.getItem(storageKey)).not.toBe(notPersistedValue);
await wrapper.setProps({ persist: false, value: notPersistedValue });
expect(getStorageValue()).toBe(value);
});
});
describe('with "asJson" prop set to "true"', () => {
const storageKey = 'testStorageKey';
describe.each`
value | serializedValue
${null} | ${'null'}
${''} | ${'""'}
${true} | ${'true'}
${false} | ${'false'}
${42} | ${'42'}
${'42'} | ${'"42"'}
${'{ foo: '} | ${'"{ foo: "'}
${['test']} | ${'["test"]'}
${{ foo: 'bar' }} | ${'{"foo":"bar"}'}
`('given $value', ({ value, serializedValue }) => {
describe('is a new value', () => {
beforeEach(async () => {
createComponent({
props: {
storageKey,
value: 'initial',
asJson: true,
},
});
wrapper.setProps({ value });
await nextTick();
});
it('serializes the value correctly to localStorage', () => {
expect(localStorage.getItem(storageKey)).toBe(serializedValue);
});
});
describe('is already stored', () => {
beforeEach(() => {
localStorage.setItem(storageKey, serializedValue);
createComponent({
props: {
storageKey,
value: 'initial',
asJson: true,
},
});
});
it('emits an input event with the deserialized value', () => {
expect(wrapper.emitted('input')).toEqual([[value]]);
});
});
describe('saving and restoring', () => {
it.each`
value | asString
${'foo'} | ${true}
${'foo'} | ${false}
${'{ a: 1 }'} | ${true}
${'{ a: 1 }'} | ${false}
${3} | ${false}
${['foo', 'bar']} | ${false}
${{ foo: 'bar' }} | ${false}
${null} | ${false}
${' '} | ${false}
${true} | ${false}
${false} | ${false}
${42} | ${false}
${'42'} | ${false}
${'{ foo: '} | ${false}
`('saves and restores the same value', async ({ value, asString }) => {
// Create an initial component to save the value.
createComponent({ asString });
await wrapper.setProps({ value });
wrapper.destroy();
// Create a second component to restore the value. Restore is only done once, when the
// component is first mounted.
createComponent({ asString });
expect(wrapper.emitted('input')[0][0]).toEqual(value);
});
describe('with bad JSON in storage', () => {
const badJSON = '{ badJSON';
beforeEach(() => {
jest.spyOn(console, 'warn').mockImplementation();
localStorage.setItem(storageKey, badJSON);
createComponent({
props: {
storageKey,
value: 'initial',
asJson: true,
},
});
});
it('should console warn', () => {
// eslint-disable-next-line no-console
expect(console.warn).toHaveBeenCalledWith(
`[gitlab] Failed to deserialize value from localStorage (key=${storageKey})`,
badJSON,
);
});
it('should not emit an input event', () => {
expect(wrapper.emitted('input')).toBeUndefined();
});
it('shows a warning when trying to save a non-string value when asString prop is true', async () => {
const spy = jest.spyOn(console, 'warn').mockImplementation();
createComponent({ asString: true });
await wrapper.setProps({ value: [] });
expect(spy).toHaveBeenCalled();
});
});
it('clears localStorage when clear property is true', async () => {
const storageKey = 'key';
const value = 'initial';
describe('with bad JSON in storage', () => {
const badJSON = '{ badJSON';
let spy;
createComponent({
props: {
storageKey,
},
beforeEach(() => {
spy = jest.spyOn(console, 'warn').mockImplementation();
setStorageValue(badJSON);
createComponent();
});
wrapper.setProps({
value,
it('should console warn', () => {
expect(spy).toHaveBeenCalled();
});
await nextTick();
it('should not emit an input event', () => {
expect(wrapper.emitted('input')).toBeUndefined();
});
});
expect(localStorage.getItem(storageKey)).toBe(value);
it('clears localStorage when clear property is true', async () => {
const value = 'initial';
createComponent({ asString: true });
await wrapper.setProps({ value });
wrapper.setProps({
clear: true,
});
expect(getStorageValue()).toBe(value);
await nextTick();
await wrapper.setProps({ clear: true });
expect(localStorage.getItem(storageKey)).toBe(null);
expect(getStorageValue()).toBeNull();
});
});
......@@ -36,10 +36,10 @@ describe('Persisted dropdown selection', () => {
});
describe('local storage sync', () => {
it('uses the local storage sync component', () => {
it('uses the local storage sync component with the correct props', () => {
createComponent();
expect(findLocalStorageSync().exists()).toBe(true);
expect(findLocalStorageSync().props('asString')).toBe(true);
});
it('passes the right props', () => {
......
......@@ -261,7 +261,10 @@ describe('Web IDE link component', () => {
});
it('should update local storage when selection changes', async () => {
expect(findLocalStorageSync().props('value')).toBe(ACTION_WEB_IDE.key);
expect(findLocalStorageSync().props()).toMatchObject({
asString: true,
value: ACTION_WEB_IDE.key,
});
findActionsButton().vm.$emit('select', ACTION_GITPOD.key);
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment