Skip to content

Commit

Permalink
feat: mpa session timeout after 15 minutes
Browse files Browse the repository at this point in the history
  • Loading branch information
VojtechVidra committed Jan 27, 2024
1 parent 4349a76 commit 15667c8
Show file tree
Hide file tree
Showing 11 changed files with 135 additions and 36 deletions.
18 changes: 18 additions & 0 deletions workspaces/e2e/tests/multi-page/multi-page.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/tests/reset.css" />
<link rel="stylesheet" href="/node_modules/@flows/js/css.min/flows.css" />
</head>
<body>
<button class="start-flow">Start flow</button>
<div
style="background-color: grey; width: 20px; height: 20px; margin: 40px"
class="target"
></div>

<script type="module" src="./multi-page.ts"></script>
</body>
</html>
38 changes: 38 additions & 0 deletions workspaces/e2e/tests/multi-page/multi-page.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect, test } from "@playwright/test";

test("Resumes flow when page is reopened", async ({ page }) => {
await page.goto("/multi-page/multi-page.html");
await expect(page.locator(".flows-tooltip")).not.toBeVisible();
await page.click(".start-flow");
await expect(page.locator(".flows-tooltip")).toBeVisible();
await expect(page.locator(".flows-tooltip")).toContainText("First");
await page.click(".flows-continue");
await expect(page.locator(".flows-tooltip")).toContainText("Second");
await page.goto("/");
await expect(page.locator(".flows-tooltip")).not.toBeVisible();
await page.goto("/multi-page/multi-page.html");
await expect(page.locator(".flows-tooltip")).toBeVisible();
await expect(page.locator(".flows-tooltip")).toContainText("Second");
});

test("Doesn't resume flow when page is reopened after timeout", async ({ page }) => {
await page.goto("/multi-page/multi-page.html");
await expect(page.locator(".flows-tooltip")).not.toBeVisible();
await page.click(".start-flow");
await expect(page.locator(".flows-tooltip")).toBeVisible();
await expect(page.locator(".flows-tooltip")).toContainText("First");
await page.click(".flows-continue");
await expect(page.locator(".flows-tooltip")).toContainText("Second");
await page.evaluate(() =>
localStorage.setItem(
"flows.state",
JSON.stringify({
...JSON.parse(localStorage.getItem("flows.state")!),
expiresAt: new Date(),
}),
),
);
await page.goto("/");
await page.goto("/multi-page/multi-page.html");
await expect(page.locator(".flows-tooltip")).not.toBeVisible();
});
23 changes: 23 additions & 0 deletions workspaces/e2e/tests/multi-page/multi-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { init, startFlow } from "@flows/js";

init({
flows: [
{
id: "flow",
steps: [
{
title: "First",
element: ".target",
},
{
title: "Second",
element: ".target",
},
],
},
],
});

document.querySelector(".start-flow")?.addEventListener("click", () => {
startFlow("flow");
});
9 changes: 8 additions & 1 deletion workspaces/e2e/tests/tracking/tracking.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,12 @@ test("Emits correct events", async ({ page }) => {
await page.locator(".start").click();
await page.locator(".flows-continue").click();
await page.locator(".flows-finish").click();
await expect(page).toHaveScreenshot({ scale: "css" });
const logs = page.locator(".log-item");
await expect(logs.nth(0)).toHaveAttribute("data-type", "startFlow");
await expect(logs.nth(1)).toHaveAttribute("data-type", "nextStep");
await expect(logs.nth(2)).toHaveAttribute("data-type", "prevStep");
await expect(logs.nth(3)).toHaveAttribute("data-type", "cancelFlow");
await expect(logs.nth(4)).toHaveAttribute("data-type", "startFlow");
await expect(logs.nth(5)).toHaveAttribute("data-type", "nextStep");
await expect(logs.nth(6)).toHaveAttribute("data-type", "finishFlow");
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
1 change: 1 addition & 0 deletions workspaces/e2e/tests/tracking/tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ void init({
tracking: (e) => {
const p = document.createElement("p");
p.classList.add("log-item");
p.dataset.type = e.type;
p.innerText = JSON.stringify(e);
document.querySelector(".log")?.appendChild(p);
},
Expand Down
1 change: 1 addition & 0 deletions workspaces/js/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ module.exports = {
},
rules: {
"@typescript-eslint/naming-convention": 0,
"no-empty": "off",
},
};
28 changes: 11 additions & 17 deletions workspaces/js/src/flow-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,33 +17,27 @@ export class FlowState {

flowsContext: FlowsContext;

_stepHistory: FlowStepIndex[];
get stepHistory(): FlowStepIndex[] {
return this._stepHistory;
}
set stepHistory(value: FlowStepIndex[]) {
this._stepHistory = value;
this.flowsContext.savePersistentState();
}

constructor(flowId: string, context: FlowsContext) {
this.flowId = flowId;
this.flowsContext = context;
this._stepHistory = this.flowsContext.persistentState.instances.find((i) => i.flowId === flowId)
?.stepHistory ?? [0];
this.track({ type: "startFlow" });
}

get storageKey(): string {
return `flows.${this.flowId}.stepHistory`;
}

get stepHistory(): FlowStepIndex[] {
try {
const data = JSON.parse(window.localStorage.getItem(this.storageKey) ?? "") as unknown;
if (!Array.isArray(data) || !data.length) throw new Error();
return data as FlowStepIndex[];
} catch {
this.stepHistory = [0];
return [0];
}
}

set stepHistory(value: FlowStepIndex[]) {
if (typeof window === "undefined") return;
if (!value.length) window.localStorage.removeItem(this.storageKey);
else window.localStorage.setItem(this.storageKey, JSON.stringify(value));
}

get step(): FlowStepIndex {
return this.stepHistory.at(-1) ?? 0;
}
Expand Down
53 changes: 35 additions & 18 deletions workspaces/js/src/flows-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ import type {
TrackingEvent,
UserProperties,
ImmutableMap,
FlowStepIndex,
} from "./types";

interface PersistentState {
expiresAt: string | null;
instances: { flowId: string; stepHistory: FlowStepIndex[] }[];
}

export class FlowsContext {
private static instance: FlowsContext | undefined;
// eslint-disable-next-line @typescript-eslint/no-empty-function -- needed for singleton
Expand All @@ -25,36 +31,47 @@ export class FlowsContext {
get instances(): ImmutableMap<string, FlowState> {
return this.#instances;
}
saveInstances(): this {
get persistentState(): PersistentState {
try {
window.localStorage.setItem("flows.instances", JSON.stringify([...this.#instances.keys()]));
const data = JSON.parse(window.localStorage.getItem("flows.state") ?? "") as unknown;
if (typeof data !== "object" || !data) throw new Error();
const state = data as PersistentState;
if (state.expiresAt && new Date(state.expiresAt) < new Date()) throw new Error();
return state;
} catch {
// Do nothing
return { expiresAt: "", instances: [] };
}
}
savePersistentState(): this {
try {
const nowPlus15Min = Date.now() + 1000 * 60 * 15;
const state: PersistentState = {
expiresAt: new Date(nowPlus15Min).toISOString(),
instances: Array.from(this.#instances.values()).map((i) => ({
flowId: i.flowId,
stepHistory: i.stepHistory,
})),
};
window.localStorage.setItem("flows.state", JSON.stringify(state));
} catch {}
return this;
}

addInstance(flowId: string, state: FlowState): this {
this.#instances.set(flowId, state);
return this.saveInstances();
return this.savePersistentState();
}
deleteInstance(flowId: string): this {
this.#instances.delete(flowId);
return this.saveInstances();
return this.savePersistentState();
}
startInstancesFromLocalStorage(): this {
try {
const instances = JSON.parse(
window.localStorage.getItem("flows.instances") ?? "[]",
) as string[];
instances.forEach((flowId) => {
if (this.#instances.has(flowId) || !this.flowsById?.[flowId]) return;
const state = new FlowState(flowId, this);
this.#instances.set(flowId, state);
state.render();
});
} catch {
// Do nothing
}
this.persistentState.instances.forEach((instance) => {
if (this.#instances.has(instance.flowId) || !this.flowsById?.[instance.flowId]) return;
const state = new FlowState(instance.flowId, this);
this.#instances.set(instance.flowId, state);
state.render();
});
return this;
}

Expand Down

0 comments on commit 15667c8

Please sign in to comment.