mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-04 10:30:00 -06:00
Compare commits
6 Commits
v3.11.0
...
devin/1747
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f30e535740 | ||
|
|
d54ce797e4 | ||
|
|
7678084061 | ||
|
|
022d33d06f | ||
|
|
4d157bf8dc | ||
|
|
9fcbe4e8c5 |
@@ -11,9 +11,7 @@
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
"eslint-plugin-react-refresh": "0.4.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "3.2.6",
|
||||
|
||||
@@ -7,39 +7,133 @@ export const GET = async (req: NextRequest) => {
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div tw={`flex flex-col w-full h-full items-center bg-[${brandColor}]/75 rounded-xl `}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
|
||||
borderRadius: "0.75rem",
|
||||
}}>
|
||||
<div
|
||||
tw="flex flex-col w-[80%] h-[60%] bg-white rounded-xl mt-13 absolute left-12 top-3 opacity-20"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "80%",
|
||||
height: "60%",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "3.25rem",
|
||||
position: "absolute",
|
||||
left: "3rem",
|
||||
top: "0.75rem",
|
||||
opacity: 0.2,
|
||||
transform: "rotate(356deg)",
|
||||
}}></div>
|
||||
<div
|
||||
tw="flex flex-col w-[84%] h-[60%] bg-white rounded-xl mt-12 absolute top-5 left-13 border-2 opacity-60"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "84%",
|
||||
height: "60%",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "3rem",
|
||||
position: "absolute",
|
||||
top: "1.25rem",
|
||||
left: "3.25rem",
|
||||
borderWidth: "2px",
|
||||
opacity: 0.6,
|
||||
transform: "rotate(357deg)",
|
||||
}}></div>
|
||||
<div
|
||||
tw="flex flex-col w-[85%] h-[67%] items-center bg-white rounded-xl mt-8 absolute top-[2.3rem] left-14"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "85%",
|
||||
height: "67%",
|
||||
alignItems: "center",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "2rem",
|
||||
position: "absolute",
|
||||
top: "2.3rem",
|
||||
left: "3.5rem",
|
||||
transform: "rotate(360deg)",
|
||||
}}>
|
||||
<div tw="flex flex-col w-full">
|
||||
<div tw="flex flex-col md:flex-row w-full md:items-center justify-between ">
|
||||
<div tw="flex flex-col px-8">
|
||||
<h2 tw="flex flex-col text-[8] sm:text-4xl font-bold tracking-tight text-slate-900 text-left mt-15">
|
||||
<div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
paddingLeft: "2rem",
|
||||
paddingRight: "2rem",
|
||||
}}>
|
||||
<h2
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
fontSize: "2rem",
|
||||
fontWeight: "700",
|
||||
letterSpacing: "-0.025em",
|
||||
color: "#0f172a",
|
||||
textAlign: "left",
|
||||
marginTop: "3.75rem",
|
||||
}}>
|
||||
{name}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div tw="flex justify-end mr-10 ">
|
||||
<div tw="flex rounded-2xl absolute -right-2 mt-2">
|
||||
<a tw={`rounded-xl border border-transparent bg-[${brandColor}] h-18 w-38 opacity-50`}></a>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginRight: "2.5rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
borderRadius: "1rem",
|
||||
position: "absolute",
|
||||
right: "-0.5rem",
|
||||
marginTop: "0.5rem",
|
||||
}}>
|
||||
<div
|
||||
content=""
|
||||
style={{
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid transparent",
|
||||
backgroundColor: brandColor ?? "#000",
|
||||
height: "4.5rem",
|
||||
width: "9.5rem",
|
||||
opacity: 0.5,
|
||||
}}></div>
|
||||
</div>
|
||||
<div tw="flex rounded-2xl shadow ">
|
||||
<a
|
||||
tw={`flex items-center justify-center rounded-xl border border-transparent bg-[${brandColor}] text-2xl text-white h-18 w-38`}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
borderRadius: "1rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid transparent",
|
||||
backgroundColor: brandColor ?? "#000",
|
||||
fontSize: "1.5rem",
|
||||
color: "white",
|
||||
height: "4.5rem",
|
||||
width: "9.5rem",
|
||||
}}>
|
||||
Begin!
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("SentryProvider", () => {
|
||||
expect(initSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dsn: sentryDsn,
|
||||
tracesSampleRate: 1,
|
||||
tracesSampleRate: 0,
|
||||
debug: false,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
@@ -81,6 +81,26 @@ describe("SentryProvider", () => {
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
|
||||
});
|
||||
|
||||
test("does not reinitialize Sentry when props change after initial render", () => {
|
||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||
|
||||
const { rerender } = render(
|
||||
<SentryProvider sentryDsn={sentryDsn} isEnabled>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</SentryProvider>
|
||||
);
|
||||
|
||||
expect(initSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<SentryProvider sentryDsn="https://newDsn@o0.ingest.sentry.io/0" isEnabled={false}>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</SentryProvider>
|
||||
);
|
||||
|
||||
expect(initSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("processes beforeSend correctly", () => {
|
||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||
|
||||
@@ -109,4 +129,36 @@ describe("SentryProvider", () => {
|
||||
const hintWithoutError = { originalException: undefined };
|
||||
expect(beforeSend(dummyEvent, hintWithoutError)).toEqual(dummyEvent);
|
||||
});
|
||||
|
||||
test("processes beforeSend correctly when hint.originalException is not an Error object", () => {
|
||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<SentryProvider sentryDsn={sentryDsn} isEnabled>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</SentryProvider>
|
||||
);
|
||||
|
||||
const config = initSpy.mock.calls[0][0];
|
||||
expect(config).toHaveProperty("beforeSend");
|
||||
const beforeSend = config.beforeSend;
|
||||
|
||||
if (!beforeSend) {
|
||||
throw new Error("beforeSend is not defined");
|
||||
}
|
||||
|
||||
const dummyEvent = { some: "event" } as unknown as Sentry.ErrorEvent;
|
||||
|
||||
const hintWithString = { originalException: "string exception" };
|
||||
expect(() => beforeSend(dummyEvent, hintWithString)).not.toThrow();
|
||||
expect(beforeSend(dummyEvent, hintWithString)).toEqual(dummyEvent);
|
||||
|
||||
const hintWithNumber = { originalException: 123 };
|
||||
expect(() => beforeSend(dummyEvent, hintWithNumber)).not.toThrow();
|
||||
expect(beforeSend(dummyEvent, hintWithNumber)).toEqual(dummyEvent);
|
||||
|
||||
const hintWithNull = { originalException: null };
|
||||
expect(() => beforeSend(dummyEvent, hintWithNull)).not.toThrow();
|
||||
expect(beforeSend(dummyEvent, hintWithNull)).toEqual(dummyEvent);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,8 +15,8 @@ export const SentryProvider = ({ children, sentryDsn, isEnabled }: SentryProvide
|
||||
Sentry.init({
|
||||
dsn: sentryDsn,
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1,
|
||||
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
|
||||
tracesSampleRate: 0,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
|
||||
@@ -15,12 +15,20 @@ import {
|
||||
describe("Time Utilities", () => {
|
||||
describe("convertDateString", () => {
|
||||
test("should format date string correctly", () => {
|
||||
expect(convertDateString("2024-03-20")).toBe("Mar 20, 2024");
|
||||
expect(convertDateString("2024-03-20:12:30:00")).toBe("Mar 20, 2024");
|
||||
});
|
||||
|
||||
test("should return empty string for empty input", () => {
|
||||
expect(convertDateString("")).toBe("");
|
||||
});
|
||||
|
||||
test("should return null for null input", () => {
|
||||
expect(convertDateString(null as any)).toBe(null);
|
||||
});
|
||||
|
||||
test("should handle invalid date strings", () => {
|
||||
expect(convertDateString("not-a-date")).toBe("Invalid Date");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertDateTimeString", () => {
|
||||
@@ -73,7 +81,7 @@ describe("Time Utilities", () => {
|
||||
|
||||
describe("formatDate", () => {
|
||||
test("should format date correctly", () => {
|
||||
const date = new Date("2024-03-20");
|
||||
const date = new Date(2024, 2, 20); // March is month 2 (0-based)
|
||||
expect(formatDate(date)).toBe("March 20, 2024");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,16 @@ import { formatDistance, intlFormat } from "date-fns";
|
||||
import { de, enUS, fr, pt, ptBR, zhTW } from "date-fns/locale";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
export const convertDateString = (dateString: string) => {
|
||||
export const convertDateString = (dateString: string | null) => {
|
||||
if (dateString === null) return null;
|
||||
if (!dateString) {
|
||||
return dateString;
|
||||
}
|
||||
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) {
|
||||
return "Invalid Date";
|
||||
}
|
||||
return intlFormat(
|
||||
date,
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { DEFAULT_SERVER_ERROR_MESSAGE, createSafeActionClient } from "next-safe-action";
|
||||
import { logger } from "@formbricks/logger";
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
|
||||
export const actionClient = createSafeActionClient({
|
||||
handleServerError(e) {
|
||||
Sentry.captureException(e);
|
||||
|
||||
if (
|
||||
e instanceof ResourceNotFoundError ||
|
||||
e instanceof AuthorizationError ||
|
||||
|
||||
@@ -240,4 +240,126 @@ describe("updateUser", () => {
|
||||
expect(result.state.data).toEqual(expect.objectContaining(mockUserState));
|
||||
expect(result.messages).toEqual([]);
|
||||
});
|
||||
|
||||
test("should handle email attribute update with ignoreEmailAttribute flag", async () => {
|
||||
vi.mocked(getContactByUserIdWithAttributes).mockResolvedValue(mockContact);
|
||||
const newAttributes = { email: "new@example.com", name: "John Doe" };
|
||||
vi.mocked(updateAttributes).mockResolvedValue({
|
||||
success: true,
|
||||
messages: [],
|
||||
ignoreEmailAttribute: true,
|
||||
});
|
||||
|
||||
vi.mocked(getUserState).mockResolvedValue({
|
||||
...mockUserState,
|
||||
});
|
||||
|
||||
const result = await updateUser(mockEnvironmentId, mockUserId, "desktop", newAttributes);
|
||||
|
||||
expect(updateAttributes).toHaveBeenCalledWith(
|
||||
mockContactId,
|
||||
mockUserId,
|
||||
mockEnvironmentId,
|
||||
newAttributes
|
||||
);
|
||||
// Email should not be included in the final attributes
|
||||
expect(result.state.data).toEqual(
|
||||
expect.objectContaining({
|
||||
...mockUserState,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle failed attribute update gracefully", async () => {
|
||||
vi.mocked(getContactByUserIdWithAttributes).mockResolvedValue(mockContact);
|
||||
const newAttributes = { company: "Formbricks" };
|
||||
vi.mocked(updateAttributes).mockResolvedValue({
|
||||
success: false,
|
||||
messages: ["Update failed"],
|
||||
});
|
||||
|
||||
const result = await updateUser(mockEnvironmentId, mockUserId, "desktop", newAttributes);
|
||||
|
||||
expect(updateAttributes).toHaveBeenCalledWith(
|
||||
mockContactId,
|
||||
mockUserId,
|
||||
mockEnvironmentId,
|
||||
newAttributes
|
||||
);
|
||||
// Should still return state even if update failed
|
||||
expect(result.state.data).toEqual(expect.objectContaining(mockUserState));
|
||||
expect(result.messages).toEqual(["Update failed"]);
|
||||
});
|
||||
|
||||
test("should handle multiple attribute updates correctly", async () => {
|
||||
vi.mocked(getContactByUserIdWithAttributes).mockResolvedValue(mockContact);
|
||||
const newAttributes = {
|
||||
company: "Formbricks",
|
||||
role: "Developer",
|
||||
language: "en",
|
||||
country: "US",
|
||||
};
|
||||
vi.mocked(updateAttributes).mockResolvedValue({
|
||||
success: true,
|
||||
messages: ["Attributes updated successfully"],
|
||||
});
|
||||
|
||||
const result = await updateUser(mockEnvironmentId, mockUserId, "desktop", newAttributes);
|
||||
|
||||
expect(updateAttributes).toHaveBeenCalledWith(
|
||||
mockContactId,
|
||||
mockUserId,
|
||||
mockEnvironmentId,
|
||||
newAttributes
|
||||
);
|
||||
expect(result.state.data?.language).toBe("en");
|
||||
expect(result.messages).toEqual(["Attributes updated successfully"]);
|
||||
});
|
||||
|
||||
test("should handle contact creation with multiple initial attributes", async () => {
|
||||
vi.mocked(getContactByUserIdWithAttributes).mockResolvedValue(null);
|
||||
const initialAttributes = {
|
||||
userId: mockUserId,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
};
|
||||
vi.mocked(prisma.contact.create).mockResolvedValue({
|
||||
id: mockContactId,
|
||||
attributes: [
|
||||
{ attributeKey: { key: "userId" }, value: mockUserId },
|
||||
{ attributeKey: { key: "email" }, value: "test@example.com" },
|
||||
{ attributeKey: { key: "name" }, value: "Test User" },
|
||||
],
|
||||
} as any);
|
||||
|
||||
const result = await updateUser(mockEnvironmentId, mockUserId, "desktop", initialAttributes);
|
||||
|
||||
expect(prisma.contact.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
environment: { connect: { id: mockEnvironmentId } },
|
||||
attributes: {
|
||||
create: [
|
||||
{
|
||||
attributeKey: {
|
||||
connect: { key_environmentId: { key: "userId", environmentId: mockEnvironmentId } },
|
||||
},
|
||||
value: mockUserId,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: { attributeKey: { select: { key: true } }, value: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(contactCache.revalidate).toHaveBeenCalledWith({
|
||||
environmentId: mockEnvironmentId,
|
||||
userId: mockUserId,
|
||||
id: mockContactId,
|
||||
});
|
||||
expect(result.state.data).toEqual(expect.objectContaining(mockUserState));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,20 +85,26 @@ export const updateUser = async (
|
||||
}
|
||||
|
||||
if (shouldUpdate) {
|
||||
const { success, messages: updateAttrMessages } = await updateAttributes(
|
||||
contact.id,
|
||||
userId,
|
||||
environmentId,
|
||||
attributes
|
||||
);
|
||||
const {
|
||||
success,
|
||||
messages: updateAttrMessages,
|
||||
ignoreEmailAttribute,
|
||||
} = await updateAttributes(contact.id, userId, environmentId, attributes);
|
||||
|
||||
messages = updateAttrMessages ?? [];
|
||||
|
||||
// If the attributes update was successful and the language attribute was provided, set the language
|
||||
if (success) {
|
||||
let attributesToUpdate = { ...attributes };
|
||||
|
||||
if (ignoreEmailAttribute) {
|
||||
const { email, ...rest } = attributes;
|
||||
attributesToUpdate = rest;
|
||||
}
|
||||
|
||||
contactAttributes = {
|
||||
...contactAttributes,
|
||||
...attributes,
|
||||
...attributesToUpdate,
|
||||
};
|
||||
|
||||
if (attributes.language) {
|
||||
|
||||
@@ -13,7 +13,7 @@ export const updateAttributes = async (
|
||||
userId: string,
|
||||
environmentId: string,
|
||||
contactAttributesParam: TContactAttributes
|
||||
): Promise<{ success: boolean; messages?: string[] }> => {
|
||||
): Promise<{ success: boolean; messages?: string[]; ignoreEmailAttribute?: boolean }> => {
|
||||
validateInputs(
|
||||
[contactId, ZId],
|
||||
[userId, ZString],
|
||||
@@ -21,6 +21,8 @@ export const updateAttributes = async (
|
||||
[contactAttributesParam, ZContactAttributes]
|
||||
);
|
||||
|
||||
let ignoreEmailAttribute = false;
|
||||
|
||||
// Fetch contact attribute keys and email check in parallel
|
||||
const [contactAttributeKeys, existingEmailAttribute] = await Promise.all([
|
||||
getContactAttributeKeys(environmentId),
|
||||
@@ -58,6 +60,10 @@ export const updateAttributes = async (
|
||||
? ["The email already exists for this environment and was not updated."]
|
||||
: [];
|
||||
|
||||
if (emailExists) {
|
||||
ignoreEmailAttribute = true;
|
||||
}
|
||||
|
||||
// First, update all existing attributes
|
||||
if (existingAttributes.length > 0) {
|
||||
await prisma.$transaction(
|
||||
@@ -124,5 +130,6 @@ export const updateAttributes = async (
|
||||
return {
|
||||
success: true,
|
||||
messages,
|
||||
ignoreEmailAttribute,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -87,21 +87,6 @@ export const CTAQuestionForm = ({
|
||||
|
||||
<div className="mt-2 flex justify-between gap-8">
|
||||
<div className="flex w-full space-x-2">
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
value={question.buttonLabel}
|
||||
label={t("environments.surveys.edit.next_button_label")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={lastQuestion ? t("common.finish") : t("common.next")}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
{questionIdx !== 0 && (
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
@@ -118,6 +103,20 @@ export const CTAQuestionForm = ({
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
value={question.buttonLabel}
|
||||
label={t("environments.surveys.edit.next_button_label")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={lastQuestion ? t("common.finish") : t("common.next")}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { QuestionCard } from "@/modules/survey/editor/components/question-card";
|
||||
import { Project } from "@prisma/client";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
// Import waitFor
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyAddressQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
// Import waitFor
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("@/modules/survey/components/question-form-input", () => ({
|
||||
@@ -371,8 +366,9 @@ describe("QuestionCard Component", () => {
|
||||
|
||||
test("applies invalid styling when isInvalid is true", () => {
|
||||
render(<QuestionCard {...defaultProps} isInvalid={true} />);
|
||||
const dragHandle = screen.getByRole("button", { name: "" }).parentElement; // Get the div containing the GripIcon
|
||||
const dragHandle = screen.getByRole("button", { name: "Drag to reorder question" }).parentElement; // Get the div containing the GripIcon
|
||||
expect(dragHandle).toHaveClass("bg-red-400");
|
||||
expect(dragHandle).toHaveClass("hover:bg-red-600");
|
||||
});
|
||||
|
||||
test("disables required toggle for Address question if all fields are optional", () => {
|
||||
@@ -507,4 +503,94 @@ describe("QuestionCard Component", () => {
|
||||
// First question should never have back button
|
||||
expect(screen.queryByTestId("question-form-input-backButtonLabel")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Accessibility Tests
|
||||
test("maintains proper focus management when toggling advanced settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
|
||||
|
||||
const advancedSettingsTrigger = screen.getByText("environments.surveys.edit.show_advanced_settings");
|
||||
await user.click(advancedSettingsTrigger);
|
||||
|
||||
const closeTrigger = screen.getByText("environments.surveys.edit.hide_advanced_settings");
|
||||
expect(closeTrigger).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("ensures proper ARIA attributes for collapsible sections", () => {
|
||||
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
|
||||
|
||||
const collapsibleTrigger = screen.getByText("environments.surveys.edit.show_advanced_settings");
|
||||
expect(collapsibleTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
fireEvent.click(collapsibleTrigger);
|
||||
expect(collapsibleTrigger).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
test("maintains keyboard accessibility for required toggle", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
|
||||
|
||||
const requiredToggle = screen.getByRole("switch", { name: "environments.surveys.edit.required" });
|
||||
await user.click(requiredToggle);
|
||||
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { required: false });
|
||||
});
|
||||
|
||||
test("provides screen reader text for drag handle", () => {
|
||||
render(<QuestionCard {...defaultProps} />);
|
||||
const dragHandle = screen.getByRole("button", { name: "Drag to reorder question" });
|
||||
const svg = dragHandle.querySelector("svg");
|
||||
expect(svg).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
test("maintains proper heading hierarchy", () => {
|
||||
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
|
||||
const headline = screen.getByText("Question Headline");
|
||||
expect(headline.tagName).toBe("H3");
|
||||
expect(headline).toHaveClass("text-sm", "font-semibold");
|
||||
});
|
||||
|
||||
test("ensures proper focus order for form elements", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
|
||||
|
||||
// Open advanced settings
|
||||
fireEvent.click(screen.getByText("environments.surveys.edit.show_advanced_settings"));
|
||||
|
||||
const requiredToggle = screen.getByRole("switch", { name: "environments.surveys.edit.required" });
|
||||
await user.click(requiredToggle);
|
||||
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { required: false });
|
||||
});
|
||||
|
||||
test("provides proper ARIA attributes for interactive elements", () => {
|
||||
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
|
||||
|
||||
const requiredToggle = screen.getByRole("switch", { name: "environments.surveys.edit.required" });
|
||||
expect(requiredToggle).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
const longAnswerToggle = screen.getByRole("switch", { name: "environments.surveys.edit.long_answer" });
|
||||
expect(longAnswerToggle).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
test("ensures proper role attributes for interactive elements", () => {
|
||||
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
|
||||
|
||||
const toggles = screen.getAllByRole("switch");
|
||||
expect(toggles).toHaveLength(2); // Required and Long Answer toggles
|
||||
|
||||
const collapsibleTrigger = screen.getByText("environments.surveys.edit.show_advanced_settings");
|
||||
expect(collapsibleTrigger).toHaveAttribute("type", "button");
|
||||
});
|
||||
|
||||
test("maintains proper focus management when closing advanced settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
|
||||
|
||||
const advancedSettingsTrigger = screen.getByText("environments.surveys.edit.show_advanced_settings");
|
||||
await user.click(advancedSettingsTrigger);
|
||||
|
||||
const closeTrigger = screen.getByText("environments.surveys.edit.hide_advanced_settings");
|
||||
await user.click(closeTrigger);
|
||||
|
||||
expect(screen.getByText("environments.surveys.edit.show_advanced_settings")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,7 +196,9 @@ export const QuestionCard = ({
|
||||
)}>
|
||||
<div className="mt-3 flex w-full justify-center">{QUESTIONS_ICON_MAP[question.type]}</div>
|
||||
|
||||
<button className="opacity-0 hover:cursor-move group-hover:opacity-100">
|
||||
<button
|
||||
className="opacity-0 hover:cursor-move group-hover:opacity-100"
|
||||
aria-label="Drag to reorder question">
|
||||
<GripIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -215,14 +217,15 @@ export const QuestionCard = ({
|
||||
className={cn(
|
||||
open ? "" : " ",
|
||||
"flex cursor-pointer justify-between gap-4 rounded-r-lg p-4 hover:bg-slate-50"
|
||||
)}>
|
||||
)}
|
||||
aria-label="Toggle question details">
|
||||
<div>
|
||||
<div className="flex grow">
|
||||
{/* <div className="-ml-0.5 mr-3 h-6 min-w-[1.5rem] text-slate-400">
|
||||
{QUESTIONS_ICON_MAP[question.type]}
|
||||
</div> */}
|
||||
<div className="flex grow flex-col justify-center" dir="auto">
|
||||
<p className="text-sm font-semibold">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
|
||||
selectedLanguageCode
|
||||
]
|
||||
@@ -232,7 +235,7 @@ export const QuestionCard = ({
|
||||
] ?? ""
|
||||
)
|
||||
: getTSurveyQuestionTypeEnumName(question.type, t)}
|
||||
</p>
|
||||
</h3>
|
||||
{!open && (
|
||||
<p className="mt-1 truncate text-xs text-slate-500">
|
||||
{question?.required
|
||||
@@ -272,7 +275,7 @@ export const QuestionCard = ({
|
||||
TSurveyQuestionTypeEnum.Ranking,
|
||||
TSurveyQuestionTypeEnum.Matrix,
|
||||
].includes(question.type) ? (
|
||||
<Alert variant="warning" size="small" className="w-fill">
|
||||
<Alert variant="warning" size="small" className="w-fill" role="alert">
|
||||
<AlertTitle>{t("environments.surveys.edit.caution_text")}</AlertTitle>
|
||||
<AlertButton onClick={() => onAlertTrigger()}>{t("common.learn_more")}</AlertButton>
|
||||
</Alert>
|
||||
@@ -457,7 +460,9 @@ export const QuestionCard = ({
|
||||
) : null}
|
||||
<div className="mt-4">
|
||||
<Collapsible.Root open={openAdvanced} onOpenChange={setOpenAdvanced} className="mt-5">
|
||||
<Collapsible.CollapsibleTrigger className="flex items-center text-sm text-slate-700">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
className="flex items-center text-sm text-slate-700"
|
||||
aria-label="Toggle advanced settings">
|
||||
{openAdvanced ? (
|
||||
<ChevronDownIcon className="mr-1 h-4 w-3" />
|
||||
) : (
|
||||
@@ -473,6 +478,30 @@ export const QuestionCard = ({
|
||||
question.type !== TSurveyQuestionTypeEnum.Rating &&
|
||||
question.type !== TSurveyQuestionTypeEnum.CTA ? (
|
||||
<div className="mt-2 flex space-x-2">
|
||||
{questionIdx !== 0 && (
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
value={question.backButtonLabel}
|
||||
label={t("environments.surveys.edit.back_button_label")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={t("common.back")}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
onBlur={(e) => {
|
||||
if (!question.backButtonLabel) return;
|
||||
let translatedBackButtonLabel = {
|
||||
...question.backButtonLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
};
|
||||
updateEmptyButtonLabels("backButtonLabel", translatedBackButtonLabel, 0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
@@ -503,30 +532,6 @@ export const QuestionCard = ({
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
{questionIdx !== 0 && (
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
value={question.backButtonLabel}
|
||||
label={t("environments.surveys.edit.back_button_label")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={t("common.back")}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
onBlur={(e) => {
|
||||
if (!question.backButtonLabel) return;
|
||||
let translatedBackButtonLabel = {
|
||||
...question.backButtonLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
};
|
||||
updateEmptyButtonLabels("backButtonLabel", translatedBackButtonLabel, 0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{(question.type === TSurveyQuestionTypeEnum.Rating ||
|
||||
|
||||
@@ -53,10 +53,10 @@ describe("SurveyCard", () => {
|
||||
survey={{ ...dummySurvey, status: "draft" } as unknown as TSurvey}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={false}
|
||||
WEBAPP_URL={WEBAPP_URL}
|
||||
duplicateSurvey={mockDuplicateSurvey}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
locale="en-US"
|
||||
surveyDomain={WEBAPP_URL}
|
||||
/>
|
||||
);
|
||||
// Draft survey => link should point to edit
|
||||
@@ -70,10 +70,10 @@ describe("SurveyCard", () => {
|
||||
survey={{ ...dummySurvey, status: "draft" } as unknown as TSurvey}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={true}
|
||||
WEBAPP_URL={WEBAPP_URL}
|
||||
duplicateSurvey={mockDuplicateSurvey}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
locale="en-US"
|
||||
surveyDomain={WEBAPP_URL}
|
||||
/>
|
||||
);
|
||||
// When it's read only and draft, we expect no link
|
||||
@@ -87,10 +87,10 @@ describe("SurveyCard", () => {
|
||||
survey={{ ...dummySurvey, status: "inProgress" } as unknown as TSurvey}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={false}
|
||||
WEBAPP_URL={WEBAPP_URL}
|
||||
duplicateSurvey={mockDuplicateSurvey}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
locale="en-US"
|
||||
surveyDomain={WEBAPP_URL}
|
||||
/>
|
||||
);
|
||||
// For non-draft => link to summary
|
||||
|
||||
@@ -4,8 +4,8 @@ export const NetPromoterScoreIcon: React.FC<React.SVGProps<SVGSVGElement>> = (pr
|
||||
<g id="Frame">
|
||||
<path
|
||||
id="Vector"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2.25 2.25C2.05109 2.25 1.86032 2.32902 1.71967 2.46967C1.57902 2.61032 1.5 2.80109 1.5 3C1.5 3.19891 1.57902 3.38968 1.71967 3.53033C1.86032 3.67098 2.05109 3.75 2.25 3.75H3V14.25C3 15.0456 3.31607 15.8087 3.87868 16.3713C4.44129 16.9339 5.20435 17.25 6 17.25H7.21L6.038 20.763C5.97514 20.9518 5.98988 21.1579 6.07896 21.3359C6.16804 21.5138 6.32417 21.6491 6.513 21.712C6.70183 21.7749 6.9079 21.7601 7.08588 21.671C7.26385 21.582 7.39914 21.4258 7.462 21.237L7.791 20.25H16.209L16.539 21.237C16.6073 21.4186 16.7433 21.5666 16.9184 21.6501C17.0935 21.7335 17.2941 21.7459 17.4782 21.6845C17.6622 21.6232 17.8153 21.4929 17.9053 21.3211C17.9954 21.1493 18.0153 20.9492 17.961 20.763L16.791 17.25H18C18.7956 17.25 19.5587 16.9339 20.1213 16.3713C20.6839 15.8087 21 15.0456 21 14.25V3.75H21.75C21.9489 3.75 22.1397 3.67098 22.2803 3.53033C22.421 3.38968 22.5 3.19891 22.5 3C22.5 2.80109 22.421 2.61032 22.2803 2.46967C22.1397 2.32902 21.9489 2.25 21.75 2.25H2.25ZM8.29 18.75L8.79 17.25H15.21L15.71 18.75H8.29ZM15.75 6.75C15.75 6.55109 15.671 6.36032 15.5303 6.21967C15.3897 6.07902 15.1989 6 15 6C14.8011 6 14.6103 6.07902 14.4697 6.21967C14.329 6.36032 14.25 6.55109 14.25 6.75V12.75C14.25 12.9489 14.329 13.1397 14.4697 13.2803C14.6103 13.421 14.8011 13.5 15 13.5C15.1989 13.5 15.3897 13.421 15.5303 13.2803C15.671 13.1397 15.75 12.9489 15.75 12.75V6.75ZM12.75 9C12.75 8.80109 12.671 8.61032 12.5303 8.46967C12.3897 8.32902 12.1989 8.25 12 8.25C11.8011 8.25 11.6103 8.32902 11.4697 8.46967C11.329 8.61032 11.25 8.80109 11.25 9V12.75C11.25 12.9489 11.329 13.1397 11.4697 13.2803C11.6103 13.421 11.8011 13.5 12 13.5C12.1989 13.5 12.3897 13.421 12.5303 13.2803C12.671 13.1397 12.75 12.9489 12.75 12.75V9ZM9.75 11.25C9.75 11.0511 9.67098 10.8603 9.53033 10.7197C9.38968 10.579 9.19891 10.5 9 10.5C8.80109 10.5 8.61032 10.579 8.46967 10.7197C8.32902 10.8603 8.25 11.0511 8.25 11.25V12.75C8.25 12.9489 8.32902 13.1397 8.46967 13.2803C8.61032 13.421 8.80109 13.5 9 13.5C9.19891 13.5 9.38968 13.421 9.53033 13.2803C9.67098 13.1397 9.75 12.9489 9.75 12.75V11.25Z"
|
||||
fill="white"
|
||||
/>
|
||||
|
||||
@@ -19,6 +19,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, isInv
|
||||
isInvalid && "border border-red-500 focus:border-red-500"
|
||||
)}
|
||||
ref={ref}
|
||||
dir="auto"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -133,6 +133,7 @@ export const Modal = ({
|
||||
<div
|
||||
id="preview-survey-base"
|
||||
aria-live="assertive"
|
||||
dir="auto"
|
||||
className={cn(
|
||||
"relative h-full w-full overflow-hidden rounded-b-md",
|
||||
overlayVisible ? (darkOverlay ? "bg-slate-700/80" : "bg-white/50") : "",
|
||||
@@ -149,6 +150,7 @@ export const Modal = ({
|
||||
background,
|
||||
}),
|
||||
}}
|
||||
dir="auto"
|
||||
className={cn(
|
||||
"no-scrollbar pointer-events-auto absolute max-h-[90%] w-full max-w-sm transition-all duration-500 ease-in-out",
|
||||
previewMode === "desktop" ? getPlacementStyle(placement) : "max-w-full",
|
||||
|
||||
@@ -283,7 +283,7 @@ export const PreviewSurvey = ({
|
||||
/>
|
||||
</Modal>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col justify-center px-1">
|
||||
<div className="flex h-full w-full flex-col justify-center px-1" dir="auto">
|
||||
<div className="absolute left-5 top-5">
|
||||
{!styling.isLogoHidden && (
|
||||
<ClientLogo environmentId={environment.id} projectLogo={project.logo} previewSurvey />
|
||||
|
||||
@@ -72,5 +72,5 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
|
||||
}
|
||||
}, [isScriptLoaded, renderInline]);
|
||||
|
||||
return <div id={containerId} className="h-full w-full" />;
|
||||
return <div id={containerId} className="h-full w-full" dir="auto" />;
|
||||
};
|
||||
|
||||
@@ -127,9 +127,9 @@ input[type="search"]::-ms-reveal {
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
background: #0f172a;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
-webkit-appearance: none;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
@@ -117,11 +117,9 @@
|
||||
"prismjs": "1.30.0",
|
||||
"qr-code-styling": "1.9.2",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-colorful": "5.6.1",
|
||||
"react-confetti": "6.4.0",
|
||||
"react-day-picker": "9.6.7",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "7.56.2",
|
||||
"react-hot-toast": "2.5.2",
|
||||
"react-turnstile": "1.1.4",
|
||||
|
||||
@@ -181,7 +181,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
await page.locator('input[name="subheader"]').fill(params.openTextQuestion.description);
|
||||
await page.getByLabel("Placeholder").fill(params.openTextQuestion.placeholder);
|
||||
|
||||
await page.locator("p").filter({ hasText: params.openTextQuestion.question }).click();
|
||||
await page.locator("h3").filter({ hasText: params.openTextQuestion.question }).click();
|
||||
|
||||
// Single Select Question
|
||||
await page
|
||||
@@ -403,7 +403,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.locator('input[name="subheader"]').fill(params.openTextQuestion.description);
|
||||
await page.getByLabel("Placeholder").fill(params.openTextQuestion.placeholder);
|
||||
|
||||
await page.locator("p").filter({ hasText: params.openTextQuestion.question }).click();
|
||||
await page.locator("h3").filter({ hasText: params.openTextQuestion.question }).click();
|
||||
|
||||
// Single Select Question
|
||||
await page
|
||||
@@ -606,8 +606,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
|
||||
// Adding logic
|
||||
// Open Text Question
|
||||
await page.locator("p", { hasText: params.openTextQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("heading", { name: params.openTextQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Toggle advanced settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "is submitted" }).click();
|
||||
@@ -637,8 +637,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.getByRole("textbox", { name: "Value" }).fill("This ");
|
||||
|
||||
// Single Select Question
|
||||
await page.locator("p", { hasText: params.singleSelectQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("heading", { name: params.singleSelectQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Toggle advanced settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "Equals one of" }).click();
|
||||
@@ -665,8 +665,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.getByRole("textbox", { name: "Value" }).fill("is ");
|
||||
|
||||
// Multi Select Question
|
||||
await page.locator("p", { hasText: params.multiSelectQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("heading", { name: params.multiSelectQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Toggle advanced settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "Includes all of" }).click();
|
||||
@@ -706,8 +706,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.getByRole("textbox", { name: "Value" }).fill("a ");
|
||||
|
||||
// Picture Select Question
|
||||
await page.locator("p", { hasText: params.pictureSelectQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("heading", { name: params.pictureSelectQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Toggle advanced settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "is submitted" }).click();
|
||||
@@ -731,8 +731,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.getByRole("textbox", { name: "Value" }).fill("secret ");
|
||||
|
||||
// Rating Question
|
||||
await page.locator("p", { hasText: params.ratingQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("heading", { name: params.ratingQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Toggle advanced settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: ">=" }).click();
|
||||
@@ -758,8 +758,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.getByRole("textbox", { name: "Value" }).fill("message ");
|
||||
|
||||
// NPS Question
|
||||
await page.locator("p", { hasText: params.npsQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("heading", { name: params.npsQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Toggle advanced settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: ">", exact: true }).click();
|
||||
@@ -819,8 +819,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.getByRole("textbox", { name: "Value" }).fill("for ");
|
||||
|
||||
// Ranking Question
|
||||
await page.locator("p", { hasText: params.ranking.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("heading", { name: params.ranking.question }).click();
|
||||
await page.getByRole("button", { name: "Toggle advanced settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "is skipped" }).click();
|
||||
@@ -844,8 +844,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.getByRole("textbox", { name: "Value" }).fill("e2e ");
|
||||
|
||||
// Matrix Question
|
||||
await page.locator("p", { hasText: params.matrix.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("heading", { name: params.matrix.question }).click();
|
||||
await page.getByRole("button", { name: "Toggle advanced settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "is completely submitted" }).click();
|
||||
@@ -877,8 +877,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.getByRole("option", { name: params.ctaQuestion.question }).click();
|
||||
|
||||
// CTA Question
|
||||
await page.locator("p", { hasText: params.ctaQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("heading", { name: params.ctaQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Toggle advanced settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "is skipped" }).click();
|
||||
@@ -905,8 +905,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.locator("#action-0-value-input").fill("1");
|
||||
|
||||
// Consent Question
|
||||
await page.locator("p", { hasText: params.consentQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("heading", { name: params.consentQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Toggle advanced settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#action-0-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
@@ -918,8 +918,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.locator("#action-0-value-input").fill("2");
|
||||
|
||||
// File Upload Question
|
||||
await page.locator("p", { hasText: params.fileUploadQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("heading", { name: params.fileUploadQuestion.question }).click();
|
||||
await page.getByRole("button", { name: "Toggle advanced settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#action-0-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
@@ -936,7 +936,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
const tomorrow = new Date(new Date().setDate(new Date().getDate() + 1)).toISOString().split("T")[0];
|
||||
|
||||
await page.getByRole("main").getByText(params.date.question).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("button", { name: "Toggle advanced settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
|
||||
await page.getByPlaceholder("Value").fill(today);
|
||||
@@ -965,8 +965,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.locator("#action-0-value-input").fill("1");
|
||||
|
||||
// Cal Question
|
||||
await page.locator("p", { hasText: params.cal.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("heading", { name: params.cal.question }).click();
|
||||
await page.getByRole("button", { name: "Toggle advanced settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#condition-0-0-conditionOperator").click();
|
||||
await page.getByRole("option", { name: "is skipped" }).click();
|
||||
@@ -980,8 +980,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.locator("#action-0-value-input").fill("1");
|
||||
|
||||
// Address Question
|
||||
await page.locator("p", { hasText: params.address.question }).click();
|
||||
await page.getByRole("button", { name: "Show Advanced Settings" }).click();
|
||||
await page.getByRole("heading", { name: params.address.question }).click();
|
||||
await page.getByRole("button", { name: "Toggle advanced settings" }).click();
|
||||
await page.getByRole("button", { name: "Add logic" }).click();
|
||||
await page.locator("#action-0-objective").click();
|
||||
await page.getByRole("option", { name: "Calculate" }).click();
|
||||
|
||||
@@ -12,8 +12,8 @@ if (SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1,
|
||||
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
|
||||
tracesSampleRate: 0,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
|
||||
@@ -11,8 +11,8 @@ if (SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1,
|
||||
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
|
||||
tracesSampleRate: 0,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"schema": "packages/database/schema.prisma"
|
||||
},
|
||||
"scripts": {
|
||||
"clean:all": "turbo run clean && rimraf node_modules pnpm-lock.yaml .turbo coverage out",
|
||||
"clean": "turbo run clean && rimraf node_modules .turbo coverage out",
|
||||
"build": "turbo run build",
|
||||
"build:dev": "turbo run build:dev",
|
||||
@@ -35,6 +36,10 @@
|
||||
"fb-migrate-dev": "pnpm --filter @formbricks/database create-migration && pnpm prisma generate",
|
||||
"tolgee-pull": "BRANCH_NAME=$(node -p \"require('./branch.json').branchName\") && tolgee pull --tags \"draft:$BRANCH_NAME\" \"production\" && prettier --write ./apps/web/locales/*.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@azure/microsoft-playwright-testing": "1.0.0-beta.7",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
|
||||
@@ -53,7 +53,7 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
|
||||
setIsLoading(false);
|
||||
}}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/preact";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/preact";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { type TSurveyOpenTextQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
@@ -76,10 +76,7 @@ describe("OpenTextQuestion", () => {
|
||||
render(<OpenTextQuestion {...defaultProps} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type here...");
|
||||
|
||||
// Directly set the input value and trigger the input event
|
||||
Object.defineProperty(input, "value", { value: "Hello" });
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
fireEvent.input(input, { target: { value: "Hello" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ q1: "Hello" });
|
||||
});
|
||||
@@ -163,4 +160,291 @@ describe("OpenTextQuestion", () => {
|
||||
|
||||
expect(focusMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles input change for textarea with resize functionality", async () => {
|
||||
// Create a spy on the Element.prototype to monitor style changes
|
||||
const styleSpy = vi.spyOn(HTMLElement.prototype, "style", "get").mockImplementation(
|
||||
() =>
|
||||
({
|
||||
height: "",
|
||||
overflow: "",
|
||||
}) as CSSStyleDeclaration
|
||||
);
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
onChange={onChange}
|
||||
question={{ ...defaultQuestion, longAnswer: true }}
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
// Only trigger a regular input event without trying to modify scrollHeight
|
||||
fireEvent.input(textarea, { target: { value: "Test value for textarea" } });
|
||||
|
||||
// Check that onChange was called with the correct value
|
||||
expect(onChange).toHaveBeenCalledWith({ q1: "Test value for textarea" });
|
||||
|
||||
// Clean up the spy
|
||||
styleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("handles textarea resize with different heights", async () => {
|
||||
// Mock styles and scrollHeight for handleInputResize testing
|
||||
let heightValue = "";
|
||||
let overflowValue = "";
|
||||
|
||||
// Mock style setter to capture values
|
||||
const originalSetProperty = CSSStyleDeclaration.prototype.setProperty;
|
||||
CSSStyleDeclaration.prototype.setProperty = vi.fn();
|
||||
|
||||
// Mock to capture style changes
|
||||
Object.defineProperty(HTMLElement.prototype, "style", {
|
||||
get: vi.fn(() => ({
|
||||
height: heightValue,
|
||||
overflow: overflowValue,
|
||||
setProperty: (prop: string, value: string) => {
|
||||
if (prop === "height") heightValue = value;
|
||||
if (prop === "overflow") overflowValue = value;
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
onChange={onChange}
|
||||
question={{ ...defaultQuestion, longAnswer: true }}
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
|
||||
// Simulate normal height (less than max)
|
||||
const mockNormalEvent = {
|
||||
target: {
|
||||
style: { height: "", overflow: "" },
|
||||
scrollHeight: 100, // Less than max 160px
|
||||
},
|
||||
};
|
||||
|
||||
// Get the event handler
|
||||
const inputHandler = textarea.oninput as EventListener;
|
||||
if (inputHandler) {
|
||||
inputHandler(mockNormalEvent as unknown as Event);
|
||||
}
|
||||
|
||||
// Now simulate text that exceeds max height
|
||||
const mockOverflowEvent = {
|
||||
target: {
|
||||
style: { height: "", overflow: "" },
|
||||
scrollHeight: 200, // More than max 160px
|
||||
},
|
||||
};
|
||||
|
||||
if (inputHandler) {
|
||||
inputHandler(mockOverflowEvent as unknown as Event);
|
||||
}
|
||||
|
||||
// Restore the original method
|
||||
CSSStyleDeclaration.prototype.setProperty = originalSetProperty;
|
||||
});
|
||||
|
||||
test("handles form submission by enter key", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const setTtc = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
<OpenTextQuestion {...defaultProps} value="Test submission" onSubmit={onSubmit} setTtc={setTtc} />
|
||||
);
|
||||
|
||||
// Get the form element using container query
|
||||
const form = container.querySelector("form");
|
||||
expect(form).toBeInTheDocument();
|
||||
|
||||
// Simulate form submission
|
||||
fireEvent.submit(form!);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({ q1: "Test submission" }, {});
|
||||
expect(setTtc).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("applies minLength constraint when configured", () => {
|
||||
render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
question={{ ...defaultQuestion, charLimit: { min: 5, max: 100 } }}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type here...");
|
||||
expect(input).toHaveAttribute("minLength", "5");
|
||||
expect(input).toHaveAttribute("maxLength", "100");
|
||||
});
|
||||
|
||||
test("handles video URL in media", () => {
|
||||
render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
question={{ ...defaultQuestion, videoUrl: "https://example.com/video.mp4" }}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("question-media")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("doesn't autofocus when not current question", () => {
|
||||
const focusMock = vi.fn();
|
||||
window.HTMLElement.prototype.focus = focusMock;
|
||||
|
||||
render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
autoFocusEnabled={true}
|
||||
currentQuestionId="q2" // Different from question id (q1)
|
||||
/>
|
||||
);
|
||||
|
||||
expect(focusMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles input change for textarea", async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
onChange={onChange}
|
||||
question={{ ...defaultQuestion, longAnswer: true }}
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
fireEvent.input(textarea, { target: { value: "Long text response" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ q1: "Long text response" });
|
||||
});
|
||||
|
||||
test("applies phone number maxLength constraint", () => {
|
||||
render(<OpenTextQuestion {...defaultProps} question={{ ...defaultQuestion, inputType: "phone" }} />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type here...");
|
||||
expect(input).toHaveAttribute("maxLength", "30");
|
||||
});
|
||||
|
||||
test("renders without subheader when not provided", () => {
|
||||
const questionWithoutSubheader = {
|
||||
...defaultQuestion,
|
||||
subheader: undefined,
|
||||
};
|
||||
|
||||
render(<OpenTextQuestion {...defaultProps} question={questionWithoutSubheader} />);
|
||||
expect(screen.getByTestId("mock-subheader")).toHaveTextContent("");
|
||||
});
|
||||
|
||||
test("sets correct tabIndex based on current question status", () => {
|
||||
// When it's the current question
|
||||
render(<OpenTextQuestion {...defaultProps} currentQuestionId="q1" />);
|
||||
const inputCurrent = screen.getByPlaceholderText("Type here...");
|
||||
const submitCurrent = screen.getByRole("button", { name: "Submit" });
|
||||
|
||||
expect(inputCurrent).toHaveAttribute("tabIndex", "0");
|
||||
expect(submitCurrent).toHaveAttribute("tabIndex", "0");
|
||||
|
||||
// When it's not the current question
|
||||
cleanup();
|
||||
render(<OpenTextQuestion {...defaultProps} currentQuestionId="q2" />);
|
||||
const inputNotCurrent = screen.getByPlaceholderText("Type here...");
|
||||
const submitNotCurrent = screen.getByRole("button", { name: "Submit" });
|
||||
|
||||
expect(inputNotCurrent).toHaveAttribute("tabIndex", "-1");
|
||||
expect(submitNotCurrent).toHaveAttribute("tabIndex", "-1");
|
||||
});
|
||||
|
||||
test("applies title attribute for phone input in textarea", () => {
|
||||
render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
question={{
|
||||
...defaultQuestion,
|
||||
longAnswer: true,
|
||||
inputType: "phone",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
expect(textarea).toHaveAttribute("title", "Please enter a valid phone number");
|
||||
});
|
||||
|
||||
test("applies character limits for textarea", () => {
|
||||
render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
question={{
|
||||
...defaultQuestion,
|
||||
longAnswer: true,
|
||||
inputType: "text",
|
||||
charLimit: { min: 10, max: 200 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
expect(textarea).toHaveAttribute("minLength", "10");
|
||||
expect(textarea).toHaveAttribute("maxLength", "200");
|
||||
});
|
||||
|
||||
test("renders input with no maxLength for other input types", () => {
|
||||
render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
question={{
|
||||
...defaultQuestion,
|
||||
inputType: "email",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type here...");
|
||||
// Should be undefined for non-text, non-phone types
|
||||
expect(input).not.toHaveAttribute("maxLength");
|
||||
});
|
||||
|
||||
test("applies autofocus attribute to textarea when enabled", () => {
|
||||
render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
autoFocusEnabled={true}
|
||||
question={{
|
||||
...defaultQuestion,
|
||||
longAnswer: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
expect(textarea).toHaveAttribute("autoFocus");
|
||||
});
|
||||
|
||||
test("does not apply autofocus attribute to textarea when not current question", () => {
|
||||
render(
|
||||
<OpenTextQuestion
|
||||
{...defaultProps}
|
||||
autoFocusEnabled={true}
|
||||
currentQuestionId="q2" // different from question.id (q1)
|
||||
question={{
|
||||
...defaultQuestion,
|
||||
longAnswer: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
expect(textarea).not.toHaveAttribute("autoFocus");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,8 +115,8 @@ export function OpenTextQuestion({
|
||||
className="fb-border-border placeholder:fb-text-placeholder fb-text-subheading focus:fb-border-brand fb-bg-input-bg fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-outline-none focus:fb-ring-0 sm:fb-text-sm"
|
||||
pattern={question.inputType === "phone" ? "^[0-9+][0-9+\\- ]*[0-9]$" : ".*"}
|
||||
title={question.inputType === "phone" ? "Enter a valid phone number" : undefined}
|
||||
minlength={question.inputType === "text" ? question.charLimit?.min : undefined}
|
||||
maxlength={
|
||||
minLength={question.inputType === "text" ? question.charLimit?.min : undefined}
|
||||
maxLength={
|
||||
question.inputType === "text"
|
||||
? question.charLimit?.max
|
||||
: question.inputType === "phone"
|
||||
@@ -143,8 +143,8 @@ export function OpenTextQuestion({
|
||||
}}
|
||||
className="fb-border-border placeholder:fb-text-placeholder fb-bg-input-bg fb-text-subheading focus:fb-border-brand fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-ring-0 sm:fb-text-sm"
|
||||
title={question.inputType === "phone" ? "Please enter a valid phone number" : undefined}
|
||||
minlength={question.inputType === "text" ? question.charLimit?.min : undefined}
|
||||
maxlength={question.inputType === "text" ? question.charLimit?.max : undefined}
|
||||
minLength={question.inputType === "text" ? question.charLimit?.min : undefined}
|
||||
maxLength={question.inputType === "text" ? question.charLimit?.max : undefined}
|
||||
/>
|
||||
)}
|
||||
{question.inputType === "text" && question.charLimit?.max !== undefined && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { type TResponseData, type TResponseVariables } from "@formbricks/types/responses";
|
||||
import { type TSurveyQuestion, TSurveyQuestionTypeEnum } from "../../../types/surveys/types";
|
||||
import { parseRecallInformation, replaceRecallInfo } from "./recall";
|
||||
@@ -15,7 +15,7 @@ vi.mock("./i18n", () => ({
|
||||
vi.mock("./date-time", () => ({
|
||||
isValidDateString: (val: string) => /^\d{4}-\d{2}-\d{2}$/.test(val) || /^\d{2}-\d{2}-\d{4}$/.test(val),
|
||||
formatDateWithOrdinal: (date: Date) =>
|
||||
`${date.getFullYear()}-${("0" + (date.getMonth() + 1)).slice(-2)}-${("0" + date.getDate()).slice(-2)}_formatted`,
|
||||
`${date.getUTCFullYear()}-${("0" + (date.getUTCMonth() + 1)).slice(-2)}-${("0" + date.getUTCDate()).slice(-2)}_formatted`,
|
||||
}));
|
||||
|
||||
describe("replaceRecallInfo", () => {
|
||||
@@ -34,79 +34,73 @@ describe("replaceRecallInfo", () => {
|
||||
lastLogin: "2024-03-10",
|
||||
};
|
||||
|
||||
it("should replace recall info from responseData", () => {
|
||||
test("should replace recall info from responseData", () => {
|
||||
const text = "Welcome, #recall:name/fallback:Guest#! Your email is #recall:email/fallback:N/A#.";
|
||||
const expected = "Welcome, John Doe! Your email is john.doe@example.com.";
|
||||
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should replace recall info from variables if not in responseData", () => {
|
||||
test("should replace recall info from variables if not in responseData", () => {
|
||||
const text = "Product: #recall:productName/fallback:N/A#. Role: #recall:userRole/fallback:User#.";
|
||||
const expected = "Product: Formbricks. Role: Admin.";
|
||||
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should use fallback if value is not found in responseData or variables", () => {
|
||||
test("should use fallback if value is not found in responseData or variables", () => {
|
||||
const text = "Your organization is #recall:orgName/fallback:DefaultOrg#.";
|
||||
const expected = "Your organization is DefaultOrg.";
|
||||
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle nbsp in fallback", () => {
|
||||
const text = "Status: #recall:status/fallback:PendingnbspReview#.";
|
||||
const expected = "Status: Pending Review.";
|
||||
test("should handle nbsp in fallback", () => {
|
||||
const text = "Status: #recall:status/fallback:Pending Review#.";
|
||||
const expected = "Status: Pending& ;Review.";
|
||||
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should format date strings from responseData", () => {
|
||||
test("should format date strings from responseData", () => {
|
||||
const text = "Registered on: #recall:registrationDate/fallback:N/A#.";
|
||||
const expected = "Registered on: 2023-01-15_formatted.";
|
||||
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should format date strings from variables", () => {
|
||||
test("should format date strings from variables", () => {
|
||||
const text = "Last login: #recall:lastLogin/fallback:N/A#.";
|
||||
const expected = "Last login: 2024-03-10_formatted.";
|
||||
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should join array values with a comma and space", () => {
|
||||
test("should join array values with a comma and space", () => {
|
||||
const text = "Tags: #recall:tags/fallback:none#.";
|
||||
const expected = "Tags: beta, user.";
|
||||
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle empty array values, replacing with fallback", () => {
|
||||
test("should handle empty array values, replacing with fallback", () => {
|
||||
const text = "Categories: #recall:emptyArray/fallback:No Categories#.";
|
||||
const expected = "Categories: No& ;Categories.";
|
||||
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle null values from responseData, replacing with fallback", () => {
|
||||
const text = "Preference: #recall:nullValue/fallback:Not Set#.";
|
||||
const expected = "Preference: Not& ;Set.";
|
||||
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle multiple recall patterns in a single string", () => {
|
||||
test("should handle multiple recall patterns in a single string", () => {
|
||||
const text =
|
||||
"Hi #recall:name/fallback:User#, welcome to #recall:productName/fallback:Our Product#. Your role is #recall:userRole/fallback:Member#.";
|
||||
const expected = "Hi John Doe, welcome to #recall:productName/fallback:Our Product#. Your role is Admin.";
|
||||
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should return original text if no recall pattern is found", () => {
|
||||
test("should return original text if no recall pattern is found", () => {
|
||||
const text = "This is a normal text without recall info.";
|
||||
expect(replaceRecallInfo(text, responseData, variables)).toBe(text);
|
||||
});
|
||||
|
||||
it("should handle recall ID not found, using fallback", () => {
|
||||
test("should handle recall ID not found, using fallback", () => {
|
||||
const text = "Value: #recall:nonExistent/fallback:FallbackValue#.";
|
||||
const expected = "Value: FallbackValue.";
|
||||
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle if recall info is incomplete (e.g. missing fallback part), effectively using empty fallback", () => {
|
||||
test("should handle if recall info is incomplete (e.g. missing fallback part), effectively using empty fallback", () => {
|
||||
// This specific pattern is not fully matched by extractRecallInfo, leading to no replacement.
|
||||
// The current extractRecallInfo expects #recall:ID/fallback:VALUE#
|
||||
const text = "Test: #recall:name#";
|
||||
@@ -114,10 +108,28 @@ describe("replaceRecallInfo", () => {
|
||||
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle complex fallback with spaces and special characters encoded as nbsp", () => {
|
||||
test("should handle complex fallback with spaces and special characters encoded as nbsp", () => {
|
||||
const text =
|
||||
"Details: #recall:extraInfo/fallback:ValuenbspWithnbspSpaces# and #recall:anotherInfo/fallback:Default#";
|
||||
const expected = "Details: Value With Spaces and Default";
|
||||
"Details: #recall:extraInfo/fallback:Value With Spaces# and #recall:anotherInfo/fallback:Default#";
|
||||
const expected = "Details: Value& ;With& ;Spaces and Default";
|
||||
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should handle fallback with only 'nbsp'", () => {
|
||||
const text = "Note: #recall:note/fallback:nbsp#.";
|
||||
const expected = "Note: .";
|
||||
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should handle fallback with only ' '", () => {
|
||||
const text = "Note: #recall:note/fallback: #.";
|
||||
const expected = "Note: & ;.";
|
||||
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should handle fallback with '$nbsp;' (should not replace '$nbsp;')", () => {
|
||||
const text = "Note: #recall:note/fallback:$nbsp;#.";
|
||||
const expected = "Note: $ ;.";
|
||||
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -151,7 +163,7 @@ describe("parseRecallInformation", () => {
|
||||
// other necessary TSurveyQuestion fields can be added here with default values
|
||||
};
|
||||
|
||||
it("should replace recall info in headline", () => {
|
||||
test("should replace recall info in headline", () => {
|
||||
const question: TSurveyQuestion = {
|
||||
...baseQuestion,
|
||||
headline: { en: "Welcome, #recall:name/fallback:Guest#!" },
|
||||
@@ -161,7 +173,7 @@ describe("parseRecallInformation", () => {
|
||||
expect(result.headline.en).toBe(expectedHeadline);
|
||||
});
|
||||
|
||||
it("should replace recall info in subheader", () => {
|
||||
test("should replace recall info in subheader", () => {
|
||||
const question: TSurveyQuestion = {
|
||||
...baseQuestion,
|
||||
headline: { en: "Main Question" },
|
||||
@@ -172,7 +184,7 @@ describe("parseRecallInformation", () => {
|
||||
expect(result.subheader?.en).toBe(expectedSubheader);
|
||||
});
|
||||
|
||||
it("should replace recall info in both headline and subheader", () => {
|
||||
test("should replace recall info in both headline and subheader", () => {
|
||||
const question: TSurveyQuestion = {
|
||||
...baseQuestion,
|
||||
headline: { en: "User: #recall:name/fallback:User#" },
|
||||
@@ -183,7 +195,7 @@ describe("parseRecallInformation", () => {
|
||||
expect(result.subheader?.en).toBe("Survey: Onboarding");
|
||||
});
|
||||
|
||||
it("should not change text if no recall info is present", () => {
|
||||
test("should not change text if no recall info is present", () => {
|
||||
const question: TSurveyQuestion = {
|
||||
...baseQuestion,
|
||||
headline: { en: "A simple question." },
|
||||
@@ -199,7 +211,7 @@ describe("parseRecallInformation", () => {
|
||||
expect(result.subheader?.en).toBe(question.subheader?.en);
|
||||
});
|
||||
|
||||
it("should handle undefined subheader gracefully", () => {
|
||||
test("should handle undefined subheader gracefully", () => {
|
||||
const question: TSurveyQuestion = {
|
||||
...baseQuestion,
|
||||
headline: { en: "Question with #recall:name/fallback:User#" },
|
||||
@@ -210,7 +222,7 @@ describe("parseRecallInformation", () => {
|
||||
expect(result.subheader).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not modify subheader if languageCode content is missing, even if recall is in other lang", () => {
|
||||
test("should not modify subheader if languageCode content is missing, even if recall is in other lang", () => {
|
||||
const question: TSurveyQuestion = {
|
||||
...baseQuestion,
|
||||
headline: { en: "Hello #recall:name/fallback:User#" },
|
||||
@@ -222,7 +234,7 @@ describe("parseRecallInformation", () => {
|
||||
expect(result.subheader?.fr).toBe("Bonjour #recall:name/fallback:Utilisateur#");
|
||||
});
|
||||
|
||||
it("should handle malformed recall string (empty ID) leading to no replacement for that pattern", () => {
|
||||
test("should handle malformed recall string (empty ID) leading to no replacement for that pattern", () => {
|
||||
// This tests extractId returning null because extractRecallInfo won't match '#recall:/fallback:foo#'
|
||||
// due to idPattern requiring at least one char for ID.
|
||||
const question: TSurveyQuestion = {
|
||||
@@ -233,7 +245,7 @@ describe("parseRecallInformation", () => {
|
||||
expect(result.headline.en).toBe("Malformed: #recall:/fallback:foo# and valid: John Doe");
|
||||
});
|
||||
|
||||
it("should use empty string for empty fallback value", () => {
|
||||
test("should use empty string for empty fallback value", () => {
|
||||
// This tests extractFallbackValue returning ""
|
||||
const question: TSurveyQuestion = {
|
||||
...baseQuestion,
|
||||
@@ -243,7 +255,7 @@ describe("parseRecallInformation", () => {
|
||||
expect(result.headline.en).toBe("Data: "); // nonExistentData not found, empty fallback used
|
||||
});
|
||||
|
||||
it("should handle recall info if subheader is present but no text for languageCode", () => {
|
||||
test("should handle recall info if subheader is present but no text for languageCode", () => {
|
||||
const question: TSurveyQuestion = {
|
||||
...baseQuestion,
|
||||
headline: { en: "Headline #recall:name/fallback:User#" },
|
||||
|
||||
@@ -7,27 +7,19 @@ import { type TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
const extractId = (text: string): string | null => {
|
||||
const pattern = /#recall:([A-Za-z0-9_-]+)/;
|
||||
const match = text.match(pattern);
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return match?.[1] ?? null;
|
||||
};
|
||||
|
||||
// Extracts the fallback value from a string containing the "fallback" pattern.
|
||||
const extractFallbackValue = (text: string): string => {
|
||||
const pattern = /fallback:(\S*)#/;
|
||||
const match = text.match(pattern);
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
return match?.[1] ?? "";
|
||||
};
|
||||
|
||||
// Extracts the complete recall information (ID and fallback) from a headline string.
|
||||
const extractRecallInfo = (headline: string, id?: string): string | null => {
|
||||
const idPattern = id ? id : "[A-Za-z0-9_-]+";
|
||||
const idPattern = id ?? "[A-Za-z0-9_-]+";
|
||||
const pattern = new RegExp(`#recall:(${idPattern})\\/fallback:(\\S*)#`);
|
||||
const match = headline.match(pattern);
|
||||
return match ? match[0] : null;
|
||||
@@ -47,7 +39,7 @@ export const replaceRecallInfo = (
|
||||
const recallItemId = extractId(recallInfo);
|
||||
if (!recallItemId) return modifiedText; // Return the text if no ID could be extracted
|
||||
|
||||
const fallback = extractFallbackValue(recallInfo).replaceAll("nbsp", " ");
|
||||
const fallback = extractFallbackValue(recallInfo).replace(/nbsp/g, " ").trim();
|
||||
let value: string | null = null;
|
||||
|
||||
// Fetching value from variables based on recallItemId
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import preact from "@preact/preset-vite";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, resolve } from "path";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import { loadEnv } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { copyCompiledAssetsPlugin } from "../vite-plugins/copy-compiled-assets";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -29,7 +30,7 @@ const config = ({ mode }) => {
|
||||
},
|
||||
},
|
||||
define: {
|
||||
"process.env": env,
|
||||
"process.env.NODE_ENV": JSON.stringify(mode),
|
||||
},
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
|
||||
2708
pnpm-lock.yaml
generated
2708
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user