Merge branch 'main' of https://github.com/formbricks/formbricks into feat/form-styling

This commit is contained in:
pandeymangg
2024-03-04 13:19:00 +05:30
62 changed files with 1964 additions and 1622 deletions

View File

@@ -13,7 +13,7 @@
"dependencies": {
"@formbricks/js": "workspace:*",
"@heroicons/react": "^2.1.1",
"next": "14.1.0",
"next": "14.1.1",
"react": "18.2.0",
"react-dom": "18.2.0"
},

View File

@@ -18,7 +18,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
{
name: "Argos",
description: "Argos provides the developer tools to debug tests and detect visual regressions..",
description: "Argos provides the developer tools to debug tests and detect visual regressions.",
href: "https://argos-ci.com",
},
{

View File

@@ -19,25 +19,29 @@ export default function PosthogIdentify({
userTargetingBillingStatus,
}: {
session: Session;
environmentId: string;
teamId: string;
teamName: string;
inAppSurveyBillingStatus: TSubscriptionStatus;
linkSurveyBillingStatus: TSubscriptionStatus;
userTargetingBillingStatus: TSubscriptionStatus;
environmentId?: string;
teamId?: string;
teamName?: string;
inAppSurveyBillingStatus?: TSubscriptionStatus;
linkSurveyBillingStatus?: TSubscriptionStatus;
userTargetingBillingStatus?: TSubscriptionStatus;
}) {
const posthog = usePostHog();
useEffect(() => {
if (posthogEnabled && session.user && posthog) {
posthog.identify(session.user.id, { name: session.user.name, email: session.user.email });
posthog.group("environment", environmentId, { name: environmentId });
posthog.group("team", teamId, {
name: teamName,
inAppSurveyBillingStatus,
linkSurveyBillingStatus,
userTargetingBillingStatus,
});
if (environmentId) {
posthog.group("environment", environmentId, { name: environmentId });
}
if (teamId) {
posthog.group("team", teamId, {
name: teamName,
inAppSurveyBillingStatus,
linkSurveyBillingStatus,
userTargetingBillingStatus,
});
}
}
}, [
posthog,

View File

@@ -8,6 +8,7 @@ import { useEffect, useState } from "react";
import { Control, Controller, UseFormSetValue, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
TIntegrationAirtable,
@@ -333,7 +334,7 @@ export default function AddIntegrationModal(props: AddIntegrationModalProps) {
<Label htmlFor="Surveys">Questions</Label>
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{selectedSurvey?.questions.map((question) => (
{checkForRecallInHeadline(selectedSurvey)?.questions.map((question) => (
<Controller
key={question.id}
control={control}

View File

@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
TIntegrationGoogleSheets,
@@ -273,7 +274,7 @@ export default function AddIntegrationModal({
<Label htmlFor="Surveys">Questions</Label>
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{selectedSurvey?.questions.map((question) => (
{checkForRecallInHeadline(selectedSurvey)?.questions.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox

View File

@@ -13,6 +13,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { TIntegrationInput } from "@formbricks/types/integration";
import {
TIntegrationNotion,
@@ -105,12 +106,13 @@ export default function AddIntegrationModal({
}, [selectedDatabase?.id]);
const questionItems = useMemo(() => {
const questions =
selectedSurvey?.questions.map((q) => ({
id: q.id,
name: q.headline,
type: q.type,
})) || [];
const questions = selectedSurvey
? checkForRecallInHeadline(selectedSurvey)?.questions.map((q) => ({
id: q.id,
name: q.headline,
type: q.type,
}))
: [];
const hiddenFields = selectedSurvey?.hiddenFields.enabled
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({

View File

@@ -53,7 +53,8 @@ export default function AddWebhookModal({ environmentId, surveys, open, setOpen
return true;
} catch (err) {
setHittingEndpoint(false);
toast.error("Oh no! We are unable to ping the webhook!");
toast.error("Unable to ping the webhook! Please check browser console for logs");
console.error("Webhook Test Failed due to: ", err.message);
setEndpointAccessible(false);
return false;
}

View File

@@ -63,7 +63,8 @@ export default function WebhookSettingsTab({
return true;
} catch (err) {
setHittingEndpoint(false);
toast.error("Oh no! We are unable to ping the webhook!");
toast.error("Unable to ping the webhook! Please check browser console for logs");
console.error("Webhook Test Failed due to: ", err.message);
setEndpointAccessible(false);
return false;
}

View File

@@ -79,7 +79,7 @@ export default function AddMemberModal({
<div>
<AddMemberRole control={control} canDoRoleManagement={canDoRoleManagement} />
{!canDoRoleManagement &&
(!isFormbricksCloud ? (
(isFormbricksCloud ? (
<UpgradePlanNotice
message="To manage access roles,"
url={`/environments/${environmentId}/settings/billing`}

View File

@@ -111,7 +111,7 @@ export default function MemberActions({ team, member, invite, showDeleteButton }
onClick={() => {
handleShareInvite();
}}
id="shareInviteButton">
className="shareInviteButton">
<ShareIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
</button>
</TooltipTrigger>

View File

@@ -94,7 +94,7 @@ export default function ShareEmbedSurvey({ survey, open, setOpen, webAppUrl, use
<div className="flex max-w-full flex-col items-center justify-center space-x-2 lg:flex-row">
<div
ref={linkTextRef}
className="mt-2 max-w-[70%] overflow-hidden rounded-lg border border-slate-300 bg-slate-50 px-3 py-2 text-slate-800"
className="mt-2 max-w-[80%] overflow-hidden rounded-lg border border-slate-300 bg-slate-50 px-3 py-2 text-slate-800"
style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}
onClick={() => handleTextSelection()}>
{surveyUrl}

View File

@@ -314,12 +314,6 @@ export default function SurveyMenuBar({
toast.success("Changes saved.");
if (shouldNavigateBack) {
router.back();
} else {
if (localSurvey.status !== "draft") {
router.push(`/environments/${environment.id}/surveys/${localSurvey.id}/summary`);
} else {
router.push(`/environments/${environment.id}/surveys`);
}
}
} catch (e) {
console.error(e);

View File

@@ -216,7 +216,7 @@ export default function PreviewSurvey({
/>
</Modal>
) : (
<div className="w-full px-4">
<div className="w-full px-3">
<div className="no-scrollbar z-10 w-full max-w-md overflow-y-auto rounded-lg border border-transparent">
<SurveyInline
survey={survey}

View File

@@ -44,6 +44,7 @@ export default function PathwaySelect({
/>
<div className="flex space-x-8">
<OptionCard
cssId="onboarding-link-survey-card"
size="lg"
title="Link Surveys"
description="Create a new survey and share a link."
@@ -53,6 +54,7 @@ export default function PathwaySelect({
<Image src={LinkMockup} alt="" height={350} />
</OptionCard>
<OptionCard
cssId="onboarding-inapp-survey-card"
size="lg"
title="In-app Surveys"
description="Run a survey on a website or in-app."

View File

@@ -58,6 +58,7 @@ const ConnectedState = ({ goToProduct }) => {
</div>
<div className="mt-4 text-right">
<Button
id="onboarding-inapp-connect-connection-successful"
variant="minimal"
loading={isLoading}
onClick={() => {
@@ -91,6 +92,7 @@ const NotConnectedState = ({ environment, webAppUrl, jsPackageVersion, goToTeamI
/>
<div className="flex justify-center">
<Button
id="onboarding-inapp-connect-not-sure-how-to-do-this"
className="opacity-0 transition-all delay-[3000ms] duration-500 ease-in-out group-hover:opacity-100"
variant="minimal"
onClick={goToTeamInvitePage}>
@@ -127,7 +129,7 @@ export function ConnectWithFormbricks({
setLocalEnvironment(refetchedEnvironment);
};
fetchLatestEnvironmentOnFirstLoad();
}, []);
}, [environment.id]);
return localEnvironment.widgetSetupCompleted ? (
<ConnectedState

View File

@@ -105,16 +105,17 @@ export function InviteTeamMate({ team, environmentId, setCurrentStep }: InviteTe
/>
<div className="flex w-full justify-between">
<Button variant="minimal" onClick={() => goBackToConnectPage()}>
<Button id="onboarding-inapp-invite-back" variant="minimal" onClick={() => goBackToConnectPage()}>
Back
</Button>
<Button variant="darkCTA" onClick={handleInvite}>
<Button id="onboarding-inapp-invite-send-invite" variant="darkCTA" onClick={handleInvite}>
Invite
</Button>
</div>
</div>
<div className="mt-auto flex justify-center">
<Button
id="onboarding-inapp-invite-have-a-look-first"
className="opacity-0 transition-all delay-[3000ms] duration-500 ease-in-out group-hover:opacity-100"
variant="minimal"
onClick={goToProduct}

View File

@@ -73,6 +73,7 @@ if (typeof window !== "undefined") {
});
}`}</CodeBlock>
<Button
id="onboarding-inapp-connect-read-npm-docs"
className="mt-3"
variant="secondary"
href="https://formbricks.com/docs/getting-started/framework-guides"
@@ -90,6 +91,7 @@ if (typeof window !== "undefined") {
</CodeBlock>
<div className="mt-4 space-x-2">
<Button
id="onboarding-inapp-connect-copy-code"
variant="darkCTA"
onClick={() => {
navigator.clipboard.writeText(htmlSnippet);
@@ -98,6 +100,7 @@ if (typeof window !== "undefined") {
Copy code
</Button>
<Button
id="onboarding-inapp-connect-step-by-step-manual"
variant="secondary"
href="https://formbricks.com/docs/getting-started/framework-guides#html"
target="_blank">

View File

@@ -155,7 +155,7 @@ export const Objective: React.FC<ObjectiveProps> = ({ formbricksResponseId, user
loading={isProfileUpdating}
disabled={!selectedChoice}
onClick={handleNextClick}
id="objective-next">
id="onboarding-inapp-objective-next">
Next
</Button>
</div>

View File

@@ -151,7 +151,7 @@ export const Role: React.FC<RoleProps> = ({ setFormbricksResponseId, session, se
loading={isUpdating}
disabled={!selectedChoice}
onClick={handleNextClick}
id="role-next">
id="onboarding-inapp-role-next">
Next
</Button>
</div>

View File

@@ -59,8 +59,10 @@ export function CreateFirstSurvey({ environmentId }: CreateFirstSurveyProps) {
<div className="grid w-11/12 max-w-6xl grid-cols-3 grid-rows-1 gap-6">
{filteredTemplates.map((template) => {
const TemplateImage = templateImages[template.name];
const cssId = `onboarding-link-template-${template.name.toLowerCase().replace(/ /g, "-")}`;
return (
<OptionCard
cssId={cssId} // Use the generated cssId here
size="md"
key={template.name}
title={template.name}
@@ -72,7 +74,9 @@ export function CreateFirstSurvey({ environmentId }: CreateFirstSurveyProps) {
);
})}
</div>
<Button
id="onboarding-start-from-scratch"
size="lg"
variant="secondary"
loading={loadingTemplate === "Start from scratch"}

View File

@@ -1,12 +1,14 @@
"use client";
import jsPackageJson from "@/../../packages/js/package.json";
import { finishOnboardingAction } from "@/app/(app)/onboarding/actions";
import { ConnectWithFormbricks } from "@/app/(app)/onboarding/components/inapp/ConnectWithFormbricks";
import { InviteTeamMate } from "@/app/(app)/onboarding/components/inapp/InviteTeamMate";
import { Objective } from "@/app/(app)/onboarding/components/inapp/SurveyObjective";
import { Role } from "@/app/(app)/onboarding/components/inapp/SurveyRole";
import { CreateFirstSurvey } from "@/app/(app)/onboarding/components/link/CreateFirstSurvey";
import { Session } from "next-auth";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
@@ -33,6 +35,7 @@ export function Onboarding({
team,
webAppUrl,
}: OnboardingProps) {
const router = useRouter();
const [selectedPathway, setSelectedPathway] = useState<string | null>(null);
const [progress, setProgress] = useState<number>(16);
const [formbricksResponseId, setFormbricksResponseId] = useState<string | undefined>();
@@ -142,6 +145,17 @@ export function Onboarding({
return (
<div className="flex h-full w-full flex-col items-center bg-slate-50">
<div className="hidden">
<button
id="FB__INTERNAL__SKIP_ONBOARDING"
onClick={async () => {
await finishOnboardingAction();
router.push(`/environments/${environment.id}/surveys`);
}}>
Skip onboarding
</button>
</div>
<OnboardingHeader progress={progress} />
<div className="mt-20 flex w-full justify-center bg-slate-50">
{renderOnboardingStep()}

View File

@@ -1,3 +1,4 @@
import PosthogIdentify from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
@@ -12,6 +13,7 @@ export default async function EnvironmentLayout({ children }) {
return (
<div className="h-full w-full bg-slate-50">
<PosthogIdentify session={session} />
<ToasterClient />
{children}
</div>

View File

@@ -162,9 +162,13 @@ export default function LinkSurvey({
getSetIsError={(f: (value: boolean) => void) => {
setIsError = f;
}}
getSetIsResponseSendingFinished={(f: (value: boolean) => void) => {
setIsResponseSendingFinished = f;
}}
getSetIsResponseSendingFinished={
!isPreview
? (f: (value: boolean) => void) => {
setIsResponseSendingFinished = f;
}
: undefined
}
onRetry={() => {
setIsError(false);
responseQueue.processQueue();

View File

@@ -2,9 +2,11 @@
import { sendLinkSurveyEmailAction } from "@/app/s/[surveyId]/actions";
import { EnvelopeIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
import { ArrowLeft } from "lucide-react";
import { useMemo, useState } from "react";
import { Toaster, toast } from "react-hot-toast";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { TSurvey } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
@@ -19,6 +21,10 @@ export default function VerifyEmail({
isErrorComponent?: boolean;
singleUseId?: string;
}) {
survey = useMemo(() => {
return checkForRecallInHeadline(survey);
}, [survey]);
const [showPreviewQuestions, setShowPreviewQuestions] = useState(false);
const [email, setEmail] = useState<string | null>(null);
const [emailSent, setEmailSent] = useState<boolean>(false);
@@ -106,7 +112,6 @@ export default function VerifyEmail({
)}
{!emailSent && showPreviewQuestions && (
<div>
{" "}
<p className="text-4xl font-bold">Question Preview</p>
<div className="mt-4 flex w-full flex-col justify-center rounded-lg border border-slate-200 bg-slate-50 bg-opacity-20 p-8 text-slate-700">
{survey.questions.map((question, index) => (
@@ -126,11 +131,8 @@ export default function VerifyEmail({
We sent an email to <span className="font-semibold italic">{email}</span>. Please click the link
in the email to take your survey.
</p>
<Button
variant="secondary"
className="mt-6 cursor-pointer text-sm text-slate-400"
onClick={handleGoBackClick}>
Go Back
<Button variant="secondary" className="mt-6" onClick={handleGoBackClick} StartIcon={ArrowLeft}>
Back
</Button>
</div>
)}

View File

@@ -8,12 +8,10 @@ import { checkValidity } from "@/app/s/[surveyId]/lib/prefilling";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { IMPRINT_URL, IS_FORMBRICKS_CLOUD, PRIVACY_URL } from "@formbricks/lib/constants";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { IMPRINT_URL, IS_FORMBRICKS_CLOUD, PRIVACY_URL, WEBAPP_URL } from "@formbricks/lib/constants";
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getResponseBySingleUseId } from "@formbricks/lib/response/service";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getResponseBySingleUseId, getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { ZId } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";

View File

@@ -23,11 +23,8 @@ export async function middleware(request: NextRequest) {
const token = await getToken({ req: request });
if (isWebAppRoute(request.nextUrl.pathname) && !token) {
const loginUrl = new URL(
`/auth/login?callbackUrl=${encodeURIComponent(request.nextUrl.toString())}`,
WEBAPP_URL
);
return NextResponse.redirect(loginUrl.href);
const loginUrl = `${WEBAPP_URL}/auth/login?callbackUrl=${encodeURIComponent(WEBAPP_URL + request.nextUrl.pathname + request.nextUrl.search)}`;
return NextResponse.redirect(loginUrl);
}
const callbackUrl = request.nextUrl.searchParams.get("callbackUrl");

View File

@@ -27,31 +27,31 @@
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-collapsible": "^1.0.3",
"@react-email/components": "^0.0.15",
"@sentry/nextjs": "^7.102.1",
"@sentry/nextjs": "^7.104.0",
"@vercel/og": "^0.6.2",
"@vercel/speed-insights": "^1.0.10",
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.5",
"encoding": "^0.1.13",
"framer-motion": "11.0.6",
"framer-motion": "11.0.8",
"googleapis": "^133.0.0",
"jiti": "^1.21.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"lru-cache": "^10.2.0",
"lucide-react": "^0.339.0",
"lucide-react": "^0.344.0",
"mime": "^4.0.1",
"next": "14.1.0",
"nodemailer": "^6.9.10",
"next": "14.1.1",
"nodemailer": "^6.9.11",
"otplib": "^12.0.1",
"posthog-js": "^1.108.3",
"posthog-js": "^1.110.0",
"prismjs": "^1.29.0",
"qrcode": "^1.5.3",
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-dom": "18.2.0",
"react-email": "^2.1.0",
"react-hook-form": "^7.50.1",
"react-hook-form": "^7.51.0",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.0.1",
"sharp": "^0.33.2",

View File

@@ -64,7 +64,8 @@ test.describe("JS Package Test", async () => {
// Formbricks Modal is visible
await expect(page.getByRole("link", { name: "Powered by Formbricks" })).toBeVisible();
await page.waitForTimeout(1000);
await page.waitForLoadState("networkidle");
await page.waitForTimeout(1500);
});
test("Admin checks Display", async ({ page }) => {
@@ -74,9 +75,11 @@ test.describe("JS Package Test", async () => {
(await page.waitForSelector("text=Responses")).isVisible();
// Survey should have 1 Display
await page.waitForTimeout(1000);
await expect(page.getByText("Displays1")).toBeVisible();
// Survey should have 0 Responses
await page.waitForTimeout(1000);
await expect(page.getByRole("button", { name: "Responses0% -" })).toBeVisible();
});
@@ -115,7 +118,7 @@ test.describe("JS Package Test", async () => {
// Formbricks Modal is not visible
await expect(page.getByText("Powered by Formbricks")).not.toBeVisible({ timeout: 10000 });
await page.waitForLoadState("networkidle");
await page.waitForTimeout(500);
await page.waitForTimeout(1500);
});
test("Admin validates Response", async ({ page }) => {
@@ -125,8 +128,11 @@ test.describe("JS Package Test", async () => {
(await page.waitForSelector("text=Responses")).isVisible();
// Survey should have 2 Displays
await page.waitForTimeout(1000);
await expect(page.getByText("Displays2")).toBeVisible();
// Survey should have 1 Response
await page.waitForTimeout(1000);
await expect(page.getByRole("button", { name: "Responses50%" })).toBeVisible();
await expect(page.getByText("1 responses", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Clickthrough Rate (CTR)100%")).toBeVisible();

View File

@@ -14,8 +14,10 @@ test.describe("Onboarding Flow Test", async () => {
await page.getByRole("button", { name: "Link Surveys Create a new" }).click();
await page.getByRole("button", { name: "Collect Feedback Collect" }).click();
await page.getByRole("button", { name: "Back", exact: true }).click();
await page.getByRole("button", { name: "Save" }).click();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await expect(page.getByText(productName)).toBeVisible();
});
test("In app survey", async ({ page }) => {
@@ -24,6 +26,10 @@ test.describe("Onboarding Flow Test", async () => {
await page.waitForURL("/onboarding");
await expect(page).toHaveURL("/onboarding");
await page.getByRole("button", { name: "In-app Surveys Run a survey" }).click();
await page.getByRole("button", { name: "Skip" }).click();
await page.getByRole("button", { name: "Skip" }).click();
await page.getByRole("button", { name: "I am not sure how to do this" }).click();
await page.locator("input").click();
await page.locator("input").fill("test@gmail.com");

View File

@@ -58,7 +58,7 @@ test.describe("Invite, accept and remove team member", async () => {
const pendingSpan = lastMemberInfo.locator("span").filter({ hasText: "Pending" });
await expect(pendingSpan).toBeVisible();
const shareInviteButton = page.locator("#shareInviteButton");
const shareInviteButton = page.locator(".shareInviteButton").last();
await expect(shareInviteButton).toBeVisible();
await shareInviteButton.click();

View File

@@ -12,11 +12,19 @@ export const signUpAndLogin = async (
await page.goto("/auth/login");
await page.getByRole("link", { name: "Create an account" }).click();
await page.getByRole("button", { name: "Continue with Email" }).click();
await expect(page.getByPlaceholder("Full Name")).toBeVisible();
await page.getByPlaceholder("Full Name").fill(name);
await page.getByPlaceholder("Full Name").press("Tab");
await expect(page.getByPlaceholder("work@email.com")).toBeVisible();
await page.getByPlaceholder("work@email.com").click();
await page.getByPlaceholder("work@email.com").fill(email);
await page.getByPlaceholder("work@email.com").press("Tab");
await expect(page.getByPlaceholder("*******")).toBeVisible();
await page.getByPlaceholder("*******").click();
await page.getByPlaceholder("*******").fill(password);
await page.getByRole("button", { name: "Continue with Email" }).click();
@@ -30,8 +38,17 @@ export const signUpAndLogin = async (
export const login = async (page: Page, email: string, password: string): Promise<void> => {
await page.goto("/auth/login");
await expect(page.getByRole("button", { name: "Login with Email" })).toBeVisible();
await page.getByRole("button", { name: "Login with Email" }).click();
await expect(page.getByPlaceholder("work@email.com")).toBeVisible();
await page.getByPlaceholder("work@email.com").fill(email);
await expect(page.getByPlaceholder("*******")).toBeVisible();
await page.getByPlaceholder("*******").click();
await page.getByPlaceholder("*******").fill(password);
await page.getByRole("button", { name: "Login with Email" }).click();
@@ -40,11 +57,19 @@ export const login = async (page: Page, email: string, password: string): Promis
export const finishOnboarding = async (page: Page): Promise<void> => {
await page.waitForURL("/onboarding");
await expect(page).toHaveURL("/onboarding");
await page.getByRole("button", { name: "In-app Surveys Run a survey" }).click();
await page.getByRole("button", { name: "I am not sure how to do this" }).click();
await page.locator("input").click();
await page.locator("input").fill("test@gmail.com");
await page.getByRole("button", { name: "Invite" }).click();
const hiddenSkipButton = page.locator("#FB__INTERNAL__SKIP_ONBOARDING");
hiddenSkipButton.evaluate((el: HTMLElement) => el.click());
// await page.getByRole("button", { name: "In-app Surveys Run a survey" }).click();
// await page.getByRole("button", { name: "Skip" }).click();
// await page.getByRole("button", { name: "Skip" }).click();
// await page.getByRole("button", { name: "I am not sure how to do this" }).click();
// await page.locator("input").click();
// await page.locator("input").fill("test@gmail.com");
// await page.getByRole("button", { name: "Invite" }).click();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await expect(page.getByText("My Product")).toBeVisible();
};

View File

@@ -7,7 +7,7 @@
"maxDuration": 10,
"memory": 300
},
"**/*.ts": {
"app/**/*.ts": {
"maxDuration": 10,
"memory": 512
}

View File

@@ -32,13 +32,13 @@
"prepare": "husky install"
},
"devDependencies": {
"@playwright/test": "^1.41.1",
"@playwright/test": "^1.42.1",
"eslint-config-formbricks": "workspace:*",
"husky": "^9.0.5",
"lint-staged": "^15.2.0",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"rimraf": "^5.0.5",
"tsx": "^4.7.0",
"turbo": "^1.11.3"
"tsx": "^4.7.1",
"turbo": "^1.12.4"
},
"lint-staged": {
"(apps|packages)/**/*.{js,ts,jsx,tsx}": [
@@ -64,6 +64,6 @@
},
"dependencies": {
"@changesets/cli": "^2.27.1",
"playwright": "^1.41.1"
"playwright": "^1.42.1"
}
}

View File

@@ -0,0 +1,20 @@
/*
Warnings:
- A unique constraint covering the columns `[personId,attributeClassId]` on the table `Attribute` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "Attribute_attributeClassId_personId_key";
-- DropIndex
DROP INDEX "AttributeClass_environmentId_idx";
-- CreateIndex
CREATE UNIQUE INDEX "Attribute_personId_attributeClassId_key" ON "Attribute"("personId", "attributeClassId");
-- CreateIndex
CREATE INDEX "AttributeClass_environmentId_created_at_idx" ON "AttributeClass"("environmentId", "created_at");
-- CreateIndex
CREATE INDEX "AttributeClass_environmentId_archived_idx" ON "AttributeClass"("environmentId", "archived");

View File

@@ -28,7 +28,7 @@
},
"dependencies": {
"@prisma/client": "^5.10.2",
"@prisma/extension-accelerate": "^0.6.3",
"@prisma/extension-accelerate": "^1.0.0",
"@t3-oss/env-nextjs": "^0.9.2",
"dotenv-cli": "^7.3.0"
},

View File

@@ -63,7 +63,7 @@ model Attribute {
personId String
value String
@@unique([attributeClassId, personId])
@@unique([personId, attributeClassId])
}
enum AttributeType {
@@ -86,7 +86,8 @@ model AttributeClass {
attributeFilters SurveyAttributeFilter[]
@@unique([name, environmentId])
@@index([environmentId])
@@index([environmentId, createdAt])
@@index([environmentId, archived])
}
model Person {

View File

@@ -19,6 +19,6 @@
"dependencies": {
"@formbricks/lib": "workspace:*",
"@t3-oss/env-nextjs": "^0.9.2",
"stripe": "^14.18.0"
"stripe": "^14.19.0"
}
}

View File

@@ -9,7 +9,7 @@
},
"devDependencies": {
"eslint": "^8.57.0",
"eslint-config-next": "^14.1.0",
"eslint-config-next": "^14.1.1",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "1.10.12",
"eslint-plugin-react": "7.33.2",

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/js",
"license": "MIT",
"version": "1.6.0",
"version": "1.6.1",
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
"homepage": "https://formbricks.com",
"repository": {
@@ -39,16 +39,16 @@
},
"author": "Formbricks <hola@formbricks.com>",
"devDependencies": {
"@babel/core": "^7.23.9",
"@babel/preset-env": "^7.23.9",
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0",
"@babel/preset-typescript": "^7.23.3",
"@formbricks/api": "workspace:*",
"@formbricks/lib": "workspace:*",
"@formbricks/surveys": "workspace:*",
"@formbricks/tsconfig": "workspace:*",
"@formbricks/types": "workspace:*",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"cross-env": "^7.0.3",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "1.10.12",

View File

@@ -46,7 +46,10 @@ export class CommandQueue {
if (currentItem.checkInitialized) {
const initResult = checkInitialized();
if (initResult && initResult.ok !== true) errorHandler.handle(initResult.error);
if (initResult && initResult.ok !== true) {
errorHandler.handle(initResult.error);
continue;
}
}
const executeCommand = async () => {

View File

@@ -25,11 +25,12 @@ export class Config {
public update(newConfig: TJsConfigUpdateInput): void {
if (newConfig) {
const expiresAt = new Date(new Date().getTime() + 2 * 60000); // 2 minutes from now
const expiresAt = new Date(new Date().getTime() + 2 * 60000); // 2 minutes in the future
this.config = {
...this.config,
...newConfig,
status: newConfig.status || "success",
expiresAt,
};

View File

@@ -2,7 +2,7 @@ import type { TJsConfig, TJsConfigInput } from "@formbricks/types/js";
import { TPersonAttributes } from "@formbricks/types/people";
import { trackAction } from "./actions";
import { Config } from "./config";
import { Config, LOCAL_STORAGE_KEY } from "./config";
import {
ErrorHandler,
MissingFieldError,
@@ -12,6 +12,7 @@ import {
Result,
err,
okVoid,
wrapThrows,
} from "./errors";
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners";
import { Logger } from "./logger";
@@ -19,23 +20,46 @@ import { checkPageUrl } from "./noCodeActions";
import { updatePersonAttributes } from "./person";
import { sync } from "./sync";
import { getIsDebug } from "./utils";
import { addWidgetContainer, closeSurvey } from "./widget";
import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } from "./widget";
const config = Config.getInstance();
const logger = Logger.getInstance();
let isInitialized = false;
export const setIsInitialized = (value: boolean) => {
isInitialized = value;
};
export const initialize = async (
c: TJsConfigInput
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
if (getIsDebug()) {
logger.configure({ logLevel: "debug" });
}
if (isInitialized) {
logger.debug("Already initialized, skipping initialization.");
return okVoid();
}
if (getIsDebug()) {
logger.configure({ logLevel: "debug" });
let existingConfig: TJsConfig | undefined;
try {
existingConfig = config.get();
logger.debug("Found existing configuration.");
} catch (e) {
logger.debug("No existing configuration found.");
}
// formbricks is in error state, skip initialization
if (existingConfig?.status === "error") {
logger.debug("Formbricks was set to an error state.");
if (existingConfig?.expiresAt && new Date(existingConfig.expiresAt) > new Date()) {
logger.debug("Error state is not expired, skipping initialization");
return okVoid();
} else {
logger.debug("Error state is expired. Continue with initialization.");
}
}
ErrorHandler.getInstance().printStatus();
@@ -81,13 +105,6 @@ export const initialize = async (
updatedAttributes = res.value;
}
let existingConfig: TJsConfig | undefined;
try {
existingConfig = config.get();
} catch (e) {
logger.debug("No existing configuration found.");
}
if (
existingConfig &&
existingConfig.state &&
@@ -96,29 +113,39 @@ export const initialize = async (
existingConfig.userId === c.userId &&
existingConfig.expiresAt // only accept config when they follow new config version with expiresAt
) {
logger.debug("Found existing configuration.");
logger.debug("Configuration fits init parameters.");
if (existingConfig.expiresAt < new Date()) {
logger.debug("Configuration expired.");
await sync({
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
});
try {
await sync({
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
});
} catch (e) {
putFormbricksInErrorState();
}
} else {
logger.debug("Configuration not expired. Extending expiration.");
config.update(existingConfig);
}
} else {
logger.debug("No valid configuration found or it has been expired. Creating new config.");
logger.debug(
"No valid configuration found or it has been expired. Resetting config and creating new one."
);
config.resetConfig();
logger.debug("Syncing.");
await sync({
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
});
try {
await sync({
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
});
} catch (e) {
handleErrorOnFirstInit();
}
// and track the new session event
await trackAction("New Session");
}
@@ -140,7 +167,7 @@ export const initialize = async (
addEventListeners();
addCleanupEventListeners();
isInitialized = true;
setIsInitialized(true);
logger.debug("Initialized");
// check page url if initialized after page load
@@ -149,6 +176,17 @@ export const initialize = async (
return okVoid();
};
const handleErrorOnFirstInit = () => {
// put formbricks in error state (by creating a new config) and throw error
const initialErrorConfig: Partial<TJsConfig> = {
status: "error",
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
};
// can't use config.update here because the config is not yet initialized
wrapThrows(() => localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig)))();
throw new Error("Could not initialize formbricks");
};
export const checkInitialized = (): Result<void, NotInitializedError> => {
logger.debug("Check if initialized");
if (!isInitialized || !ErrorHandler.initialized) {
@@ -163,8 +201,19 @@ export const checkInitialized = (): Result<void, NotInitializedError> => {
export const deinitalize = (): void => {
logger.debug("Deinitializing");
closeSurvey();
removeWidgetContainer();
setIsSurveyRunning(false);
removeAllEventListeners();
config.resetConfig();
isInitialized = false;
setIsInitialized(false);
};
export const putFormbricksInErrorState = (): void => {
logger.debug("Putting formbricks in error state");
// change formbricks status to error
config.update({
...config.get(),
status: "error",
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
});
deinitalize();
};

View File

@@ -13,7 +13,6 @@ import {
} from "./errors";
import { deinitalize, initialize } from "./initialize";
import { Logger } from "./logger";
import { sync } from "./sync";
import { closeSurvey } from "./widget";
const config = Config.getInstance();
@@ -55,15 +54,7 @@ export const updatePersonAttribute = async (
}
if (res.data.changed) {
logger.debug("Attribute updated. Syncing...");
await sync(
{
environmentId: environmentId,
apiHost: apiHost,
userId: userId,
},
true
);
logger.debug("Attribute updated in Formbricks");
}
return okVoid();
@@ -178,6 +169,7 @@ export const setPersonAttribute = async (
export const logoutPerson = async (): Promise<void> => {
deinitalize();
config.resetConfig();
};
export const resetPerson = async (): Promise<Result<void, NetworkError>> => {

View File

@@ -15,21 +15,42 @@ const syncWithBackend = async (
{ apiHost, environmentId, userId }: TJsSyncParams,
noCache: boolean
): Promise<Result<TJsStateSync, NetworkError>> => {
const baseUrl = `${apiHost}/api/v1/client/${environmentId}/in-app/sync`;
const urlSuffix = `?version=${import.meta.env.VERSION}`;
try {
const baseUrl = `${apiHost}/api/v1/client/${environmentId}/in-app/sync`;
const urlSuffix = `?version=${import.meta.env.VERSION}`;
let fetchOptions: RequestInit = {};
let fetchOptions: RequestInit = {};
if (noCache || getIsDebug()) {
fetchOptions.cache = "no-cache";
logger.debug("No cache option set for sync");
}
if (noCache || getIsDebug()) {
fetchOptions.cache = "no-cache";
logger.debug("No cache option set for sync");
}
// if user id is available
// if user id is not available
if (!userId) {
const url = baseUrl + urlSuffix;
// public survey
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const jsonRes = await response.json();
return err({
code: "network_error",
status: response.status,
message: "Error syncing with backend",
url,
responseMessage: jsonRes.message,
});
}
return ok((await response.json()).data as TJsState);
}
// userId is available, call the api with the `userId` param
const url = `${baseUrl}/${userId}${urlSuffix}`;
if (!userId) {
const url = baseUrl + urlSuffix;
// public survey
const response = await fetch(url, fetchOptions);
if (!response.ok) {
@@ -44,38 +65,20 @@ const syncWithBackend = async (
});
}
return ok((await response.json()).data as TJsState);
const data = await response.json();
const { data: state } = data;
return ok(state as TJsStateSync);
} catch (e) {
return err(e as NetworkError);
}
// userId is available, call the api with the `userId` param
const url = `${baseUrl}/${userId}${urlSuffix}`;
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const jsonRes = await response.json();
return err({
code: "network_error",
status: response.status,
message: "Error syncing with backend",
url,
responseMessage: jsonRes.message,
});
}
const data = await response.json();
const { data: state } = data;
return ok(state as TJsStateSync);
};
export const sync = async (params: TJsSyncParams, noCache = false): Promise<void> => {
try {
const syncResult = await syncWithBackend(params, noCache);
if (syncResult?.ok !== true) {
logger.error(`Sync failed: ${JSON.stringify(syncResult.error)}`);
throw syncResult.error;
}
@@ -115,8 +118,6 @@ export const sync = async (params: TJsSyncParams, noCache = false): Promise<void
userId: params.userId,
state,
});
// before finding the surveys, check for public use
} catch (error) {
logger.error(`Error during sync: ${error}`);
throw error;
@@ -175,17 +176,23 @@ export const addExpiryCheckListener = (): void => {
// add event listener to check sync with backend on regular interval
if (typeof window !== "undefined" && syncIntervalId === null) {
syncIntervalId = window.setInterval(async () => {
// check if the config has not expired yet
if (config.get().expiresAt && new Date(config.get().expiresAt) >= new Date()) {
return;
try {
// check if the config has not expired yet
if (config.get().expiresAt && new Date(config.get().expiresAt) >= new Date()) {
return;
}
logger.debug("Config has expired. Starting sync.");
await sync({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
userId: config.get().userId,
});
} catch (e) {
logger.error(`Error during expiry check: ${e}`);
logger.debug("Extending config and try again later.");
const existingConfig = config.get();
config.update(existingConfig);
}
logger.debug("Config has expired. Starting sync.");
await sync({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
userId: config.get().userId,
// personId: config.get().state?.person?.id,
});
}, updateInterval);
}
};

View File

@@ -7,23 +7,29 @@ import { TSurvey } from "@formbricks/types/surveys";
import { Config } from "./config";
import { ErrorHandler } from "./errors";
import { putFormbricksInErrorState } from "./initialize";
import { Logger } from "./logger";
import { filterPublicSurveys, sync } from "./sync";
const containerId = "formbricks-web-container";
const config = Config.getInstance();
const logger = Logger.getInstance();
const errorHandler = ErrorHandler.getInstance();
let surveyRunning = false;
let isSurveyRunning = false;
let setIsError = (_: boolean) => {};
let setIsResponseSendingFinished = (_: boolean) => {};
export const setIsSurveyRunning = (value: boolean) => {
isSurveyRunning = value;
};
export const renderWidget = async (survey: TSurvey) => {
if (surveyRunning) {
if (isSurveyRunning) {
logger.debug("A survey is already running. Skipping.");
return;
}
surveyRunning = true;
setIsSurveyRunning(false);
if (survey.delay) {
logger.debug(`Delaying survey by ${survey.delay} seconds.`);
@@ -163,7 +169,7 @@ export const renderWidget = async (survey: TSurvey) => {
export const closeSurvey = async (): Promise<void> => {
// remove container element from DOM
document.getElementById(containerId)?.remove();
removeWidgetContainer();
addWidgetContainer();
// if unidentified user, refilter the surveys
@@ -174,7 +180,7 @@ export const closeSurvey = async (): Promise<void> => {
...config.get(),
state: updatedState,
});
surveyRunning = false;
setIsSurveyRunning(false);
return;
}
@@ -188,9 +194,10 @@ export const closeSurvey = async (): Promise<void> => {
},
true
);
surveyRunning = false;
} catch (e) {
setIsSurveyRunning(false);
} catch (e: any) {
errorHandler.handle(e);
putFormbricksInErrorState();
}
};
@@ -200,6 +207,10 @@ export const addWidgetContainer = (): void => {
document.body.appendChild(containerElement);
};
export const removeWidgetContainer = (): void => {
document.getElementById(containerId)?.remove();
};
const loadFormbricksSurveysExternally = (): Promise<typeof window.formbricksSurveys> => {
const formbricksSurveysScriptSrc = import.meta.env.FORMBRICKS_SURVEYS_SCRIPT_SRC;

View File

@@ -14,9 +14,9 @@
"test": "dotenv -e ../../.env -- vitest run"
},
"dependencies": {
"@aws-sdk/client-s3": "3.521.0",
"@aws-sdk/s3-presigned-post": "3.521.0",
"@aws-sdk/s3-request-presigner": "3.521.0",
"@aws-sdk/client-s3": "3.525.0",
"@aws-sdk/s3-presigned-post": "3.525.0",
"@aws-sdk/s3-request-presigner": "3.525.0",
"@formbricks/api": "*",
"@formbricks/database": "*",
"@formbricks/types": "*",
@@ -29,14 +29,14 @@
"mime-types": "^2.1.35",
"nanoid": "^5.0.6",
"next-auth": "^4.24.6",
"nodemailer": "^6.9.10",
"nodemailer": "^6.9.11",
"posthog-node": "^3.6.3",
"server-only": "^0.0.1",
"tailwind-merge": "^2.2.1"
},
"devDependencies": {
"@formbricks/tsconfig": "*",
"@types/jsonwebtoken": "^9.0.5",
"@types/jsonwebtoken": "^9.0.6",
"@types/mime-types": "^2.1.4",
"dotenv": "^16.4.5",
"eslint-config-formbricks": "workspace:*",

View File

@@ -258,7 +258,7 @@ export const updatePerson = async (personId: string, personInput: TPersonUpdateI
// Now perform the upsert for the attribute with the found or created attributeClassId
await prisma.attribute.upsert({
where: {
attributeClassId_personId: {
personId_attributeClassId: {
attributeClassId: attributeClass!.id,
personId,
},
@@ -391,7 +391,7 @@ export const updatePersonAttribute = async (
const attributes = await prisma.attribute.upsert({
where: {
attributeClassId_personId: {
personId_attributeClassId: {
attributeClassId,
personId,
},

View File

@@ -23,7 +23,7 @@ import { productCache } from "../product/cache";
import { getProductByEnvironmentId } from "../product/service";
import { responseCache } from "../response/cache";
import { segmentCache } from "../segment/cache";
import { evaluateSegment, getSegment, updateSegment } from "../segment/service";
import { createSegment, evaluateSegment, getSegment, updateSegment } from "../segment/service";
import { transformSegmentFiltersToAttributeFilters } from "../segment/utils";
import { subscribeTeamMembersToSurveyResponses } from "../team/service";
import { diffInDays, formatDateFields } from "../utils/datetime";
@@ -100,7 +100,10 @@ const getActionClassIdFromName = (actionClasses: TActionClass[], actionClassName
return actionClasses.find((actionClass) => actionClass.name === actionClassName)!.id;
};
const revalidateSurveyByActionClassId = (actionClasses: TActionClass[], actionClassNames: string[]): void => {
const revalidateSurveyByActionClassName = (
actionClasses: TActionClass[],
actionClassNames: string[]
): void => {
for (const actionClassName of actionClassNames) {
const actionClassId: string = getActionClassIdFromName(actionClasses, actionClassName);
surveyCache.revalidate({
@@ -128,7 +131,7 @@ const processTriggerUpdates = (
// find removed triggers
for (const trigger of currentSurveyTriggers) {
if (!triggers.includes(trigger)) {
removedTriggers.push(getActionClassIdFromName(actionClasses, trigger));
removedTriggers.push(trigger);
}
}
@@ -143,10 +146,12 @@ const processTriggerUpdates = (
if (removedTriggers.length > 0) {
triggersUpdate.deleteMany = {
actionClassId: { in: removedTriggers },
actionClassId: {
in: removedTriggers.map((trigger) => getActionClassIdFromName(actionClasses, trigger)),
},
};
}
revalidateSurveyByActionClassId(actionClasses, [...newTriggers, ...removedTriggers]);
revalidateSurveyByActionClassName(actionClasses, [...newTriggers, ...removedTriggers]);
return triggersUpdate;
};
@@ -438,7 +443,7 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
if (surveyBody.triggers) {
const actionClasses = await getActionClasses(environmentId);
revalidateSurveyByActionClassId(actionClasses, surveyBody.triggers);
revalidateSurveyByActionClassName(actionClasses, surveyBody.triggers);
}
const createdBy = surveyBody.createdBy;
@@ -544,24 +549,67 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string, u
verifyEmail: existingSurvey.verifyEmail
? JSON.parse(JSON.stringify(existingSurvey.verifyEmail))
: Prisma.JsonNull,
segment: existingSurvey.segment ? { connect: { id: existingSurvey.segment.id } } : undefined,
// we'll update the segment later
segment: undefined,
},
});
// if the existing survey has an inline segment, we copy the filters and create a new inline segment and connect it to the new survey
if (existingSurvey.segment) {
if (existingSurvey.segment.isPrivate) {
const newInlineSegment = await createSegment({
environmentId,
title: `${newSurvey.id}`,
isPrivate: true,
surveyId: newSurvey.id,
filters: existingSurvey.segment.filters,
});
await prisma.survey.update({
where: {
id: newSurvey.id,
},
data: {
segment: {
connect: {
id: newInlineSegment.id,
},
},
},
});
segmentCache.revalidate({
id: newInlineSegment.id,
environmentId: newSurvey.environmentId,
});
} else {
await prisma.survey.update({
where: {
id: newSurvey.id,
},
data: {
segment: {
connect: {
id: existingSurvey.segment.id,
},
},
},
});
segmentCache.revalidate({
id: existingSurvey.segment.id,
environmentId: newSurvey.environmentId,
});
}
}
surveyCache.revalidate({
id: newSurvey.id,
environmentId: newSurvey.environmentId,
});
if (newSurvey.segmentId) {
segmentCache.revalidate({
id: newSurvey.segmentId,
environmentId: newSurvey.environmentId,
});
}
// Revalidate surveys by actionClassId
revalidateSurveyByActionClassId(actionClasses, existingSurvey.triggers);
revalidateSurveyByActionClassName(actionClasses, existingSurvey.triggers);
return newSurvey;
};

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/surveys",
"license": "MIT",
"version": "1.6.0",
"version": "1.6.1",
"description": "Formbricks-surveys is a helper library to embed surveys into your application",
"homepage": "https://formbricks.com",
"repository": {
@@ -42,7 +42,7 @@
"@formbricks/tsconfig": "workspace:*",
"@formbricks/types": "workspace:*",
"@preact/preset-vite": "^2.8.1",
"autoprefixer": "^10.4.17",
"autoprefixer": "^10.4.18",
"concurrently": "8.2.2",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "1.10.12",

View File

@@ -49,7 +49,7 @@ export default function CalEmbed({ question, onSuccessfulBooking }: CalEmbedProp
}, [cal, question.calUserName]);
return (
<div className="relative mt-4 max-h-[30vh] overflow-auto">
<div className="relative mt-4 max-h-[33vh] overflow-auto">
<div id="fb-cal-embed" className={cn("rounded-lg border border-slate-200")} />
</div>
);

View File

@@ -37,8 +37,10 @@ export function Survey({
activeQuestionId || (survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id)
);
const [showError, setShowError] = useState(false);
// flag state to store whether response processing has been completed or not
const [isResponseSendingFinished, setIsResponseSendingFinished] = useState(false);
// flag state to store whether response processing has been completed or not, we ignore this check for survey editor preview and link survey preview where getSetIsResponseSendingFinished is undefined
const [isResponseSendingFinished, setIsResponseSendingFinished] = useState(
getSetIsResponseSendingFinished ? false : true
);
const [loadingElement, setLoadingElement] = useState(false);
const [history, setHistory] = useState<string[]>([]);

View File

@@ -7,8 +7,7 @@ import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn, shuffleQuestions } from "@/lib/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { TResponseData } from "@formbricks/types/responses";
import { TResponseTtc } from "@formbricks/types/responses";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyMultipleChoiceMultiQuestion } from "@formbricks/types/surveys";
interface MultipleChoiceMultiProps {
@@ -127,7 +126,7 @@ export default function MultipleChoiceMultiQuestion({
<div className="mt-4">
<fieldset>
<legend className="sr-only">Options</legend>
<div className="bg-survey-bg relative max-h-[30vh] space-y-2 overflow-y-auto rounded-md py-0.5 pr-2">
<div className="bg-survey-bg relative max-h-[33vh] space-y-2 overflow-y-auto rounded-md py-0.5 pr-2">
{questionChoices.map((choice, idx) => (
<label
key={choice.id}

View File

@@ -7,8 +7,7 @@ import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn, shuffleQuestions } from "@/lib/utils";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { TResponseData } from "@formbricks/types/responses";
import { TResponseTtc } from "@formbricks/types/responses";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyMultipleChoiceSingleQuestion } from "@formbricks/types/surveys";
interface MultipleChoiceSingleProps {
@@ -86,7 +85,7 @@ export default function MultipleChoiceSingleQuestion({
<legend className="sr-only">Options</legend>
<div
className="bg-survey-bg relative max-h-[30vh] space-y-2 overflow-y-auto rounded-md py-0.5 pr-2"
className="bg-survey-bg relative max-h-[33vh] space-y-2 overflow-y-auto rounded-md py-0.5 pr-2"
role="radiogroup">
{questionChoices.map((choice, idx) => (
<label

View File

@@ -86,6 +86,7 @@ export default function OpenTextQuestion({
tabIndex={1}
name={question.id}
id={question.id}
step={"any"}
placeholder={question.placeholder}
required={question.required}
value={value ? (value as string) : ""}

View File

@@ -102,7 +102,7 @@ export default function PictureSelectionQuestion({
<div className="mt-4">
<fieldset>
<legend className="sr-only">Options</legend>
<div className="rounded-m bg-survey-bg relative grid max-h-[30vh] grid-cols-2 gap-x-5 gap-y-4 overflow-y-auto">
<div className="rounded-m bg-survey-bg relative grid max-h-[33vh] grid-cols-2 gap-x-5 gap-y-4 overflow-y-auto">
{questionChoices.map((choice, idx) => (
<label
key={choice.id}

View File

@@ -9,7 +9,7 @@
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.10",
"autoprefixer": "^10.4.17",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1"
},

View File

@@ -7,8 +7,8 @@
"clean": "rimraf node_modules dist turbo"
},
"devDependencies": {
"@types/node": "20.11.20",
"@types/react": "18.2.58",
"@types/node": "20.11.24",
"@types/react": "18.2.61",
"@types/react-dom": "18.2.19",
"typescript": "^5.3.3"
},

View File

@@ -78,6 +78,7 @@ export const ZJsConfig = z.object({
userId: z.string().optional(),
state: ZJsState,
expiresAt: z.date(),
status: z.enum(["success", "error"]).optional(),
});
export type TJsConfig = z.infer<typeof ZJsConfig>;
@@ -87,6 +88,8 @@ export const ZJsConfigUpdateInput = z.object({
apiHost: z.string(),
userId: z.string().optional(),
state: ZJsState,
expiresAt: z.date().optional(),
status: z.enum(["success", "error"]).optional(),
});
export type TJsConfigUpdateInput = z.infer<typeof ZJsConfigUpdateInput>;

View File

@@ -8,6 +8,7 @@ interface PathwayOptionProps {
description: string;
loading?: boolean;
onSelect: () => void;
cssId?: string;
children?: React.ReactNode;
}
@@ -24,9 +25,11 @@ export const OptionCard: React.FC<PathwayOptionProps> = ({
children,
onSelect,
loading,
cssId,
}) => (
<div className="relative">
<div
id={cssId}
className={`flex cursor-pointer flex-col items-center justify-center bg-white p-4 hover:scale-105 hover:border-slate-300 ${sizeClasses[size]}`}
onClick={onSelect}
role="button"

View File

@@ -384,7 +384,7 @@ export default function SingleResponseCard({
{response.data[question.id]}
</p>
) : (
<p className="ph-no-capture my-1 font-semibold text-slate-700">
<p className="ph-no-capture my-1 whitespace-pre-line font-semibold text-slate-700">
{response.data[question.id]}
</p>
)

View File

@@ -12,7 +12,7 @@
"@formbricks/types": "workspace:*",
"concurrently": "^8.2.2",
"eslint-config-formbricks": "workspace:*",
"postcss": "^8.4.33",
"postcss": "^8.4.35",
"react": "18.2.0"
},
"dependencies": {
@@ -20,13 +20,13 @@
"@formbricks/lib": "workspace:*",
"@formbricks/surveys": "workspace:*",
"@heroicons/react": "^2.1.1",
"@lexical/code": "^0.13.0",
"@lexical/link": "^0.13.0",
"@lexical/list": "^0.13.0",
"@lexical/markdown": "^0.13.0",
"@lexical/react": "^0.13.0",
"@lexical/rich-text": "^0.13.0",
"@lexical/table": "^0.13.0",
"@lexical/code": "^0.13.1",
"@lexical/link": "^0.13.1",
"@lexical/list": "^0.13.1",
"@lexical/markdown": "^0.13.1",
"@lexical/react": "^0.13.1",
"@lexical/rich-text": "^0.13.1",
"@lexical/table": "^0.13.1",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
@@ -42,9 +42,9 @@
"boring-avatars": "^1.10.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^0.2.0",
"lexical": "^0.13.0",
"lucide-react": "^0.315.0",
"cmdk": "^0.2.1",
"lexical": "^0.13.1",
"lucide-react": "^0.344.0",
"mime": "^4.0.1",
"react-colorful": "^5.6.1",
"react-confetti": "^6.1.0",

2905
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff