Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react): current blocks hook #306

Merged
merged 4 commits into from
Feb 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion workspaces/e2e/pages/react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,24 @@ const Card: FC<{ text: string }> = (props) => (
</div>
);

const BlockTrigger: FC<{
title: string;
trigger: () => void;
items: { text: string; trigger?: () => void }[];
}> = (props) => (
<div className="flows-card">
<p>{props.title}</p>
<button onClick={props.trigger}>Trigger</button>
<ul>
{props.items.map((item) => (
<li key={item.text}>
<button onClick={item.trigger}>{item.text}</button>
</li>
))}
</ul>
</div>
);

createRoot(document.getElementById("root")!).render(
<StrictMode>
<FlowsProvider
Expand All @@ -37,7 +55,7 @@ createRoot(document.getElementById("root")!).render(
age: 10,
}}
apiUrl={apiUrl}
components={{ ...components, Card }}
components={{ ...components, Card, BlockTrigger }}
tourComponents={{ ...tourComponents, Card }}
>
<h1>heading 1</h1>
Expand Down
85 changes: 85 additions & 0 deletions workspaces/e2e/tests/block-trigger.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Block } from "@flows/shared";
import test, { expect } from "@playwright/test";
import { randomUUID } from "crypto";

test.beforeEach(async ({ page }) => {
await page.routeWebSocket(
(url) => url.pathname === "/ws/sdk/block-updates",
() => {},
);
});

const getBlock = (): Block => ({
id: randomUUID(),
type: "component",
componentType: "BlockTrigger",
data: {
f__exit_nodes: ["trigger"],
title: "Block Trigger title",
items: [
{ text: "first item", f__exit_nodes: [] },
{
text: "second item",
f__exit_nodes: ["trigger"],
},
],
},
slottable: false,
exitNodes: [],
});

const run = (packageName: string) => {
test(`${packageName} - shouldn't pass trigger with empty array`, async ({ page }) => {
await page.route("**/v2/sdk/blocks", (route) => {
route.fulfill({ json: { blocks: [getBlock()] } });
});
await page.goto(`/${packageName}.html`);
await expect(page.getByText("Block Trigger title")).toBeVisible();
let reqWasSent = false;
page.on("request", (req) => {
if (req.url().includes("v2/sdk/events")) {
reqWasSent = true;
}
});
await page.getByRole("button", { name: "first item" }).click();
expect(reqWasSent).toBe(false);
});
test(`${packageName} - should pass trigger with exit nodes`, async ({ page }) => {
await page.route("**/v2/sdk/blocks", (route) => {
route.fulfill({ json: { blocks: [getBlock()] } });
});
await page.goto(`/${packageName}.html`);
await expect(page.getByText("Block Trigger title")).toBeVisible();

const rootBlockTriggerReq = page.waitForRequest((req) => {
const body = req.postDataJSON();
return (
req.url() === "https://api.flows-cloud.com/v2/sdk/events" &&
body.organizationId === "orgId" &&
body.userId === "testUserId" &&
body.environment === "prod" &&
body.name === "transition" &&
body.propertyKey === "trigger"
);
});
await page.getByRole("button", { name: "Trigger" }).click();
await rootBlockTriggerReq;

const arrayBlockTriggerReq = page.waitForRequest((req) => {
const body = req.postDataJSON();
return (
req.url() === "https://api.flows-cloud.com/v2/sdk/events" &&
body.organizationId === "orgId" &&
body.userId === "testUserId" &&
body.environment === "prod" &&
body.name === "transition" &&
body.propertyKey === "items.1.trigger"
);
});
await page.getByRole("button", { name: "second item" }).click();
await arrayBlockTriggerReq;
});
};

// run("js");
run("react");
2 changes: 1 addition & 1 deletion workspaces/js/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export * from "./init";
export * from "./methods";
export type { ActiveBlock } from "./types/active-block";
export type { ActiveBlock } from "@flows/shared";
export type { FlowsOptions } from "./types/configuration";
3 changes: 1 addition & 2 deletions workspaces/js/src/lib/active-block.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { type Block } from "@flows/shared";
import { type ActiveBlock } from "../types/active-block";
import { type Block, type ActiveBlock } from "@flows/shared";
import { nextTourStep, previousTourStep, cancelTour } from "./tour";
import { sendEvent } from "./api";

Expand Down
3 changes: 1 addition & 2 deletions workspaces/js/src/methods.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { computed, effect, type ReadonlySignal } from "@preact/signals-core";
import { type Block, pathnameMatch } from "@flows/shared";
import { type ActiveBlock } from "./types/active-block";
import { type ActiveBlock, type Block, pathnameMatch } from "@flows/shared";
import { blocks, pathname, type RunningTour, runningTours } from "./store";
import { blockToActiveBlock, tourToActiveBlock } from "./lib/active-block";
import { sendEvent } from "./lib/api";
Expand Down
2 changes: 1 addition & 1 deletion workspaces/react/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@flows/react",
"version": "1.1.0",
"version": "1.1.1-canary.0",
"description": "Flows React SDK – Build native product growth experiences, your way",
"keywords": [
"react",
Expand Down
19 changes: 19 additions & 0 deletions workspaces/react/src/components/block.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { type FC } from "react";
import { type ActiveBlock, log } from "@flows/shared";
import { useFlowsContext } from "../flows-context";

interface Props {
block: ActiveBlock;
}

export const Block: FC<Props> = ({ block }) => {
const { components } = useFlowsContext();

const Component = components[block.component];
if (!Component) {
log.error(`Component not found for workflow block "${block.component}"`);
return null;
}

return <Component {...block.props} />;
};
19 changes: 19 additions & 0 deletions workspaces/react/src/components/floating-blocks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { type FC } from "react";
import { useCurrentFloatingBlocks } from "../hooks/use-current-blocks";
import { Block } from "./block";
import { TourBlock } from "./tour-block";

export const FloatingBlocks: FC = () => {
const items = useCurrentFloatingBlocks();

return (
<>
{items.map((item) => {
if (item.type === "component") return <Block key={item.id} block={item} />;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- it's better to be safe
if (item.type === "tour-component") return <TourBlock key={item.id} block={item} />;
return null;
})}
</>
);
};
23 changes: 23 additions & 0 deletions workspaces/react/src/components/flows-slot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { type FC, type ReactNode } from "react";
import { useCurrentSlotBlocks } from "../hooks/use-current-blocks";
import { Block } from "./block";
import { TourBlock } from "./tour-block";

export interface FlowsSlotProps {
id: string;
placeholder?: ReactNode;
}

export const FlowsSlot: FC<FlowsSlotProps> = ({ id, placeholder }) => {
const items = useCurrentSlotBlocks(id);

if (items.length)
return items.map((item) => {
if (item.type === "component") return <Block key={item.id} block={item} />;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- it's better to be safe
if (item.type === "tour-component") return <TourBlock key={item.id} block={item} />;
return null;
});

return placeholder ?? null;
};
19 changes: 19 additions & 0 deletions workspaces/react/src/components/tour-block.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { type FC } from "react";
import { type ActiveBlock, log } from "@flows/shared";
import { useFlowsContext } from "../flows-context";

interface Props {
block: ActiveBlock;
}

export const TourBlock: FC<Props> = ({ block }) => {
const { tourComponents } = useFlowsContext();

const Component = tourComponents[block.component];
if (!Component) {
log.error(`Tour Component not found for tour block "${block.component}"`);
return null;
}

return <Component {...block.props} />;
};
27 changes: 3 additions & 24 deletions workspaces/react/src/flows-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { type FC, type ReactNode, useMemo } from "react";
import { type FC, type ReactNode } from "react";
import { type UserProperties } from "@flows/shared";
import { type TourComponents, type Components } from "./types";
import { Block } from "./block";
import { FlowsContext } from "./flows-context";
import { useRunningTours } from "./hooks/use-running-tours";
import { useBlocks } from "./hooks/use-blocks";
import { TourBlock } from "./tour-block";
import { PathnameProvider } from "./contexts/pathname-context";
import { TourController } from "./tour-controller";
import { globalConfig } from "./lib/store";
import { FloatingBlocks } from "./components/floating-blocks";

export interface FlowsProviderProps {
/**
Expand Down Expand Up @@ -62,31 +61,11 @@ export const FlowsProvider: FC<FlowsProviderProps> = ({

const runningTours = useRunningTours({ blocks });

const floatingBlocks = useMemo(
() =>
blocks.filter(
(b) =>
!b.slottable &&
// tour block doesn't have componentType
b.componentType,
),
[blocks],
);
const floatingTourBlocks = useMemo(
() => runningTours.filter((b) => b.activeStep && !b.activeStep.slottable),
[runningTours],
);

return (
<PathnameProvider>
<FlowsContext.Provider value={{ blocks, components, runningTours, tourComponents }}>
{children}
{floatingBlocks.map((block) => {
return <Block block={block} key={block.id} />;
})}
{floatingTourBlocks.map((tour) => {
return <TourBlock key={tour.block.id} tour={tour} />;
})}
<FloatingBlocks />
<TourController />
</FlowsContext.Provider>
</PathnameProvider>
Expand Down
42 changes: 0 additions & 42 deletions workspaces/react/src/flows-slot.tsx

This file was deleted.

Loading