From a44f01bb922654bb3e7979147da2274d18df9c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20Vidra?= Date: Fri, 1 Mar 2024 09:26:09 +0100 Subject: [PATCH] feat: cancel and secondary button (#36) --- .../e2e/tests/async-flow/async-flow.spec.ts | 4 +- workspaces/e2e/tests/branch/branch.spec.ts | 16 ++--- workspaces/e2e/tests/branch/branch.ts | 2 +- workspaces/e2e/tests/buttons/buttons.spec.ts | 67 ++++++++++-------- workspaces/e2e/tests/buttons/buttons.ts | 6 ++ .../e2e/tests/multi-page/multi-page.spec.ts | 4 +- .../e2e/tests/tracking/tracking.spec.ts | 6 +- workspaces/js/css/template.css | 68 ++++++++----------- workspaces/js/src/core/init.ts | 6 +- workspaces/js/src/core/render.tsx | 56 +++++++-------- workspaces/js/src/core/validation.ts | 2 + workspaces/js/src/types/flow.ts | 9 +++ 12 files changed, 134 insertions(+), 112 deletions(-) diff --git a/workspaces/e2e/tests/async-flow/async-flow.spec.ts b/workspaces/e2e/tests/async-flow/async-flow.spec.ts index 905512b..a716363 100644 --- a/workspaces/e2e/tests/async-flow/async-flow.spec.ts +++ b/workspaces/e2e/tests/async-flow/async-flow.spec.ts @@ -15,7 +15,7 @@ test("Should not continue if flow is not fully loaded", async ({ page }) => { await page.route("**/sdk/flows/flow?projectId=my-proj", (route) => route.abort()); await page.goto("/async-flow/async-flow.html"); await expect(page.locator(".flows-tooltip")).toContainText("First"); - await page.locator(".flows-continue").click(); + await page.locator(".flows-next").click(); await expect(page.locator(".flows-tooltip")).toContainText("First"); }); @@ -44,6 +44,6 @@ test("Should continue if flow is fully loaded", async ({ page }) => { }); await page.goto("/async-flow/async-flow.html"); await expect(page.locator(".flows-tooltip")).toContainText("First"); - await page.locator(".flows-continue").click(); + await page.locator(".flows-next").click(); await expect(page.locator(".flows-tooltip")).toContainText("Second"); }); diff --git a/workspaces/e2e/tests/branch/branch.spec.ts b/workspaces/e2e/tests/branch/branch.spec.ts index c2904da..e0335d0 100644 --- a/workspaces/e2e/tests/branch/branch.spec.ts +++ b/workspaces/e2e/tests/branch/branch.spec.ts @@ -2,11 +2,11 @@ import { expect, test } from "@playwright/test"; test("Enter branch by footerAction", async ({ page }) => { await page.goto("/branch/branch.html"); - await page.locator(".flows-option").nth(0).click(); + await page.locator(".flows-action").nth(0).click(); await expect(page.locator(".flows-tooltip")).toContainText("Variant 1"); await page.locator(".flows-finish").click(); await page.goto("/branch/branch.html"); - await page.locator(".flows-option").nth(1).click(); + await page.locator(".flows-action").nth(1).click(); await expect(page.locator(".flows-tooltip")).toContainText("Variant 2"); }); test("Enter branch by click", async ({ page }) => { @@ -22,22 +22,22 @@ test("Exits branch and returns to root step", async ({ page }) => { await page.goto("/branch/branch.html?lastStep=true"); await page.locator(".enter-1").click(); await expect(page.locator(".flows-tooltip")).toContainText("Variant 1"); - await page.locator(".flows-continue").click(); + await page.locator(".flows-next").click(); await expect(page.locator(".flows-tooltip")).toContainText("Last Step"); }); test("branch can have multiple steps", async ({ page }) => { await page.goto("/branch/branch.html?lastStep=true"); await page.locator(".enter-2").click(); await expect(page.locator(".flows-tooltip")).toContainText("Variant 2"); - await page.locator(".flows-continue").click(); + await page.locator(".flows-next").click(); await expect(page.locator(".flows-tooltip")).toContainText("Var 2 last step"); - await page.locator(".flows-continue").click(); + await page.locator(".flows-next").click(); await expect(page.locator(".flows-tooltip")).toContainText("Last Step"); }); test("should reset flow when entering branch without targetBranch", async ({ page }) => { await page.goto("/branch/branch.html?hideNext=false&logErrors=true"); await expect(page.locator(".flows-tooltip")).toBeVisible(); - await page.locator(".flows-continue").click(); + await page.locator(".flows-next").click(); await expect(page.locator(".flows-tooltip")).toBeHidden(); await page.locator(".start-flow").click(); await expect(page.locator(".flows-tooltip")).toBeVisible(); @@ -46,8 +46,8 @@ test("should reset flow when entering branch without targetBranch", async ({ pag test("should reset flow when entering out of bound step", async ({ page }) => { await page.goto("/branch/branch.html?lastStep=true&logErrors=true"); await page.locator(".enter-1").click(); - await page.locator(".flows-continue").click(); - await page.locator(".flows-option").click(); + await page.locator(".flows-next").click(); + await page.locator(".flows-action").click(); await expect(page.locator(".flows-tooltip")).toBeHidden(); await page.locator(".start-flow").click(); await expect(page.locator(".flows-tooltip")).toBeVisible(); diff --git a/workspaces/e2e/tests/branch/branch.ts b/workspaces/e2e/tests/branch/branch.ts index 9462ccc..077e5fd 100644 --- a/workspaces/e2e/tests/branch/branch.ts +++ b/workspaces/e2e/tests/branch/branch.ts @@ -45,7 +45,7 @@ if (lastStep) steps.push({ targetElement: ".target", title: "Last Step", - footerActions: { right: [{ label: "Continue", targetBranch: undefined }] }, + footerActions: { right: [{ label: "Continue", targetBranch: 0 }] }, }); void init({ diff --git a/workspaces/e2e/tests/buttons/buttons.spec.ts b/workspaces/e2e/tests/buttons/buttons.spec.ts index 1e194db..a36529d 100644 --- a/workspaces/e2e/tests/buttons/buttons.spec.ts +++ b/workspaces/e2e/tests/buttons/buttons.spec.ts @@ -13,57 +13,57 @@ test("Hides close", async ({ page }) => { test("Next is visible", async ({ page }) => { await page.goto("/buttons/buttons.html"); - await expect(page.locator(".flows-continue")).toBeVisible(); - await page.locator(".flows-continue").click(); - await expect(page.locator(".flows-continue")).not.toBeVisible(); + await expect(page.locator(".flows-next")).toBeVisible(); + await page.locator(".flows-next").click(); + await expect(page.locator(".flows-next")).not.toBeVisible(); await expect(page.locator(".flows-finish")).toBeVisible(); }); test("Hides next", async ({ page }) => { await page.goto("/buttons/buttons.html?hideNext=true"); - await expect(page.locator(".flows-continue")).not.toBeVisible(); + await expect(page.locator(".flows-next")).not.toBeVisible(); }); test("Prev is visible", async ({ page }) => { await page.goto("/buttons/buttons.html"); - await expect(page.locator(".flows-back")).not.toBeVisible(); - await page.locator(".flows-continue").click(); - await expect(page.locator(".flows-back")).toBeVisible(); + await expect(page.locator(".flows-prev")).not.toBeVisible(); + await page.locator(".flows-next").click(); + await expect(page.locator(".flows-prev")).toBeVisible(); }); test("Hides prev", async ({ page }) => { await page.goto("/buttons/buttons.html?hidePrev=true"); - await expect(page.locator(".flows-back")).not.toBeVisible(); - await page.locator(".flows-continue").click(); - await expect(page.locator(".flows-back")).not.toBeVisible(); + await expect(page.locator(".flows-prev")).not.toBeVisible(); + await page.locator(".flows-next").click(); + await expect(page.locator(".flows-prev")).not.toBeVisible(); }); test("Default next text", async ({ page }) => { await page.goto("/buttons/buttons.html"); - await expect(page.locator(".flows-continue")).toHaveText("Continue"); - await page.locator(".flows-continue").click(); + await expect(page.locator(".flows-next")).toHaveText("Continue"); + await page.locator(".flows-next").click(); await expect(page.locator(".flows-finish")).toHaveText("Finish"); }); test("Custom next text", async ({ page }) => { await page.goto("/buttons/buttons.html?nextLabel=Next"); - await expect(page.locator(".flows-continue")).toHaveText("Next"); - await page.locator(".flows-continue").click(); + await expect(page.locator(".flows-next")).toHaveText("Next"); + await page.locator(".flows-next").click(); await expect(page.locator(".flows-finish")).toHaveText("Next"); }); test("Default prev text", async ({ page }) => { await page.goto("/buttons/buttons.html"); - await page.locator(".flows-continue").click(); - await expect(page.locator(".flows-back")).toHaveText("Back"); + await page.locator(".flows-next").click(); + await expect(page.locator(".flows-prev")).toHaveText("Back"); }); test("Custom prev text", async ({ page }) => { await page.goto("/buttons/buttons.html?prevLabel=Previous"); - await page.locator(".flows-continue").click(); - await expect(page.locator(".flows-back")).toHaveText("Previous"); + await page.locator(".flows-next").click(); + await expect(page.locator(".flows-prev")).toHaveText("Previous"); }); test("Custom link", async ({ page }) => { await page.goto("/buttons/buttons.html?customLink=true&hideNext=true"); - await expect(page.locator(".flows-option")).toHaveCount(3); - const firstLink = page.locator(".flows-option").first(); + await expect(page.locator("a.flows-primary-btn")).toHaveCount(3); + const firstLink = page.locator("a.flows-primary-btn").first(); await expect(firstLink).toBeVisible(); await expect(firstLink).toHaveText("Google"); await expect(firstLink).toHaveAttribute("href", "https://google.com"); @@ -71,8 +71,8 @@ test("Custom link", async ({ page }) => { }); test("Custom external link", async ({ page }) => { await page.goto("/buttons/buttons.html?customExternalLink=true&hideNext=true"); - await expect(page.locator(".flows-option")).toHaveCount(3); - const firstLink = page.locator(".flows-option").first(); + await expect(page.locator("a.flows-primary-btn")).toHaveCount(3); + const firstLink = page.locator("a.flows-primary-btn").first(); await expect(firstLink).toBeVisible(); await expect(firstLink).toHaveText("Google"); await expect(firstLink).toHaveAttribute("href", "https://google.com"); @@ -80,8 +80,8 @@ test("Custom external link", async ({ page }) => { }); test("Custom action", async ({ page }) => { await page.goto("/buttons/buttons.html?customAction=true&hideNext=true"); - await expect(page.locator(".flows-option")).toHaveCount(3); - const firstLink = page.locator(".flows-option").first(); + await expect(page.locator(".flows-action")).toHaveCount(3); + const firstLink = page.locator(".flows-action").first(); await expect(firstLink).toBeVisible(); await expect(firstLink).toHaveText("Action"); await expect(firstLink).toHaveAttribute("data-action", "0"); @@ -90,8 +90,8 @@ test("Custom action", async ({ page }) => { }); test("Custom next", async ({ page }) => { await page.goto("/buttons/buttons.html?customNext=true&hideNext=true"); - await expect(page.locator(".flows-continue")).toHaveCount(3); - const firstLink = page.locator(".flows-continue").first(); + await expect(page.locator(".flows-next")).toHaveCount(3); + const firstLink = page.locator(".flows-next").first(); await expect(firstLink).toBeVisible(); await expect(firstLink).toHaveText("My Next"); await expect(firstLink).not.toHaveAttribute("href"); @@ -99,10 +99,21 @@ test("Custom next", async ({ page }) => { }); test("Custom prev", async ({ page }) => { await page.goto("/buttons/buttons.html?customPrev=true&hideNext=true"); - await expect(page.locator(".flows-back")).toHaveCount(3); - const firstLink = page.locator(".flows-back").first(); + await expect(page.locator(".flows-prev")).toHaveCount(3); + const firstLink = page.locator(".flows-prev").first(); await expect(firstLink).toBeVisible(); await expect(firstLink).toHaveText("My Prev"); await expect(firstLink).not.toHaveAttribute("href"); await expect(firstLink).not.toHaveAttribute("target"); }); +test("Custom cancel", async ({ page }) => { + await page.goto("/buttons/buttons.html?customCancel=true&hideNext=true&hideClose=true"); + await expect(page.locator(".flows-cancel")).toHaveCount(3); + const firstLink = page.locator(".flows-cancel").first(); + await expect(firstLink).toBeVisible(); + await expect(firstLink).toHaveText("My Cancel"); + await expect(firstLink).not.toHaveAttribute("href"); + await expect(firstLink).not.toHaveAttribute("target"); + await firstLink.click(); + await expect(page.locator(".flows-tooltip")).not.toBeVisible(); +}); diff --git a/workspaces/e2e/tests/buttons/buttons.ts b/workspaces/e2e/tests/buttons/buttons.ts index d568057..be13763 100644 --- a/workspaces/e2e/tests/buttons/buttons.ts +++ b/workspaces/e2e/tests/buttons/buttons.ts @@ -12,6 +12,7 @@ const customExternalLink = const customAction = new URLSearchParams(window.location.search).get("customAction") === "true"; const customNext = new URLSearchParams(window.location.search).get("customNext") === "true"; const customPrev = new URLSearchParams(window.location.search).get("customPrev") === "true"; +const customCancel = new URLSearchParams(window.location.search).get("customCancel") === "true"; const footerActionsArray: NonNullable["left"] = []; if (customLink) @@ -40,6 +41,11 @@ if (customPrev) label: "My Prev", prev: true, }); +if (customCancel) + footerActionsArray.push({ + label: "My Cancel", + cancel: true, + }); const footerActions = { left: footerActionsArray, center: footerActionsArray, diff --git a/workspaces/e2e/tests/multi-page/multi-page.spec.ts b/workspaces/e2e/tests/multi-page/multi-page.spec.ts index 8ef4e29..716fd6f 100644 --- a/workspaces/e2e/tests/multi-page/multi-page.spec.ts +++ b/workspaces/e2e/tests/multi-page/multi-page.spec.ts @@ -6,7 +6,7 @@ test("Resumes flow when page is reopened", async ({ page }) => { 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 page.click(".flows-next"); await expect(page.locator(".flows-tooltip")).toContainText("Second"); await page.goto("/"); await expect(page.locator(".flows-tooltip")).not.toBeVisible(); @@ -21,7 +21,7 @@ test("Doesn't resume flow when page is reopened after timeout", async ({ page }) 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 page.click(".flows-next"); await expect(page.locator(".flows-tooltip")).toContainText("Second"); await page.evaluate(() => localStorage.setItem( diff --git a/workspaces/e2e/tests/tracking/tracking.spec.ts b/workspaces/e2e/tests/tracking/tracking.spec.ts index 74290c5..f2a7bf5 100644 --- a/workspaces/e2e/tests/tracking/tracking.spec.ts +++ b/workspaces/e2e/tests/tracking/tracking.spec.ts @@ -3,11 +3,11 @@ import { expect, test } from "@playwright/test"; test("Emits correct events", async ({ page }) => { await page.goto("/tracking/tracking.html"); await page.locator(".start").click(); - await page.locator(".flows-continue").click(); - await page.locator(".flows-back").click(); + await page.locator(".flows-next").click(); + await page.locator(".flows-prev").click(); await page.locator(".flows-cancel").click(); await page.locator(".start").click(); - await page.locator(".flows-continue").click(); + await page.locator(".flows-next").click(); await page.locator(".flows-finish").click(); const logs = page.locator(".log-item"); await expect(logs.nth(0)).toHaveAttribute("data-type", "startFlow"); diff --git a/workspaces/js/css/template.css b/workspaces/js/css/template.css index e746beb..e17732f 100644 --- a/workspaces/js/css/template.css +++ b/workspaces/js/css/template.css @@ -48,62 +48,54 @@ justify-content: flex-end; } -.flows-button { - background-color: var(--flows-bg-subtle); - border: var(--flows-border); - color: var(--flows-fg-default); +.flows-primary-btn, +.flows-secondary-btn, +.flows-close-btn { border-radius: var(--flows-borderRadius-small); - padding: 4px 8px; - text-wrap: nowrap; - font-family: var(--flows-font-family); - font-size: var(--flows-base-font-size); - line-height: var(--flows-base-line-height); - font-weight: 600; cursor: pointer; transition: background-color 120ms ease-in-out, border-color 120ms ease-in-out; } -.flows-button svg { - pointer-events: none; -} -.flows-button:hover { - background-color: var(--flows-bg-hover); +.flows-primary-btn, +.flows-secondary-btn { + padding: 4px 8px; + font-family: var(--flows-font-family); + font-size: var(--flows-base-font-size); + line-height: var(--flows-base-line-height); + font-weight: 600; + text-wrap: nowrap; + text-decoration: none; } - -.flows-continue, -.flows-option { +.flows-primary-btn { background-color: var(--flows-bg-primary); border: 1px solid var(--flows-bg-primary); color: var(--flows-fg-onPrimary); } - -.flows-continue:hover, -.flows-option:hover { +.flows-primary-btn:hover { background-color: var(--flows-bg-primary-hover); border: 1px solid var(--flows-bg-primary-hover); } - -.flows-finish { - background-color: var(--flows-bg-primary); - border: 1px solid var(--flows-bg-primary); - color: var(--flows-fg-onPrimary); +.flows-secondary-btn { + background-color: var(--flows-bg-subtle); + border: var(--flows-border); + color: var(--flows-fg-default); } - -.flows-finish:hover { - background-color: var(--flows-bg-primary-hover); - border: 1px solid var(--flows-bg-primary-hover); +.flows-secondary-btn:hover { + background-color: var(--flows-bg-hover); } - -.flows-cancel { +.flows-close-btn { + background-color: rgba(0, 0, 0, 0); + border: none; + padding: 0; width: 20px; height: 20px; - border: none; position: absolute; - background-color: rgba(0, 0, 0, 0); - padding: 0; } -.flows-cancel:after { +.flows-close-btn:hover { + background-color: var(--flows-bg-hover); +} +.flows-close-btn:after { content: ""; position: absolute; top: 0; @@ -156,7 +148,7 @@ position: fixed; inset: 0; } -.flows-tooltip .flows-cancel { +.flows-tooltip .flows-close-btn { top: var(--flows-tooltip-padding); right: var(--flows-tooltip-padding); } @@ -209,7 +201,7 @@ min-width: var(--flows-modal-minWidth); max-width: var(--flows-modal-maxWidth); } -.flows-modal .flows-cancel { +.flows-modal .flows-close-btn { top: var(--flows-modal-padding); right: var(--flows-modal-padding); } diff --git a/workspaces/js/src/core/init.ts b/workspaces/js/src/core/init.ts index 7e641f6..bb3e124 100644 --- a/workspaces/js/src/core/init.ts +++ b/workspaces/js/src/core/init.ts @@ -68,19 +68,19 @@ const _init = (options: FlowsInitOptions): void => { if (matchingWait) state.nextStep(matchingWait.targetBranch).render(); }); - if (eventTarget.matches(".flows-back")) { + if (eventTarget.matches(".flows-prev")) { const flow = Array.from(FlowsContext.getInstance().instances.values()).find((s) => s.flowElement?.element.contains(eventTarget), ); flow?.prevStep().render(); } - if (eventTarget.matches(".flows-continue")) { + if (eventTarget.matches(".flows-next")) { const flow = Array.from(FlowsContext.getInstance().instances.values()).find((s) => s.flowElement?.element.contains(eventTarget), ); flow?.nextStep().render(); } - if (eventTarget.matches(".flows-option")) { + if (eventTarget.matches(".flows-action")) { const action = Number(eventTarget.getAttribute("data-action")); if (Number.isNaN(action)) return; const flow = Array.from(FlowsContext.getInstance().instances.values()).find((s) => diff --git a/workspaces/js/src/core/render.tsx b/workspaces/js/src/core/render.tsx index 1e2c4bd..0781394 100644 --- a/workspaces/js/src/core/render.tsx +++ b/workspaces/js/src/core/render.tsx @@ -81,24 +81,9 @@ const updateTooltip = ({ const getStepHeader = ({ step }: { step: FlowTooltipStep | FlowModalStep }): HTMLElement => (

- {!step.hideClose &&

); -const getContinueButton = ({ - state, - children, -}: { - children?: string; - state: FlowState; -}): HTMLElement => - state.hasNextStep ? ( - - ) : ( - - ); -const getBackButton = ({ children }: { children?: string }): HTMLElement => ( - -); const getStepFooterActionButton = ({ props, state, @@ -106,25 +91,42 @@ const getStepFooterActionButton = ({ props: FooterActionItem; state: FlowState; }): HTMLElement => { - if (props.prev) return getBackButton({ children: props.label }); - if (props.next) return getContinueButton({ children: props.label, state }); - const buttonClassName = "flows-option flows-button"; + const classList = []; + const variant = props.variant || "primary"; + + if (variant === "primary") classList.push("flows-primary-btn"); + if (variant === "secondary") classList.push("flows-secondary-btn"); + if (props.cancel) classList.push("flows-cancel"); + if (props.prev) classList.push("flows-prev"); + if (props.next && state.hasNextStep) classList.push("flows-next"); + if (props.next && !state.hasNextStep) classList.push("flows-finish"); + if (props.targetBranch !== undefined) classList.push("flows-action"); + + const className = classList.join(" "); + if (props.href) return ( - + {props.label} ); return ( - ); }; +const getNextButton = ({ state, label }: { state: FlowState; label?: string }): HTMLElement => + getStepFooterActionButton({ + props: { next: true, label: label || (state.hasNextStep ? "Continue" : "Finish") }, + state, + }); +const getPrevButton = ({ state, label }: { state: FlowState; label?: string }): HTMLElement => + getStepFooterActionButton({ + props: { prev: true, label: label || "Back", variant: "secondary" }, + state, + }); + const getStepFooterActions = ({ items, state, @@ -140,8 +142,8 @@ const getStepFooter = ({ state: FlowState; }): HTMLElement | null => { const backBtn = - state.hasPrevStep && !step.hidePrev && getBackButton({ children: step.prevLabel }); - const continueBtn = !step.hideNext && getContinueButton({ state, children: step.nextLabel }); + state.hasPrevStep && !step.hidePrev && getPrevButton({ label: step.prevLabel, state }); + const continueBtn = !step.hideNext && getNextButton({ label: step.nextLabel, state }); const leftOptions = getStepFooterActions({ items: step.footerActions?.left, state }); const centerOptions = getStepFooterActions({ items: step.footerActions?.center, state }); const rightOptions = getStepFooterActions({ items: step.footerActions?.right, state }); diff --git a/workspaces/js/src/core/validation.ts b/workspaces/js/src/core/validation.ts index a2142c1..f34034d 100644 --- a/workspaces/js/src/core/validation.ts +++ b/workspaces/js/src/core/validation.ts @@ -64,6 +64,8 @@ const FooterActionItemStruct: Describe = object({ external: optional(boolean()), next: optional(boolean()), prev: optional(boolean()), + cancel: optional(boolean()), + variant: optional(enums(["primary", "secondary"])), }); const FooterActionsStruct: Describe = object({ center: optional(array(FooterActionItemStruct)), diff --git a/workspaces/js/src/types/flow.ts b/workspaces/js/src/types/flow.ts index 56af769..06f6a81 100644 --- a/workspaces/js/src/types/flow.ts +++ b/workspaces/js/src/types/flow.ts @@ -19,6 +19,15 @@ export interface FooterActionItem { * When true the button will act as a next step or finish button. */ next?: boolean; + /** + * When true the button will cancel the flow. + */ + cancel?: boolean; + /** + * Variant of the button. + * @defaultValue `primary` + */ + variant?: "primary" | "secondary"; /** * Use to navigate to a custom URL. The button element will be rendered as an anchor tag. */