Skip to content

Commit e9f2edf

Browse files
feat(js,js-components): add onNavigate for client-side navigation (#684)
* feat(js,js-components): add onNavigate for client-side navigation * fix: tests * docs: improve jsdoc --------- Co-authored-by: Ondřej Pešička <77627332+OPesicka@users.noreply.github.com>
1 parent 94c8fd0 commit e9f2edf

13 files changed

Lines changed: 118 additions & 45 deletions

File tree

workspaces/e2e/pages/js.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CustomFetch, Action as IAction, LanguageOption } from "@flows/js";
1+
import type { CustomFetch, Action as IAction, LanguageOption, OnNavigate } from "@flows/js";
22
import {
33
init,
44
resetAllWorkflowsProgress,
@@ -38,6 +38,8 @@ const noCurrentBlocks =
3838
new URLSearchParams(window.location.search).get("noCurrentBlocks") === "true";
3939
const language = new URLSearchParams(window.location.search).get("language") as LanguageOption;
4040
const slotLimit = new URLSearchParams(window.location.search).get("slotLimit");
41+
const enableOnNavigate =
42+
new URLSearchParams(window.location.search).get("customNavigation") === "true";
4143

4244
class Card extends LitElement {
4345
@property({ type: String })
@@ -126,6 +128,12 @@ class Action extends LitElement {
126128
}
127129
}
128130

131+
const onNavigate: OnNavigate = (href, event) => {
132+
event.preventDefault();
133+
const to = href.startsWith("/") ? href : `/${href}`;
134+
window.history.pushState({}, "", `#${to}`);
135+
};
136+
129137
init({
130138
environment: "prod",
131139
organizationId: "orgId",
@@ -137,6 +145,7 @@ init({
137145
email: "test@flows.sh",
138146
age: 10,
139147
},
148+
onNavigate: enableOnNavigate ? onNavigate : undefined,
140149
});
141150

142151
const components = {

workspaces/e2e/pages/react.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ const noCurrentBlocks =
4646
new URLSearchParams(window.location.search).get("noCurrentBlocks") === "true";
4747
const language = new URLSearchParams(window.location.search).get("language") as LanguageOption;
4848
const enableLinkComponent =
49-
new URLSearchParams(window.location.search).get("LinkComponent") === "true";
49+
new URLSearchParams(window.location.search).get("customNavigation") === "true";
5050
const slotLimit = new URLSearchParams(window.location.search).get("slotLimit");
5151

5252
const Card: FC<ComponentProps<{ text: string }>> = (props) => (

workspaces/e2e/tests/link.spec.ts

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,53 +31,56 @@ const getBlock = ({ url, openInNew }: { url: string; openInNew?: boolean }): Blo
3131
],
3232
});
3333

34-
test.describe("react", () => {
35-
test("link component navigation", async ({ page }) => {
34+
const run = (packageName: string) => {
35+
test(`${packageName} link component navigation`, async ({ page }) => {
3636
await mockBlocksEndpoint(page, [getBlock({ url: "/another-page" })]);
37-
await page.goto(`/react.html?LinkComponent=true`);
37+
await page.goto(`/${packageName}.html?customNavigation=true`);
3838
await expect(page.getByText("My modal", { exact: true })).toBeVisible();
3939
await page.getByText("Go to another page", { exact: true }).click();
4040
// The example app uses HashRouter
41-
await expect(page).toHaveURL(`/react.html?LinkComponent=true#/another-page`);
41+
await expect(page).toHaveURL(`/${packageName}.html?customNavigation=true#/another-page`);
4242
});
43-
test("should use link with relative urls", async ({ page }) => {
43+
test(`${packageName} should use link with relative urls`, async ({ page }) => {
4444
await mockBlocksEndpoint(page, [getBlock({ url: "?search=test" })]);
45-
await page.goto(`/react.html?LinkComponent=true`);
45+
await page.goto(`/${packageName}.html?customNavigation=true`);
4646
await expect(page.getByText("My modal", { exact: true })).toBeVisible();
4747
await page.getByText("Go to another page", { exact: true }).click();
48-
await expect(page).toHaveURL(`/react.html?LinkComponent=true#/?search=test`);
48+
await expect(page).toHaveURL(`/${packageName}.html?customNavigation=true#/?search=test`);
4949
});
50-
test("should support personalization", async ({ page }) => {
50+
test(`${packageName} should support personalization`, async ({ page }) => {
5151
await mockBlocksEndpoint(page, [getBlock({ url: "/{{ email }}" })]);
52-
await page.goto(`/react.html?LinkComponent=true`);
52+
await page.goto(`/${packageName}.html?customNavigation=true`);
5353
await expect(page.getByText("My modal", { exact: true })).toBeVisible();
5454
await expect(page.getByRole("link", { name: "Go to another page" })).toHaveAttribute(
5555
"href",
56-
"#/test@flows.sh",
56+
/\/test@flows\.sh/,
5757
);
5858
});
59-
test("should fallback to <a> without link component", async ({ page }) => {
59+
test(`${packageName} should fallback to <a> without link component`, async ({ page }) => {
6060
await mockBlocksEndpoint(page, [getBlock({ url: "/another-page" })]);
61-
await page.goto(`/react.html`);
61+
await page.goto(`/${packageName}.html`);
6262
await expect(page.getByText("My modal", { exact: true })).toBeVisible();
6363
await page.getByText("Go to another page", { exact: true }).click();
6464
await expect(page).toHaveURL(`/another-page`);
6565
});
66-
test("shouldn't use link component with target blank", async ({ page }) => {
66+
test(`${packageName} shouldn't use link component with target blank`, async ({ page }) => {
6767
await mockBlocksEndpoint(page, [getBlock({ url: "/another-page", openInNew: true })]);
68-
await page.goto(`/react.html?LinkComponent=true`);
68+
await page.goto(`/${packageName}.html?customNavigation=true`);
6969
await expect(page.getByText("My modal", { exact: true })).toBeVisible();
7070
await page.getByText("Go to another page", { exact: true }).click();
7171
const newTabPromise = page.waitForEvent("popup");
72-
await expect(page).toHaveURL(`/react.html?LinkComponent=true`);
72+
await expect(page).toHaveURL(`/${packageName}.html?customNavigation=true`);
7373
const newTab = await newTabPromise;
7474
await expect(newTab).toHaveURL("/another-page");
7575
});
76-
test("shouldn't use link component for external links", async ({ page }) => {
76+
test(`${packageName} shouldn't use link component for external links`, async ({ page }) => {
7777
await mockBlocksEndpoint(page, [getBlock({ url: "https://example.com" })]);
78-
await page.goto(`/react.html?LinkComponent=true`);
78+
await page.goto(`/${packageName}.html?customNavigation=true`);
7979
await expect(page.getByText("My modal", { exact: true })).toBeVisible();
8080
await page.getByText("Go to another page", { exact: true }).click();
8181
await expect(page).toHaveURL(`https://example.com`);
8282
});
83-
});
83+
};
84+
85+
run("js");
86+
run("react");

workspaces/e2e/tests/survey.spec.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -402,19 +402,18 @@ const run = (packageName: string) => {
402402
],
403403
});
404404
await mockBlocksEndpoint(page, [block]);
405-
await page.goto(`/${packageName}.html?LinkComponent=true`);
405+
await page.goto(`/${packageName}.html?customNavigation=true`);
406406
await expect(page.locator(".flows_basicsV2_survey_popover_submit")).toHaveCount(0);
407407
const submitReq = waitForSurveySubmit({
408408
page,
409409
block,
410410
questions: [{ questionId: "question-1", clickedLink: true }],
411-
urlMatcher: (url) => url === `http://localhost:3000/${packageName}.html?LinkComponent=true`,
411+
urlMatcher: (url) =>
412+
url === `http://localhost:3000/${packageName}.html?customNavigation=true`,
412413
});
413414
await page.getByRole("link", { name: "Go to another page" }).click();
414415
await submitReq;
415-
if (packageName === "react")
416-
await expect(page).toHaveURL(`/${packageName}.html?LinkComponent=true#/another-page`);
417-
if (packageName === "js") await expect(page).toHaveURL(`/another-page`);
416+
await expect(page).toHaveURL(`/${packageName}.html?customNavigation=true#/another-page`);
418417
});
419418
test(`${packageName} - shouldn't send unanswered optional link`, async ({ page }) => {
420419
const block = getBlock({
@@ -439,7 +438,7 @@ const run = (packageName: string) => {
439438
}
440439
});
441440
await mockBlocksEndpoint(page, [block]);
442-
await page.goto(`/${packageName}.html?LinkComponent=true`);
441+
await page.goto(`/${packageName}.html?customNavigation=true`);
443442
await expect(page.locator(".flows_basicsV2_survey_popover_submit")).toHaveCount(0);
444443
await page.locator(".flows_basicsV2_survey_popover_close").click();
445444
await page.waitForTimeout(300);
@@ -474,24 +473,22 @@ const run = (packageName: string) => {
474473
],
475474
});
476475
await mockBlocksEndpoint(page, [block]);
477-
await page.goto(`/${packageName}.html?LinkComponent=true`);
476+
await page.goto(`/${packageName}.html?customNavigation=true`);
478477
await page.locator(".flows_basicsV2_survey_popover_freeform_textarea").fill("Answer");
479478
const submitReq = waitForSurveySubmit({
480479
page,
481480
block,
482481
questions: [{ questionId: "question-1", textResponse: "Answer" }],
483482
urlMatcher: (url) =>
484-
url.startsWith(`http://localhost:3000/${packageName}.html?LinkComponent=true`),
483+
url.startsWith(`http://localhost:3000/${packageName}.html?customNavigation=true`),
485484
});
486485
await page.locator(".flows_basicsV2_survey_popover_submit").click();
487486
await submitReq;
488487
await expect(page.getByText("All done", { exact: true })).toBeVisible();
489488
await expect(page.getByText("Thanks for your feedback", { exact: true })).toBeVisible();
490489
await expect(page.locator(".flows_basicsV2_survey_popover_close")).toHaveCount(0);
491490
await page.getByRole("link", { name: "Back to app" }).click();
492-
if (packageName === "react")
493-
await expect(page).toHaveURL(`/${packageName}.html?LinkComponent=true#/another-page`);
494-
if (packageName === "js") await expect(page).toHaveURL(`/another-page`);
491+
await expect(page).toHaveURL(`/${packageName}.html?customNavigation=true#/another-page`);
495492
});
496493
});
497494

workspaces/js-components/src/internal-components/button.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type ButtonSize, type ButtonVariant } from "@flows/shared";
1+
import { isInternalLink, type ButtonSize, type ButtonVariant } from "@flows/shared";
22
import { clsx } from "clsx";
33
import { type TemplateResult } from "lit";
44
import { html, literal } from "lit/static-html.js";
@@ -37,11 +37,26 @@ export const Button = ({
3737
classNameProp,
3838
);
3939

40+
const handleClick = (event: PointerEvent) => {
41+
// The click is fired before the navigation, this is in line with how e.g. "next/link" works
42+
onClick?.();
43+
44+
const navigationHandler = window.__flows_onNavigate;
45+
if (
46+
navigationHandler &&
47+
typeof navigationHandler === "function" &&
48+
href &&
49+
isInternalLink(href, target)
50+
) {
51+
navigationHandler(href, event);
52+
}
53+
};
54+
4055
return html`
4156
<${tag}
4257
type=${tag === buttonLiteral ? "button" : undefined}
4358
class=${className}
44-
@click=${onClick}
59+
@click=${handleClick}
4560
target=${target}
4661
href=${href}
4762
?disabled=${disabled}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { type OnNavigate } from "@flows/shared";
2+
3+
declare global {
4+
interface Window {
5+
__flows_onNavigate?: OnNavigate;
6+
}
7+
}

workspaces/js/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export type {
1313
WorkflowStatus,
1414
WorkflowFrequency,
1515
WorkflowUserState,
16+
// Navigation
17+
OnNavigate,
1618
// Components
1719
ComponentProps,
1820
TourComponentProps,

workspaces/js/src/init.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export const init = ({ debug, onDebugShortcut, ...options }: FlowsOptions): void
1717
const apiUrl = options.apiUrl ?? "https://api.flows-cloud.com";
1818
config.value = { ...options, apiUrl };
1919

20+
window.__flows_onNavigate = options.onNavigate;
21+
2022
connectToWebsocketAndFetchBlocks();
2123

2224
if (locationChangeInterval !== null) clearInterval(locationChangeInterval);

workspaces/js/src/types/configuration.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CustomFetch, LanguageOption, UserProperties } from "@flows/shared";
1+
import type { CustomFetch, LanguageOption, OnNavigate, UserProperties } from "@flows/shared";
22

33
export interface FlowsOptions {
44
/**
@@ -63,4 +63,33 @@ export interface FlowsOptions {
6363
* ```
6464
*/
6565
onDebugShortcut?: (event: KeyboardEvent) => boolean;
66+
67+
/**
68+
* Custom navigation handler for client-side navigation when using components from `@flows/js-components`.
69+
* Without this, every link click results in a full page reload.
70+
*
71+
* Expects a function from your router library (e.g. `navigateTo()` from Nuxt).
72+
* The function receives the `href` string and the `PointerEvent`.
73+
*
74+
* `onNavigate` is called for internal links (relative URLs) without `target="_blank"`:
75+
* - `/about` — internal, `onNavigate` is called
76+
* - `?search=test` — internal, `onNavigate` is called
77+
* - `https://example.com` — external URL, `onNavigate` is not called
78+
* - `/about` with `openInNew` — internal URL with `target="_blank"`, `onNavigate` is not called
79+
*
80+
* @example
81+
* ```ts
82+
* import { init } from "@flows/js";
83+
*
84+
* init({
85+
* onNavigate: (href, event) => {
86+
* // Prevent full page reload
87+
* event.preventDefault();
88+
* // Use your router's navigation method, e.g. navigateTo() from Nuxt
89+
* navigateTo(href);
90+
* }
91+
* });
92+
* ```
93+
*/
94+
onNavigate?: OnNavigate;
6695
}

workspaces/js/src/window.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { type OnNavigate } from "@flows/shared";
2+
3+
declare global {
4+
interface Window {
5+
__flows_onNavigate?: OnNavigate;
6+
}
7+
}

0 commit comments

Comments
 (0)