Skip to content

Commit

Permalink
feat: support for multi page app
Browse files Browse the repository at this point in the history
  • Loading branch information
VojtechVidra committed Dec 22, 2023
1 parent c50a7a6 commit 6f66a9d
Show file tree
Hide file tree
Showing 12 changed files with 133 additions and 24 deletions.
3 changes: 3 additions & 0 deletions examples/react-nextjs/app/cloud/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ export default function CloudPage() {
<h1>
<span className="page-title">Cloud</span>
</h1>
<h2>Flow 1 - basic flow</h2>
<button id="start-flow-1">Start flow</button>
<hr />
</>
);
}
17 changes: 17 additions & 0 deletions examples/react-nextjs/components/CloudFlows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ export const CloudFlows = () => {
email: "bob@gmail.com",
role: "admin",
},
flows: [
{
id: "vanilla-demo-flow-1",
element: "#start-flow-1",
steps: [
{
element: "#start-flow-1",
title: "Welcome to FlowsJS!",
body: "This is a demo of FlowsJS. Click the button below to continue.",
},
{
title: "This is a modal",
body: "This is a modal. It is an useful way to show larger amounts of information with detailed descriptions. For smaller amounts of information, you can use a tooltip. Click the button below to continue.",
},
],
},
],
});
}, []);

Expand Down
1 change: 1 addition & 0 deletions examples/react-nextjs/next.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
eslint: { ignoreDuringBuilds: true },
reactStrictMode: false,
};

module.exports = nextConfig;
2 changes: 1 addition & 1 deletion src/cloud/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const init = async (options: FlowsCloudOptions): Promise<void> => {
void api(apiUrl)
.getPreviewFlow({ flowId, projectId })
.then((flow) => {
context.addFlow({ ...flow, draft: true });
context.addFlowData({ ...flow, draft: true });
startFlow(flow.id, { startDraft: true });
});
},
Expand Down
51 changes: 37 additions & 14 deletions src/flow-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,46 @@ import { render } from "./render";
import type { Flow, FlowStep, FlowStepIndex, TrackingEvent } from "./types";
import { hash, isModalStep, isTooltipStep } from "./utils";

interface InterfaceFlowState {
flowId: string;
flowElement?: { element: HTMLElement; cleanup?: () => void };
}

const getStep = ({ flow, step }: { flow: Flow; step: FlowStepIndex }): FlowStep | undefined => {
if (!Array.isArray(step)) return flow.steps[step] as FlowStep | undefined;

// eslint-disable-next-line -- this reduce is really hard to type
return step.reduce<any>((acc, index) => acc?.[index], flow.steps ?? []) as FlowStep | undefined;
};

export class FlowState implements InterfaceFlowState {
export class FlowState {
flowId: string;
stepHistory: FlowStepIndex[] = [0];
flowElement?: { element: HTMLElement; cleanup?: () => void };
flowElement?: { element: HTMLElement; cleanup?: () => void; target?: Element };
waitingForElement = false;

flowsContext: FlowsContext;

constructor(data: InterfaceFlowState, context: FlowsContext) {
this.flowId = data.flowId;
this.flowElement = data.flowElement;
constructor(flowId: string, context: FlowsContext) {
this.flowId = flowId;
this.flowsContext = context;
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)) return [];
return data as FlowStepIndex[];
} catch {
return [];
}
}

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 Expand Up @@ -135,21 +148,31 @@ export class FlowState implements InterfaceFlowState {
cancel(): this {
this.track({ type: "cancelFlow" });
this.flowsContext.flowSeen(this.flowId);
this.cleanup();
this.unmount();
return this;
}

finish(): this {
this.track({ type: "finishFlow" });
this.flowsContext.flowSeen(this.flowId);
this.cleanup();
this.unmount();
return this;
}

cleanup(): this {
/**
* Remove the flow element from the DOM. Used before rendering next step and when flow is finished.
*/
unmount(): this {
if (!this.flowElement) return this;
this.flowElement.cleanup?.();
this.flowElement.element.remove();
return this;
}

destroy(): this {
this.unmount();
this.stepHistory = [];
this.flowsContext.deleteInstance(this.flowId);
return this;
}
}
52 changes: 48 additions & 4 deletions src/flows-context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import type { FlowState } from "./flow-state";
import type { Flow, FlowStep, FlowsInitOptions, TrackingEvent, UserProperties } from "./types";
import { FlowState } from "./flow-state";
import type {
Flow,
FlowStep,
FlowsInitOptions,
TrackingEvent,
UserProperties,
ImmutableMap,
} from "./types";

export class FlowsContext {
private static instance: FlowsContext | undefined;
Expand All @@ -13,7 +20,42 @@ export class FlowsContext {
}

seenFlowIds: string[] = [];
readonly instances = new Map<string, FlowState>();
readonly #instances = new Map<string, FlowState>();
get instances(): ImmutableMap<string, FlowState> {
return this.#instances;
}
saveInstances(): this {
try {
window.localStorage.setItem("flows.instances", JSON.stringify([...this.#instances.keys()]));
} catch {
// Do nothing
}
return this;
}
addInstance(flowId: string, state: FlowState): this {
this.#instances.set(flowId, state);
return this.saveInstances();
}
deleteInstance(flowId: string): this {
this.#instances.delete(flowId);
return this.saveInstances();
}
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
}
return this;
}

projectId = "";
userId?: string;
Expand Down Expand Up @@ -45,11 +87,13 @@ export class FlowsContext {
{} as Record<string, Flow>,
),
};
this.startInstancesFromLocalStorage();
}

addFlow(flow: Flow): this {
addFlowData(flow: Flow): this {
if (!this.flowsById) this.flowsById = {};
this.flowsById[flow.id] = flow;
this.startInstancesFromLocalStorage();
return this;
}

Expand Down
12 changes: 12 additions & 0 deletions src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import { validateFlowsOptions } from "./validation";
let observer: MutationObserver | null = null;

export const init = (options: FlowsInitOptions): void => {
setTimeout(() => {
_init(options);
}, 0);
};
const _init = (options: FlowsInitOptions): void => {
const validationResult = validateFlowsOptions(options);
if (validationResult.error)
// eslint-disable-next-line no-console -- useful for user debugging
Expand Down Expand Up @@ -174,6 +179,13 @@ export const init = (options: FlowsInitOptions): void => {

FlowsContext.getInstance().instances.forEach((state) => {
if (state.waitingForElement) state.render();

const step = state.currentStep;
if (step && "element" in step) {
const el = document.querySelector(step.element);
const targetChanged = state.flowElement?.target && el !== state.flowElement.target;
if (targetChanged) state.render();
}
});
});
observer.observe(document, {
Expand Down
7 changes: 4 additions & 3 deletions src/public-methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { EndFlowOptions, FlowStep, IdentifyUserOptions, StartFlowOptions }
import { flowUserPropertyGroupMatch } from "./user-properties";

export const startFlow = (flowId: string, { again, startDraft }: StartFlowOptions = {}): void => {
if (!flowId) return;
const instances = FlowsContext.getInstance().instances;
if (instances.has(flowId)) return;

Expand All @@ -26,8 +27,8 @@ export const startFlow = (flowId: string, { again, startDraft }: StartFlowOption
if (!userPropertiesMatch) return;
}

const state = new FlowState({ flowId }, FlowsContext.getInstance());
instances.set(flowId, state);
const state = new FlowState(flowId, FlowsContext.getInstance());
FlowsContext.getInstance().addInstance(flowId, state);
state.render();
};

Expand All @@ -37,7 +38,7 @@ export const endFlow = (flowId: string, { variant = "cancel" }: EndFlowOptions =
if (!state) return;
if (variant === "finish") state.finish();
else state.cancel();
instances.delete(flowId);
state.destroy();
};

export const identifyUser = (userId: string, options?: IdentifyUserOptions): void => {
Expand Down
4 changes: 2 additions & 2 deletions src/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,15 +183,15 @@ export const render = (state: FlowState): void => {
const step = state.currentStep;
if (!step) return;

state.cleanup();
state.unmount();

if (isTooltipStep(step)) {
const target = document.querySelector(step.element);
if (target) {
state.waitingForElement = false;
const root = createRoot(state.flowsContext.rootElement);
const { cleanup } = renderTooltip({ root, step, state, target });
state.flowElement = { element: root, cleanup };
state.flowElement = { element: root, cleanup, target };
} else {
state.waitingForElement = true;
}
Expand Down
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./flow";
export * from "./map";
export * from "./options";
export * from "./user";
6 changes: 6 additions & 0 deletions src/types/map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface ImmutableMap<K, V> {
get: (key: K) => V | undefined;
has: (key: K) => boolean;
forEach: (callbackfn: (value: V, key: K, map: Map<K, V>) => void) => void;
values: () => IterableIterator<V>;
}
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"extends": "@vercel/style-guide/typescript",
"compilerOptions": {
"noEmit": true,
"target": "ES2015",
"moduleResolution": "Bundler",
"module": "ESNext",
"jsx": "react",
Expand Down

0 comments on commit 6f66a9d

Please sign in to comment.