feat: improved survey UI (#6988)

Co-authored-by: Matti Nannt <matti@formbricks.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Dhruwang Jariwala
2025-12-17 20:43:28 +05:30
committed by GitHub
parent 3ce07edf43
commit 15dc83a4eb
160 changed files with 18354 additions and 6088 deletions

View File

@@ -1,8 +1,11 @@
import type { StorybookConfig } from "@storybook/react-vite";
import { createRequire } from "module";
import { dirname, join } from "path";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* This function is used to resolve the absolute path of a package.
@@ -13,7 +16,7 @@ function getAbsolutePath(value: string): any {
}
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
stories: ["../src/**/*.mdx", "../../../packages/survey-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"),
@@ -25,5 +28,25 @@ const config: StorybookConfig = {
name: getAbsolutePath("@storybook/react-vite"),
options: {},
},
async viteFinal(config) {
const surveyUiPath = resolve(__dirname, "../../../packages/survey-ui/src");
const rootPath = resolve(__dirname, "../../../");
// Configure server to allow files from outside the storybook directory
config.server = config.server || {};
config.server.fs = {
...config.server.fs,
allow: [...(config.server.fs?.allow || []), rootPath],
};
// Configure simple alias resolution
config.resolve = config.resolve || {};
config.resolve.alias = {
...config.resolve.alias,
"@": surveyUiPath,
};
return config;
},
};
export default config;

View File

@@ -1,19 +1,6 @@
import type { Preview } from "@storybook/react-vite";
import React from "react";
import { I18nProvider } from "../../web/lingodotdev/client";
import "../../web/modules/ui/globals.css";
// Create a Storybook-specific Lingodot Dev decorator
const withLingodotDev = (Story: any) => {
return React.createElement(
I18nProvider,
{
language: "en-US",
defaultLanguage: "en-US",
} as any,
React.createElement(Story)
);
};
import "../../../packages/survey-ui/src/styles/globals.css";
const preview: Preview = {
parameters: {
@@ -22,9 +9,23 @@ const preview: Preview = {
color: /(background|color)$/i,
date: /Date$/i,
},
expanded: true,
},
backgrounds: {
default: "light",
},
},
decorators: [withLingodotDev],
decorators: [
(Story) =>
React.createElement(
"div",
{
id: "fbjs",
className: "w-full h-full min-h-screen p-4 bg-background font-sans antialiased text-foreground",
},
React.createElement(Story)
),
],
};
export default preview;

View File

@@ -11,22 +11,24 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"eslint-plugin-react-refresh": "0.4.20"
"@formbricks/survey-ui": "workspace:*",
"eslint-plugin-react-refresh": "0.4.24"
},
"devDependencies": {
"@chromatic-com/storybook": "^4.0.1",
"@storybook/addon-a11y": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/react-vite": "9.0.15",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react": "4.4.1",
"esbuild": "0.25.4",
"eslint-plugin-storybook": "9.0.15",
"@chromatic-com/storybook": "^4.1.3",
"@storybook/addon-a11y": "10.0.8",
"@storybook/addon-links": "10.0.8",
"@storybook/addon-onboarding": "10.0.8",
"@storybook/react-vite": "10.0.8",
"@typescript-eslint/eslint-plugin": "8.48.0",
"@tailwindcss/vite": "4.1.17",
"@typescript-eslint/parser": "8.48.0",
"@vitejs/plugin-react": "5.1.1",
"esbuild": "0.27.0",
"eslint-plugin-storybook": "10.0.8",
"prop-types": "15.8.1",
"storybook": "9.0.15",
"vite": "6.4.1",
"@storybook/addon-docs": "9.0.15"
"storybook": "10.0.8",
"vite": "7.2.4",
"@storybook/addon-docs": "10.0.8"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,7 +1,15 @@
/** @type {import('tailwindcss').Config} */
import base from "../web/tailwind.config";
import surveyUi from "../../packages/survey-ui/tailwind.config";
export default {
...base,
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}", "../web/modules/ui/**/*.{js,ts,jsx,tsx}"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"../../packages/survey-ui/src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
...surveyUi.theme?.extend,
},
},
};

View File

@@ -1,16 +1,17 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
define: {
"process.env": {},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "../web"),
"@formbricks/survey-ui": path.resolve(__dirname, "../../packages/survey-ui/src"),
},
},
});

View File

@@ -15,7 +15,7 @@ export const renderEmailResponseValue = async (
return (
<Container>
{overrideFileUploadResponse ? (
<Text className="mt-0 whitespace-pre-wrap break-words text-sm italic">
<Text className="mt-0 text-sm break-words whitespace-pre-wrap italic">
{t("emails.render_email_response_value_file_upload_response_link_not_included")}
</Text>
) : (
@@ -65,6 +65,6 @@ export const renderEmailResponseValue = async (
);
default:
return <Text className="mt-0 whitespace-pre-wrap break-words text-sm">{response}</Text>;
return <Text className="mt-0 text-sm break-words whitespace-pre-wrap">{response}</Text>;
}
};

View File

@@ -74,7 +74,7 @@ export async function ResponseFinishedEmail({
)}
{variable.name}
</Text>
<Text className="mt-0 whitespace-pre-wrap break-words font-medium">
<Text className="mt-0 font-medium break-words whitespace-pre-wrap">
{variableResponse}
</Text>
</Column>
@@ -94,7 +94,7 @@ export async function ResponseFinishedEmail({
<Text className="mb-2 flex items-center gap-2 text-sm">
{hiddenFieldId} <EyeOffIcon />
</Text>
<Text className="mt-0 whitespace-pre-wrap break-words text-sm">
<Text className="mt-0 text-sm break-words whitespace-pre-wrap">
{hiddenFieldResponse}
</Text>
</Column>

View File

@@ -284,7 +284,7 @@ export const BlockCard = ({
</div>
<button
className="opacity-0 hover:cursor-move group-hover:opacity-100"
className="opacity-0 group-hover:opacity-100 hover:cursor-move"
aria-label="Drag to reorder block">
<GripIcon className="h-4 w-4" />
</button>

View File

@@ -400,7 +400,7 @@ export const SurveyMenuBar = ({
/>
</div>
<div className="mt-3 flex items-center gap-2 sm:ml-4 sm:mt-0">
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
{!isStorageConfigured && (
<div>
<Alert variant="warning" size="small">

View File

@@ -84,7 +84,7 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JS
? `${t("emails.number_variable")}: ${variable.name}`
: `${t("emails.text_variable")}: ${variable.name}`}
</Text>
<Text className="mt-0 whitespace-pre-wrap break-words text-sm text-slate-700">
<Text className="mt-0 text-sm break-words whitespace-pre-wrap text-slate-700">
{variableResponse}
</Text>
</Column>
@@ -107,7 +107,7 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JS
<Text className="mb-2 text-sm font-semibold text-slate-900">
{t("emails.hidden_field")}: {hiddenFieldId}
</Text>
<Text className="mt-0 whitespace-pre-wrap break-words text-sm text-slate-700">
<Text className="mt-0 text-sm break-words whitespace-pre-wrap text-slate-700">
{hiddenFieldResponse}
</Text>
</Column>

View File

@@ -155,7 +155,7 @@ export const FollowUpItem = ({
</div>
</button>
<div className="absolute right-4 top-4 flex items-center">
<div className="absolute top-4 right-4 flex items-center">
<TooltipRenderer tooltipContent={t("common.delete")}>
<Button
variant="ghost"

View File

@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 hover:enabled:border-slate-400 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 focus:outline-none hover:enabled:border-slate-400 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
className
)}
{...props}>
@@ -52,7 +52,7 @@ const SelectLabel: React.ComponentType<SelectPrimitive.SelectLabelProps> = React
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold text-slate-900 dark:text-slate-200", className)}
className={cn("py-1.5 pr-2 pl-8 text-sm font-semibold text-slate-900 dark:text-slate-200", className)}
{...props}
/>
));
@@ -65,7 +65,7 @@ const SelectItem: React.ComponentType<SelectPrimitive.SelectItemProps> = React.f
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-2 pr-2 text-sm font-medium outline-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-pointer items-center rounded-md py-1.5 pr-2 pl-2 text-sm font-medium outline-none select-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>

View File

@@ -72,8 +72,8 @@
"@radix-ui/react-tooltip": "1.2.6",
"@react-email/components": "0.0.38",
"@sentry/nextjs": "10.5.0",
"@t3-oss/env-nextjs": "0.13.4",
"@tailwindcss/forms": "0.5.10",
"@t3-oss/env-nextjs": "0.13.4",
"@tailwindcss/typography": "0.5.16",
"@tanstack/react-table": "8.21.3",
"@ungap/structured-clone": "1.3.0",
@@ -116,6 +116,7 @@
"react-day-picker": "9.6.7",
"react-hook-form": "7.56.2",
"react-hot-toast": "2.5.2",
"react-calendar": "5.1.0",
"react-i18next": "15.7.3",
"react-turnstile": "1.1.4",
"react-use": "17.6.0",

View File

@@ -115,12 +115,12 @@ test.describe("JS Package Test", async () => {
await page.locator("#questionCard-2").getByRole("button", { name: "Next" }).click();
await page
.locator("#questionCard-3")
.getByLabel("textarea")
.getByRole("textbox")
.fill("People who believe that PMF is necessary");
await page.locator("#questionCard-3").getByRole("button", { name: "Next" }).click();
await page.locator("#questionCard-4").getByLabel("textarea").fill("Much higher response rates!");
await page.locator("#questionCard-4").getByRole("textbox").fill("Much higher response rates!");
await page.locator("#questionCard-4").getByRole("button", { name: "Next" }).click();
await page.locator("#questionCard-5").getByLabel("textarea").fill("Make this end to end test pass!");
await page.locator("#questionCard-5").getByRole("textbox").fill("Make this end to end test pass!");
await page.locator("#questionCard-5").getByRole("button", { name: "Finish" }).click();
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });

View File

@@ -113,10 +113,12 @@ test.describe("Survey Create & Submit Response without logic", async () => {
await expect(
page.locator("#questionCard-3").getByText(surveys.createAndSubmit.ratingQuestion.highLabel)
).toBeVisible();
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
// Rating component uses fieldset with labels, not a group with name "Choices"
expect(await page.locator("#questionCard-3").locator("fieldset label").count()).toBe(5);
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByRole("radio", { name: "Rate 3 out of" }).check();
// Click on the label instead of the radio to avoid SVG intercepting pointer events
await page.locator("#questionCard-3").locator('label:has(input[value="3"])').click();
await page.locator("#questionCard-3").getByRole("button", { name: "Next" }).click();
// NPS Question
@@ -165,9 +167,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
await expect(page.getByText(surveys.createAndSubmit.fileUploadQuestion.question)).toBeVisible();
await expect(page.locator("#questionCard-8").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-8").getByRole("button", { name: "Back" })).toBeVisible();
await expect(
page.locator("label").filter({ hasText: "Click or drag to upload files." }).locator("button").nth(0)
).toBeVisible();
await expect(page.getByRole("button", { name: "Upload files by clicking or" })).toBeVisible();
await page.locator("input[type=file]").setInputFiles({
name: "file.doc",
@@ -191,22 +191,22 @@ test.describe("Survey Create & Submit Response without logic", async () => {
page.getByRole("rowheader", { name: surveys.createAndSubmit.matrix.rows[2] })
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: surveys.createAndSubmit.matrix.columns[0] })
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[0], exact: true })
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: surveys.createAndSubmit.matrix.columns[1] })
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[1], exact: true })
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: surveys.createAndSubmit.matrix.columns[2] })
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[2], exact: true })
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: surveys.createAndSubmit.matrix.columns[3] })
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[3], exact: true })
).toBeVisible();
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByRole("cell", { name: "Roses 0" }).locator("div").click();
await page.getByRole("cell", { name: "Trees 0" }).locator("div").click();
await page.getByRole("cell", { name: "Ocean 0" }).locator("div").click();
await page.getByRole("radio", { name: "Roses-0" }).click();
await page.getByRole("radio", { name: "Trees-0" }).click();
await page.getByRole("radio", { name: "Ocean-0" }).click();
await page.locator("#questionCard-9").getByRole("button", { name: "Next" }).click();
// Address Question
@@ -858,7 +858,8 @@ test.describe("Testing Survey with advanced logic", async () => {
).toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByRole("radio", { name: "Rate 4 out of" }).check();
// Click on the label instead of the radio to avoid SVG intercepting pointer events
await page.locator("#questionCard-4").locator('label:has(input[value="4"])').click();
await page.locator("#questionCard-4").getByRole("button", { name: "Next" }).click();
// NPS Question
@@ -895,22 +896,22 @@ test.describe("Testing Survey with advanced logic", async () => {
page.getByRole("rowheader", { name: surveys.createWithLogicAndSubmit.matrix.rows[2] })
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: surveys.createWithLogicAndSubmit.matrix.columns[0] })
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[0], exact: true })
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: surveys.createWithLogicAndSubmit.matrix.columns[1] })
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[1], exact: true })
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: surveys.createWithLogicAndSubmit.matrix.columns[2] })
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[2], exact: true })
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: surveys.createWithLogicAndSubmit.matrix.columns[3] })
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[3], exact: true })
).toBeVisible();
await expect(page.locator("#questionCard-7").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-7").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByRole("cell", { name: "Roses 0" }).locator("div").click();
await page.getByRole("cell", { name: "Trees 0" }).locator("div").click();
await page.getByRole("cell", { name: "Ocean 0" }).locator("div").click();
await page.getByRole("radio", { name: "Roses-0" }).click();
await page.getByRole("radio", { name: "Trees-0" }).click();
await page.getByRole("radio", { name: "Ocean-0" }).click();
await page.locator("#questionCard-7").getByRole("button", { name: "Next" }).click();
// CTA Question
@@ -939,9 +940,9 @@ test.describe("Testing Survey with advanced logic", async () => {
).toBeVisible();
await expect(page.locator("#questionCard-10").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-10").getByRole("button", { name: "Back" })).toBeVisible();
await expect(
page.locator("label").filter({ hasText: "Click or drag to upload files." }).locator("button").nth(0)
).toBeVisible();
await expect(page.getByRole("button", { name: "Upload files by clicking or" })).toBeVisible();
await page.locator("input[type=file]").setInputFiles({
name: "file.doc",
mimeType: "application/msword",
@@ -952,11 +953,10 @@ test.describe("Testing Survey with advanced logic", async () => {
// Date Question
await expect(page.getByText(surveys.createWithLogicAndSubmit.date.question)).toBeVisible();
await page.getByText("Select a date").click();
const date = new Date().getDate();
const month = new Date().toLocaleString("default", { month: "long" });
await page.getByRole("button", { name: `${month} ${date},` }).click();
await page.locator("#questionCard-11").getByRole("button", { name: "Next" }).click();
// Click the "Today" button in the date picker - matches format like "Today, Tuesday, December 16th,"
await page.getByRole("button", { name: /^Today,/ }).click();
await page.getByRole("button", { name: "Scroll to bottom" }).click();
await page.locator("#questionCard-11").getByRole("button", { name: "Next", exact: true }).click();
// Cal Question
await expect(page.getByText(surveys.createWithLogicAndSubmit.cal.question)).toBeVisible();