Skip to content

Commit

Permalink
feat(js): tour wait
Browse files Browse the repository at this point in the history
  • Loading branch information
VojtechVidra committed Jan 6, 2025
1 parent ab684c8 commit 9332d4a
Show file tree
Hide file tree
Showing 9 changed files with 186 additions and 64 deletions.
4 changes: 4 additions & 0 deletions workspaces/js/src/init.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { handleDocumentClick } from "./lib/click";
import { connectToWebsocketAndFetchBlocks } from "./lib/blocks";
import { addHandlers } from "./lib/handler";
import { config, pathname } from "./store";
import { type FlowsConfiguration } from "./types/configuration";

Expand All @@ -17,4 +19,6 @@ export const init = (configuration: FlowsConfiguration): void => {
pathname.value = window.location.pathname;
}
}, 250);

addHandlers([{ type: "click", handler: handleDocumentClick }]);
};
59 changes: 6 additions & 53 deletions workspaces/js/src/lib/active-block.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,7 @@
import { type Block, getApi } from "@flows/shared";
import { type Block } from "@flows/shared";
import { type ActiveBlock } from "../types/active-block";
import { config, type RunningTour, runningTours } from "../store";

const sendEvent = async (props: {
name: "transition" | "tour-update";
blockId: string;
propertyKey?: string;
properties?: Record<string, unknown>;
}): Promise<void> => {
const configuration = config.value;
if (!configuration) return;
const { environment, organizationId, userId, apiUrl } = configuration;
await getApi(apiUrl).sendEvent({
...props,
environment,
organizationId,
userId,
});
};
import { nextTourStep, previousTourStep, cancelTour } from "./tour";
import { sendEvent } from "./api";

export const blockToActiveBlock = (block: Block): ActiveBlock | [] => {
if (!block.componentType) return [];
Expand All @@ -43,45 +27,14 @@ export const tourToActiveBlock = (block: Block, currentIndex: number): ActiveBlo
const activeStep = tourBlocks.at(currentIndex);
if (!activeStep?.componentType) return [];

const updateState = (updateFn: (tour: RunningTour) => RunningTour): void => {
runningTours.value = runningTours.value.map((tour) =>
tour.blockId === block.id ? updateFn(tour) : tour,
);
};
const hide = (): void => {
updateState((t) => ({ ...t, hidden: true }));
};

const isFirstStep = currentIndex === 0;
const isLastStep = currentIndex === tourBlocks.length - 1;

const handlePrevious = (): void => {
if (isFirstStep) return;
const newIndex = currentIndex - 1;
updateState((t) => ({ ...t, currentBlockIndex: newIndex }));
void sendEvent({
name: "tour-update",
blockId: block.id,
properties: { currentTourIndex: newIndex },
});
previousTourStep(block, currentIndex);
};
const handleContinue = (): void => {
if (isLastStep) {
hide();
void sendEvent({ name: "transition", blockId: block.id, propertyKey: "complete" });
} else {
const newIndex = currentIndex + 1;
updateState((t) => ({ ...t, currentBlockIndex: newIndex }));
void sendEvent({
name: "tour-update",
blockId: block.id,
properties: { currentTourIndex: newIndex },
});
}
nextTourStep(block, currentIndex);
};
const handleCancel = (): void => {
hide();
void sendEvent({ name: "transition", blockId: block.id, propertyKey: "cancel" });
cancelTour(block.id);
};

return {
Expand Down
19 changes: 19 additions & 0 deletions workspaces/js/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { getApi } from "@flows/shared";
import { config } from "../store";

export const sendEvent = async (props: {
name: "transition" | "tour-update";
blockId: string;
propertyKey?: string;
properties?: Record<string, unknown>;
}): Promise<void> => {
const configuration = config.value;
if (!configuration) return;
const { environment, organizationId, userId, apiUrl } = configuration;
await getApi(apiUrl).sendEvent({
...props,
environment,
organizationId,
userId,
});
};
8 changes: 8 additions & 0 deletions workspaces/js/src/lib/click.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { handleTourDocumentClick } from "./tour";

export const handleDocumentClick = (event: MouseEvent): void => {
const eventTarget = event.target;
if (!eventTarget || !(eventTarget instanceof Element)) return;

handleTourDocumentClick(eventTarget);
};
17 changes: 17 additions & 0 deletions workspaces/js/src/lib/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
interface Handler {
type: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- this is correct type
handler: (...args: any[]) => any;
}

let handlers: Handler[] = [];

export const addHandlers = (handlersToAdd: Handler[]): void => {
handlers.forEach(({ type, handler }) => {
document.removeEventListener(type, handler, true);
});
handlersToAdd.forEach(({ type, handler }) => {
document.addEventListener(type, handler, true);
});
handlers = handlersToAdd;
};
104 changes: 104 additions & 0 deletions workspaces/js/src/lib/tour.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { elementContains, getPathname, pathnameMatch, type Block } from "@flows/shared";
import { effect } from "@preact/signals-core";
import { blocks, pathname, type RunningTour, runningTours } from "../store";
import { sendEvent } from "./api";

export const updateTourState = (
tourBlockId: string,
updateFn: (tour: RunningTour) => RunningTour,
): void => {
runningTours.value = runningTours.value.map((tour) =>
tour.blockId === tourBlockId ? updateFn(tour) : tour,
);
};

export const hideTour = (tourBlockId: string): void => {
updateTourState(tourBlockId, (t) => ({ ...t, hidden: true }));
};

export const previousTourStep = (tourBlock: Block, currentIndex: number): void => {
const isFirstStep = currentIndex === 0;

if (isFirstStep) return;
const newIndex = currentIndex - 1;
updateTourState(tourBlock.id, (t) => ({ ...t, currentBlockIndex: newIndex }));
void sendEvent({
name: "tour-update",
blockId: tourBlock.id,
properties: { currentTourIndex: newIndex },
});
};

export const nextTourStep = (tourBlock: Block, currentIndex: number): void => {
const isLastStep = currentIndex === (tourBlock.tourBlocks?.length ?? 1) - 1;

if (isLastStep) {
hideTour(tourBlock.id);
void sendEvent({ name: "transition", blockId: tourBlock.id, propertyKey: "complete" });
} else {
const newIndex = currentIndex + 1;
updateTourState(tourBlock.id, (t) => ({ ...t, currentBlockIndex: newIndex }));
void sendEvent({
name: "tour-update",
blockId: tourBlock.id,
properties: { currentTourIndex: newIndex },
});
}
};

export const cancelTour = (tourBlockId: string): void => {
hideTour(tourBlockId);
void sendEvent({ name: "transition", blockId: tourBlockId, propertyKey: "cancel" });
};

export const handleTourDocumentClick = (eventTarget: Element): void => {
const currentPathname = getPathname();

const blocksById = new Map(blocks.value.map((block) => [block.id, block]));
runningTours.value.forEach((tour) => {
const tourBlock = blocksById.get(tour.blockId);
if (!tourBlock) return;
const activeStep = tourBlock.tourBlocks?.at(tour.currentBlockIndex);
if (!activeStep) return;
const tourWait = activeStep.tourWait;
if (!tourWait) return;

if (tourWait.interaction === "click") {
const pageMatch = pathnameMatch({
pathname: currentPathname,
operator: tourWait.page?.operator,
value: tourWait.page?.value,
});
const clickMatch = elementContains({ eventTarget, value: tourWait.element });
if (clickMatch && pageMatch) {
nextTourStep(tourBlock, tour.currentBlockIndex);
}
}
});
};

effect(() => {
const pathnameValue = pathname.value;
const blocksValue = blocks.value;
const runningToursValue = runningTours.value;

const blocksById = new Map(blocksValue.map((block) => [block.id, block]));
runningToursValue.forEach((tour) => {
const tourBlock = blocksById.get(tour.blockId);
if (!tourBlock) return;
const activeStep = tourBlock.tourBlocks?.at(tour.currentBlockIndex);
if (!activeStep) return;
const tourWait = activeStep.tourWait;
if (!tourWait) return;

if (tourWait.interaction === "navigation") {
const match = pathnameMatch({
pathname: pathnameValue,
operator: tourWait.page?.operator,
value: tourWait.page?.value,
});

if (match) nextTourStep(tourBlock, tour.currentBlockIndex);
}
});
});
29 changes: 21 additions & 8 deletions workspaces/js/src/methods.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { computed, effect, type ReadonlySignal } from "@preact/signals-core";
import { pathnameMatch } from "@flows/shared";
import { type Block, pathnameMatch } from "@flows/shared";
import { type ActiveBlock } from "./types/active-block";
import { blocks, pathname, runningTours } from "./store";
import { blocks, pathname, type RunningTour, runningTours } from "./store";
import { blockToActiveBlock, tourToActiveBlock } from "./lib/active-block";

const visibleBlocks = computed(() =>
Expand Down Expand Up @@ -49,14 +49,27 @@ const floatingItems = computed(() => {

const slotBlocks = computed(() => visibleBlocks.value.filter((b) => b.slottable));

const isBlock = (item: Block | RunningTour): item is Block => "type" in item;
const getSlotIndex = (item: Block | (RunningTour & { block: Block })): number => {
if (isBlock(item)) return item.slotIndex ?? 0;
const activeStep = item.block.tourBlocks?.at(item.currentBlockIndex);
return activeStep?.slotIndex ?? 0;
};

const computedActiveBlocksBySlotId = new Map<string, ReadonlySignal<ActiveBlock[]>>();
const addActiveSlotBlocksComputed = (slotId: string): ReadonlySignal<ActiveBlock[]> => {
const newComputed = computed(() =>
slotBlocks.value
.filter((b) => b.slotId === slotId)
.sort((a, b) => (a.slotIndex ?? 0) - (b.slotIndex ?? 0))
.flatMap(blockToActiveBlock),
);
const newComputed = computed(() => {
const workflowBlocks = slotBlocks.value.filter((b) => b.slottable && b.slotId === slotId);
const tours = visibleTours.value.filter((t) => {
const activeStep = t.block.tourBlocks?.at(t.currentBlockIndex);
return activeStep?.slottable && activeStep.slotId === slotId;
});
const sorted = [...workflowBlocks, ...tours].sort((a, b) => getSlotIndex(a) - getSlotIndex(b));
return sorted.flatMap((item) => {
if (isBlock(item)) return blockToActiveBlock(item);
return tourToActiveBlock(item.block, item.currentBlockIndex);
});
});
computedActiveBlocksBySlotId.set(slotId, newComputed);
return newComputed;
};
Expand Down
4 changes: 3 additions & 1 deletion workspaces/js/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ export interface RunningTour {
export const runningTours = signal<RunningTour[]>([]);

effect(() => {
const tourBlocks = blocks.value.filter((b) => b.type === "tour");
const blocksValue = blocks.value;

const tourBlocks = blocksValue.filter((b) => b.type === "tour");
const prevTours = runningTours.peek();
const prevTourMap = new Map(prevTours.map((tour) => [tour.blockId, tour]));
const newRunningTours = tourBlocks.map((block): RunningTour => {
Expand Down
6 changes: 4 additions & 2 deletions workspaces/react/src/tour-controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type FC, useEffect, useMemo } from "react";
import { elementContains, pathnameMatch } from "@flows/shared";
import { elementContains, getPathname, pathnameMatch } from "@flows/shared";
import { useFlowsContext } from "./flows-context";
import { usePathname } from "./contexts/pathname-context";

Expand Down Expand Up @@ -35,12 +35,14 @@ export const TourController: FC = () => {
const eventTarget = event.target;
if (!eventTarget || !(eventTarget instanceof Element)) return;

const currentPathname = getPathname();

relevantTours.forEach((tour) => {
const tourWait = tour.activeStep?.tourWait;

if (tourWait?.interaction === "click") {
const pageMatch = pathnameMatch({
pathname,
pathname: currentPathname,
operator: tourWait.page?.operator,
value: tourWait.page?.value,
});
Expand Down

0 comments on commit 9332d4a

Please sign in to comment.