CSR-1655
Multiple tabs can merge session data
This commit is contained in:
parent
d8ec9c2c7e
commit
19aa7d006f
8 changed files with 399 additions and 1 deletions
|
|
@ -28,6 +28,7 @@ import {
|
|||
removeCurrentlyActivePromoCodesFromInactivePromos,
|
||||
} from "@/helpers/promotions-helper";
|
||||
import { getDateDifferenceInDays } from "@/helpers/date-helper";
|
||||
import sharedMutations from "./vuex-shared-mutations/vuexSharedMutations";
|
||||
// Export State
|
||||
const getDefaultState = () => {
|
||||
return {
|
||||
|
|
@ -2523,7 +2524,12 @@ export const actions = {
|
|||
};
|
||||
|
||||
export default createStore({
|
||||
plugins: [createPersistedState()],
|
||||
plugins: [
|
||||
createPersistedState(),
|
||||
sharedMutations({
|
||||
predicate: [...Object.values(storeMutations)],
|
||||
}),
|
||||
],
|
||||
// IMPORTANT: Be VERY careful when modifying these fields for at least a few reasons:
|
||||
// * The CMS can reference the fields by name
|
||||
// * Return users may have a previous "version" of the model, and we don't want
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
const DEFAULT_CHANNEL = "vuex-shared-mutations";
|
||||
|
||||
const globalObj =
|
||||
typeof window !== "undefined" ? window : /* istanbul ignore next: node env */ global;
|
||||
|
||||
export default class BroadcastChannelStrategy {
|
||||
static available(BroadcastChannelImpl = globalObj.BroadcastChannel) {
|
||||
return !(typeof BroadcastChannelImpl !== "function");
|
||||
}
|
||||
|
||||
constructor(options = {}) {
|
||||
const BroadcastChannelImpl = options.BroadcastChannel || globalObj.BroadcastChannel;
|
||||
const key = options.key || DEFAULT_CHANNEL;
|
||||
|
||||
if (!this.constructor.available(BroadcastChannelImpl)) {
|
||||
throw new Error("Broadcast strategy not available");
|
||||
}
|
||||
|
||||
this.channel = new BroadcastChannelImpl(key);
|
||||
}
|
||||
|
||||
addEventListener(fn) {
|
||||
this.channel.addEventListener("message", (e) => {
|
||||
fn(e.data);
|
||||
});
|
||||
}
|
||||
|
||||
share(message) {
|
||||
return this.channel.postMessage(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import BroadcastChannelStrategy from "./broadcastChannel";
|
||||
|
||||
describe("BroadcastChannelStrategy", () => {
|
||||
const mockChannel = {
|
||||
addEventListener: jest.fn(),
|
||||
postMessage: jest.fn(),
|
||||
};
|
||||
const mockBroadcastChannel = jest.fn(() => mockChannel);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("available", () => {
|
||||
it("should return true if BroadcastChannelImpl is a function", () => {
|
||||
expect(BroadcastChannelStrategy.available(mockBroadcastChannel)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if BroadcastChannelImpl is not a function", () => {
|
||||
expect(BroadcastChannelStrategy.available(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should create a new BroadcastChannel with the default channel name if no key is provided", () => {
|
||||
const strategy = new BroadcastChannelStrategy({
|
||||
BroadcastChannel: mockBroadcastChannel,
|
||||
});
|
||||
|
||||
expect(mockBroadcastChannel).toHaveBeenCalledWith("vuex-shared-mutations");
|
||||
expect(strategy.channel).toBe(mockChannel);
|
||||
});
|
||||
|
||||
it("should create a new BroadcastChannel with the provided key", () => {
|
||||
const strategy = new BroadcastChannelStrategy({
|
||||
BroadcastChannel: mockBroadcastChannel,
|
||||
key: "test-channel",
|
||||
});
|
||||
|
||||
expect(mockBroadcastChannel).toHaveBeenCalledWith("test-channel");
|
||||
expect(strategy.channel).toBe(mockChannel);
|
||||
});
|
||||
|
||||
it("should throw an error if Broadcast strategy is not available", () => {
|
||||
expect(() => new BroadcastChannelStrategy({ BroadcastChannel: null })).toThrow(
|
||||
"Broadcast strategy not available"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addEventListener", () => {
|
||||
it("should add a message event listener to the channel", () => {
|
||||
const strategy = new BroadcastChannelStrategy({
|
||||
BroadcastChannel: mockBroadcastChannel,
|
||||
});
|
||||
const mockFn = jest.fn();
|
||||
|
||||
strategy.addEventListener(mockFn);
|
||||
expect(mockChannel.addEventListener).toHaveBeenCalledWith(
|
||||
"message",
|
||||
expect.any(Function)
|
||||
);
|
||||
|
||||
// const mockEvent = { data: "test-message" };
|
||||
// console.log(mockChannel.addEventListener.mock.calls);
|
||||
// mockChannel.addEventListener.mock.calls[0][1];
|
||||
// expect(mockFn).toHaveBeenCalledWith(mockEvent.data);
|
||||
});
|
||||
});
|
||||
|
||||
describe("share", () => {
|
||||
it("should post a message to the channel", () => {
|
||||
const strategy = new BroadcastChannelStrategy({
|
||||
BroadcastChannel: mockBroadcastChannel,
|
||||
});
|
||||
const mockMessage = { test: "message" };
|
||||
|
||||
expect(strategy.share(mockMessage)).toBeUndefined();
|
||||
expect(mockChannel.postMessage).toHaveBeenCalledWith(mockMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import BroadcastChannelStrategy from "./broadcastChannel";
|
||||
import LocalStorageStrategy from "./localStorage";
|
||||
|
||||
export default function createDefaultStrategy() {
|
||||
/* istanbul ignore next: browser-dependent code */
|
||||
if (LocalStorageStrategy.available()) {
|
||||
return new LocalStorageStrategy();
|
||||
}
|
||||
|
||||
/* istanbul ignore next: browser-dependent code */
|
||||
if (BroadcastChannelStrategy.available()) {
|
||||
return new BroadcastChannelStrategy();
|
||||
}
|
||||
|
||||
/* istanbul ignore next: browser-dependent code */
|
||||
throw new Error("No strategies available");
|
||||
}
|
||||
101
src/store/vuex-shared-mutations/strategies/localStorage.js
Normal file
101
src/store/vuex-shared-mutations/strategies/localStorage.js
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
const DEFAULT_KEY = "vuex-shared-mutations";
|
||||
|
||||
const globalObj =
|
||||
typeof window !== "undefined" ? window : /* istanbul ignore next: node env */ global;
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 4 * 1024;
|
||||
let messageCounter = 1;
|
||||
|
||||
function splitMessage(message) {
|
||||
const partsCount = Math.ceil(message.length / MAX_MESSAGE_LENGTH);
|
||||
return Array.from({ length: partsCount }).map((_, idx) =>
|
||||
message.substr(idx * MAX_MESSAGE_LENGTH, MAX_MESSAGE_LENGTH)
|
||||
);
|
||||
}
|
||||
|
||||
export default class LocalStorageStrategy {
|
||||
static available(
|
||||
{ window: windowImpl, localStorage: localStorageImpl } = {
|
||||
window: globalObj.window,
|
||||
localStorage: globalObj.localStorage,
|
||||
}
|
||||
) {
|
||||
if (!windowImpl || !localStorageImpl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorageImpl.setItem("vuex-shared-mutations-test-key", Date.now());
|
||||
localStorageImpl.removeItem("vuex-shared-mutations-test-key");
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
constructor(options = {}) {
|
||||
const windowImpl = options.window || globalObj.window;
|
||||
const localStorageImpl = options.localStorage || globalObj.localStorage;
|
||||
if (
|
||||
!this.constructor.available({
|
||||
window: windowImpl,
|
||||
localStorage: localStorageImpl,
|
||||
})
|
||||
) {
|
||||
throw new Error("Strategy unavailable");
|
||||
}
|
||||
this.uniqueId = `${Date.now()}-${Math.random()}`;
|
||||
this.messageBuffer = [];
|
||||
this.window = windowImpl;
|
||||
this.storage = localStorageImpl;
|
||||
this.options = {
|
||||
key: DEFAULT_KEY,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
addEventListener(fn) {
|
||||
return this.window.addEventListener("storage", (event) => {
|
||||
if (!event.newValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.key.indexOf("##") === -1 || event.key.split("##")[0] !== this.options.key) {
|
||||
return false;
|
||||
}
|
||||
const message = this.window.JSON.parse(event.newValue);
|
||||
/* istanbul ignore next: IE does not follow storage event spec */
|
||||
if (message.author === this.uniqueId) {
|
||||
return false;
|
||||
}
|
||||
this.messageBuffer.push(message.messagePart);
|
||||
if (this.messageBuffer.length === message.total) {
|
||||
const mutation = this.window.JSON.parse(this.messageBuffer.join(""));
|
||||
this.messageBuffer = [];
|
||||
fn(mutation);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
share(message) {
|
||||
const rawMessage = this.window.JSON.stringify(message);
|
||||
const messageParts = splitMessage(rawMessage);
|
||||
messageParts.forEach((m, idx) => {
|
||||
messageCounter += 1;
|
||||
const key = `${this.options.key}##${idx}`;
|
||||
this.storage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
author: this.uniqueId,
|
||||
part: idx,
|
||||
total: messageParts.length,
|
||||
messagePart: m,
|
||||
messageCounter,
|
||||
})
|
||||
);
|
||||
this.storage.removeItem(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import LocalStorageStrategy from "./localStorage";
|
||||
|
||||
describe("LocalStorageStrategy", () => {
|
||||
describe("available", () => {
|
||||
it("should return true if localStorage is available", () => {
|
||||
const localStorageMock = {
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
};
|
||||
expect(
|
||||
LocalStorageStrategy.available({ window: {}, localStorage: localStorageMock })
|
||||
).toBe(true);
|
||||
});
|
||||
it("should return false if localStorage is not available", () => {
|
||||
const windowMock = {};
|
||||
expect(LocalStorageStrategy.available({ window: windowMock })).toBe(false);
|
||||
});
|
||||
it("should return false if window is not available", () => {
|
||||
expect(LocalStorageStrategy.available({ window: undefined })).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
51
src/store/vuex-shared-mutations/vuexSharedMutations.js
Normal file
51
src/store/vuex-shared-mutations/vuexSharedMutations.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
//Share vuex mutations between tabs/windows
|
||||
//Referenced by https://github.com/xanf/vuex-shared-mutations/
|
||||
|
||||
import createDefaultStrategy from "./strategies/defaultStrategy";
|
||||
|
||||
export { default as BroadcastChannelStrategy } from "./strategies/broadcastChannel";
|
||||
export { default as LocalStorageStratery } from "./strategies/localStorage";
|
||||
|
||||
export default ({ predicate, strategy, ...rest } = {}) => {
|
||||
/* istanbul ignore next: deprecation warning */
|
||||
if ("storageKey" in rest || "sharingKey" in rest) {
|
||||
window.console.warn(
|
||||
"Configuration directly on plugin was removed, configure specific strategies if needed"
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(predicate) && typeof predicate !== "function") {
|
||||
throw new Error(
|
||||
"Either array of accepted mutations or predicate function must be supplied"
|
||||
);
|
||||
}
|
||||
|
||||
const predicateFn =
|
||||
typeof predicate === "function" ? predicate : ({ type }) => predicate.indexOf(type) !== -1;
|
||||
|
||||
let sharingInProgress = false;
|
||||
const selectedStrategy = strategy || createDefaultStrategy();
|
||||
return (store) => {
|
||||
store.subscribe(async (mutation, state) => {
|
||||
if (sharingInProgress) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const shouldShare = await Promise.resolve(predicateFn(mutation, state));
|
||||
if (!shouldShare) {
|
||||
return;
|
||||
}
|
||||
selectedStrategy.share(mutation);
|
||||
});
|
||||
|
||||
selectedStrategy.addEventListener((mutation) => {
|
||||
try {
|
||||
sharingInProgress = true;
|
||||
store.commit(mutation.type, mutation.payload);
|
||||
} finally {
|
||||
sharingInProgress = false;
|
||||
}
|
||||
return "done";
|
||||
});
|
||||
};
|
||||
};
|
||||
88
src/store/vuex-shared-mutations/vuexSharedMutations.spec.js
Normal file
88
src/store/vuex-shared-mutations/vuexSharedMutations.spec.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import createMutationsSharer from "./vuexSharedMutations";
|
||||
|
||||
describe("Vuex shared mutations", () => {
|
||||
it("should throw an error if predicate function is not supplied", () => {
|
||||
expect(() => {
|
||||
createMutationsSharer();
|
||||
}).toThrowError(Error);
|
||||
});
|
||||
it("should accept array as predicate", () => {
|
||||
expect(() => {
|
||||
createMutationsSharer({
|
||||
predicate: ["m-1"],
|
||||
});
|
||||
}).not.toThrowError(Error);
|
||||
});
|
||||
it("should accept function as predicate", () => {
|
||||
expect(() => {
|
||||
createMutationsSharer({
|
||||
predicate: jest.fn(),
|
||||
});
|
||||
}).not.toThrowError(Error);
|
||||
});
|
||||
it("should share relevant mutation", () => {
|
||||
let capturedHandler;
|
||||
const fakeStrategy = {
|
||||
share: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
};
|
||||
|
||||
const fakeStore = {
|
||||
subscribe(fn) {
|
||||
capturedHandler = fn;
|
||||
},
|
||||
};
|
||||
|
||||
createMutationsSharer({
|
||||
predicate: ["m-1"],
|
||||
strategy: fakeStrategy,
|
||||
})(fakeStore);
|
||||
|
||||
return capturedHandler({ type: "m-1", payload: "lol" }).then(() => {
|
||||
expect(fakeStrategy.share).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
it("should not share irrelevant mutation", () => {
|
||||
let capturedHandler;
|
||||
const fakeStrategy = {
|
||||
share: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
};
|
||||
|
||||
const fakeStore = {
|
||||
subscribe(fn) {
|
||||
capturedHandler = fn;
|
||||
},
|
||||
};
|
||||
|
||||
createMutationsSharer({
|
||||
predicate: ["m-1"],
|
||||
strategy: fakeStrategy,
|
||||
})(fakeStore);
|
||||
return capturedHandler({ type: "m-2", payload: "lol" }).then(() => {
|
||||
expect(fakeStrategy.share).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
it("should respect predicate function when sharing mutation", () => {
|
||||
let capturedHandler;
|
||||
const fakeStrategy = {
|
||||
share: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
};
|
||||
|
||||
const fakeStore = {
|
||||
subscribe(fn) {
|
||||
capturedHandler = fn;
|
||||
},
|
||||
};
|
||||
|
||||
createMutationsSharer({
|
||||
predicate: ({ type }) => ["m-1"].indexOf(type) !== -1,
|
||||
strategy: fakeStrategy,
|
||||
})(fakeStore);
|
||||
|
||||
return capturedHandler({ type: "m-1", payload: "lol" }).then(() => {
|
||||
expect(fakeStrategy.share).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue