mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-26 00:10:21 -06:00
Compare commits
13 Commits
fix/hidden
...
v3.7.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6671d877ad | ||
|
|
2867c95494 | ||
|
|
aa55cec060 | ||
|
|
dfb6c4cd9e | ||
|
|
a9082f66e8 | ||
|
|
bf39b0fbfb | ||
|
|
e347f2179a | ||
|
|
d4f155b6bc | ||
|
|
da001834f5 | ||
|
|
f54352dd82 | ||
|
|
0fba0fae73 | ||
|
|
406ec88515 | ||
|
|
b97957d166 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@formbricks/demo",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next",
|
||||
@@ -12,8 +12,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/js": "workspace:*",
|
||||
"lucide-react": "0.468.0",
|
||||
"next": "15.2.3",
|
||||
"lucide-react": "0.486.0",
|
||||
"next": "15.2.4",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
|
||||
@@ -11,30 +11,30 @@
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint-plugin-react-refresh": "0.4.16",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
"eslint-plugin-react-refresh": "0.4.19",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "3.2.2",
|
||||
"@chromatic-com/storybook": "3.2.6",
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@storybook/addon-a11y": "8.4.7",
|
||||
"@storybook/addon-essentials": "8.4.7",
|
||||
"@storybook/addon-interactions": "8.4.7",
|
||||
"@storybook/addon-links": "8.4.7",
|
||||
"@storybook/addon-onboarding": "8.4.7",
|
||||
"@storybook/blocks": "8.4.7",
|
||||
"@storybook/react": "8.4.7",
|
||||
"@storybook/react-vite": "8.4.7",
|
||||
"@storybook/test": "8.4.7",
|
||||
"@typescript-eslint/eslint-plugin": "8.18.0",
|
||||
"@typescript-eslint/parser": "8.18.0",
|
||||
"@storybook/addon-a11y": "8.6.11",
|
||||
"@storybook/addon-essentials": "8.6.11",
|
||||
"@storybook/addon-interactions": "8.6.11",
|
||||
"@storybook/addon-links": "8.6.11",
|
||||
"@storybook/addon-onboarding": "8.6.11",
|
||||
"@storybook/blocks": "8.6.11",
|
||||
"@storybook/react": "8.6.11",
|
||||
"@storybook/react-vite": "8.6.11",
|
||||
"@storybook/test": "8.6.11",
|
||||
"@typescript-eslint/eslint-plugin": "8.29.0",
|
||||
"@typescript-eslint/parser": "8.29.0",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"esbuild": "0.25.1",
|
||||
"eslint-plugin-storybook": "0.11.1",
|
||||
"esbuild": "0.25.2",
|
||||
"eslint-plugin-storybook": "0.12.0",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "8.4.7",
|
||||
"tsup": "8.3.5",
|
||||
"vite": "6.0.12"
|
||||
"storybook": "8.6.11",
|
||||
"tsup": "8.4.0",
|
||||
"vite": "6.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,12 +47,6 @@ vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
|
||||
vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({
|
||||
NoMobileOverlay: () => <div data-testid="no-mobile-overlay" />,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/post-hog-client", () => ({
|
||||
PHProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="ph-provider">{children}</div>
|
||||
),
|
||||
PostHogPageview: () => <div data-testid="ph-pageview" />,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/toaster-client", () => ({
|
||||
ToasterClient: () => <div data-testid="toaster-client" />,
|
||||
}));
|
||||
@@ -74,8 +68,6 @@ describe("(app) AppLayout", () => {
|
||||
render(element);
|
||||
|
||||
expect(screen.getByTestId("no-mobile-overlay")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("ph-pageview")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("ph-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mock-intercom-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children");
|
||||
|
||||
@@ -105,7 +105,6 @@ describe("RootLayout", () => {
|
||||
console.log("vercel", process.env.VERCEL);
|
||||
|
||||
expect(screen.getByTestId("speed-insights")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("ph-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("sentry-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Child Content");
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { SentryProvider } from "@/app/sentry/SentryProvider";
|
||||
import { PHProvider } from "@/modules/ui/components/post-hog-client";
|
||||
import { TolgeeNextProvider } from "@/tolgee/client";
|
||||
import { getLocale } from "@/tolgee/language";
|
||||
import { getTolgee } from "@/tolgee/server";
|
||||
@@ -7,7 +6,7 @@ import { TolgeeStaticData } from "@tolgee/react";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import { Metadata } from "next";
|
||||
import React from "react";
|
||||
import { IS_POSTHOG_CONFIGURED, SENTRY_DSN } from "@formbricks/lib/constants";
|
||||
import { SENTRY_DSN } from "@formbricks/lib/constants";
|
||||
import "../modules/ui/globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -29,11 +28,9 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
<body className="flex h-dvh flex-col transition-all ease-in-out">
|
||||
{process.env.VERCEL === "1" && <SpeedInsights sampleRate={0.1} />}
|
||||
<SentryProvider sentryDsn={SENTRY_DSN}>
|
||||
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
|
||||
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
|
||||
{children}
|
||||
</TolgeeNextProvider>
|
||||
</PHProvider>
|
||||
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
|
||||
{children}
|
||||
</TolgeeNextProvider>
|
||||
</SentryProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,7 +19,7 @@ export const getFile = async (
|
||||
headers: {
|
||||
"Content-Type": metaData.contentType,
|
||||
"Content-Disposition": "attachment",
|
||||
"Cache-Control": "public, max-age=1200, s-maxage=1200, stale-while-revalidate=300",
|
||||
"Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
|
||||
Vary: "Accept-Encoding",
|
||||
},
|
||||
});
|
||||
@@ -35,10 +35,7 @@ export const getFile = async (
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: signedUrl,
|
||||
"Cache-Control":
|
||||
accessType === "public"
|
||||
? `public, max-age=3600, s-maxage=3600, stale-while-revalidate=300`
|
||||
: `public, max-age=600, s-maxage=3600, stale-while-revalidate=300`,
|
||||
"Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -26,12 +26,15 @@ export const EditMemberships = async ({
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-5 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-1">{t("common.full_name")}</div>
|
||||
<div className="col-span-1 text-center">{t("common.email")}</div>
|
||||
{canDoRoleManagement && <div className="col-span-1 text-center">{t("common.role")}</div>}
|
||||
<div className="col-span-1 text-center">{t("common.status")}</div>
|
||||
<div className="col-span-1"></div>
|
||||
<div className="flex h-12 w-full max-w-full items-center gap-x-4 rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="w-1/2 overflow-hidden">{t("common.full_name")}</div>
|
||||
<div className="w-1/2 overflow-hidden">{t("common.email")}</div>
|
||||
|
||||
{canDoRoleManagement && <div className="min-w-[100px] whitespace-nowrap">{t("common.role")}</div>}
|
||||
|
||||
<div className="min-w-[80px] whitespace-nowrap">{t("common.status")}</div>
|
||||
|
||||
<div className="min-w-[125px] whitespace-nowrap">{t("common.actions")}</div>
|
||||
</div>
|
||||
|
||||
{role && (
|
||||
|
||||
@@ -110,47 +110,46 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{showDeleteButton && (
|
||||
<>
|
||||
<TooltipRenderer tooltipContent={t("common.delete")}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
id="deleteMemberButton"
|
||||
onClick={() => setDeleteMemberModalOpen(true)}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
</>
|
||||
)}
|
||||
<TooltipRenderer tooltipContent={t("common.delete")} shouldRender={!!showDeleteButton}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
id="deleteMemberButton"
|
||||
disabled={!showDeleteButton}
|
||||
onClick={() => setDeleteMemberModalOpen(true)}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
|
||||
{invite && (
|
||||
<>
|
||||
<TooltipRenderer tooltipContent={t("environments.settings.general.share_invite_link")}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
id="shareInviteButton"
|
||||
onClick={() => {
|
||||
handleShareInvite();
|
||||
}}>
|
||||
<ShareIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
<TooltipRenderer
|
||||
tooltipContent={t("environments.settings.general.share_invite_link")}
|
||||
shouldRender={!!invite}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
id="shareInviteButton"
|
||||
disabled={!invite}
|
||||
onClick={() => {
|
||||
handleShareInvite();
|
||||
}}>
|
||||
<ShareIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
|
||||
<TooltipRenderer tooltipContent={t("environments.settings.general.resend_invitation_email")}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
id="resendInviteButton"
|
||||
onClick={() => {
|
||||
handleResendInvite();
|
||||
}}>
|
||||
<SendHorizonalIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
</>
|
||||
)}
|
||||
<TooltipRenderer
|
||||
tooltipContent={t("environments.settings.general.resend_invitation_email")}
|
||||
shouldRender={!!invite}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
id="resendInviteButton"
|
||||
disabled={!invite}
|
||||
onClick={() => {
|
||||
handleResendInvite();
|
||||
}}>
|
||||
<SendHorizonalIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
|
||||
<DeleteDialog
|
||||
open={isDeleteMemberModalOpen}
|
||||
|
||||
@@ -86,20 +86,21 @@ export const MembersInfo = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid-cols-5" id="membersInfoWrapper">
|
||||
<div className="max-w-full space-y-4 px-4 py-3" id="membersInfoWrapper">
|
||||
{allMembers.map((member) => (
|
||||
<div
|
||||
className="singleMemberInfo grid h-auto w-full grid-cols-5 content-center rounded-lg px-4 py-3 text-left text-sm text-slate-900"
|
||||
id="singleMemberInfo"
|
||||
className="flex w-full max-w-full items-center gap-x-4 text-left text-sm text-slate-900"
|
||||
key={member.email}>
|
||||
<div className="ph-no-capture col-span-1 flex flex-col justify-center break-all">
|
||||
<p>{member.name}</p>
|
||||
<div className="ph-no-capture w-1/2 overflow-hidden">
|
||||
<p className="w-full truncate">{member.name}</p>
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-1 flex flex-col justify-center break-all text-center">
|
||||
{member.email}
|
||||
<div className="ph-no-capture w-1/2 overflow-hidden">
|
||||
<p className="w-full truncate"> {member.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="ph-no-capture col-span-1 flex flex-col items-center justify-center break-all">
|
||||
{canDoRoleManagement && allMembers?.length > 0 && (
|
||||
{canDoRoleManagement && allMembers?.length > 0 && (
|
||||
<div className="ph-no-capture min-w-[100px]">
|
||||
<EditMembershipRole
|
||||
currentUserRole={currentUserRole}
|
||||
memberRole={member.role}
|
||||
@@ -111,17 +112,16 @@ export const MembersInfo = ({
|
||||
doesOrgHaveMoreThanOneOwner={doesOrgHaveMoreThanOneOwner}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center justify-center">{getMembershipBadge(member)}</div>
|
||||
<div className="col-span-1 flex items-center justify-end gap-x-4 pr-4">
|
||||
<MemberActions
|
||||
organization={organization}
|
||||
member={!isInvitee(member) ? member : undefined}
|
||||
invite={isInvitee(member) ? member : undefined}
|
||||
showDeleteButton={showDeleteButton(member)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-[80px]">{getMembershipBadge(member)}</div>
|
||||
|
||||
<MemberActions
|
||||
organization={organization}
|
||||
member={!isInvitee(member) ? member : undefined}
|
||||
invite={isInvitee(member) ? member : undefined}
|
||||
showDeleteButton={showDeleteButton(member)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -39,19 +39,23 @@ export const BulkInviteTab = ({
|
||||
}
|
||||
Papa.parse(csvFile, {
|
||||
skipEmptyLines: true,
|
||||
comments: "Full Name,Email Address,Role",
|
||||
complete: (results: ParseResult<string[]>) => {
|
||||
header: true,
|
||||
transformHeader: (header) => {
|
||||
if (header === "Full Name") return "name";
|
||||
if (header === "Email Address") return "email";
|
||||
if (header === "Role") return "role";
|
||||
return header;
|
||||
},
|
||||
complete: (results: ParseResult<{ name: string; email: string; role: string }>) => {
|
||||
const members = results.data.map((csv) => {
|
||||
const [name, email, role] = csv;
|
||||
|
||||
let orgRole = canDoRoleManagement ? role.trim().toLowerCase() : "owner";
|
||||
let orgRole = canDoRoleManagement ? csv.role.trim().toLowerCase() : "owner";
|
||||
if (!isFormbricksCloud) {
|
||||
orgRole = orgRole === "billing" ? "owner" : orgRole;
|
||||
}
|
||||
|
||||
return {
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
name: csv.name.trim(),
|
||||
email: csv.email.trim(),
|
||||
role: orgRole as TOrganizationRole,
|
||||
teamIds: [],
|
||||
};
|
||||
@@ -60,7 +64,6 @@ export const BulkInviteTab = ({
|
||||
ZInvitees.parse(members);
|
||||
onSubmit(members);
|
||||
} catch (err) {
|
||||
console.error(err.message);
|
||||
toast.error(t("environments.settings.general.please_check_csv_file"));
|
||||
}
|
||||
setOpen(false);
|
||||
|
||||
@@ -146,6 +146,10 @@ export const validationRules = {
|
||||
fieldsToValidate = fieldsToValidate.filter((field) => field !== "backButtonLabel");
|
||||
}
|
||||
|
||||
if ((question.type === "nps" || question.type === "rating") && question.required) {
|
||||
fieldsToValidate = fieldsToValidate.filter((field) => field !== "buttonLabel");
|
||||
}
|
||||
|
||||
for (const field of fieldsToValidate) {
|
||||
if (
|
||||
question[field] &&
|
||||
|
||||
@@ -116,6 +116,15 @@ const nextConfig = {
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Strict-Transport-Security",
|
||||
value: "max-age=63072000; includeSubDomains; preload",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// matching all API routes
|
||||
source: "/api/v1/client/:path*",
|
||||
@@ -321,8 +330,6 @@ const sentryConfig = {
|
||||
disableLogger: true,
|
||||
};
|
||||
|
||||
const exportConfig = process.env.SENTRY_DSN
|
||||
? withSentryConfig(nextConfig, sentryOptions)
|
||||
: nextConfig;
|
||||
const exportConfig = process.env.SENTRY_DSN ? withSentryConfig(nextConfig, sentryOptions) : nextConfig;
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -48,7 +48,7 @@ test.describe("Invite, accept and remove organization member", async () => {
|
||||
await test.step("Copy invite Link", async () => {
|
||||
await expect(page.locator("#membersInfoWrapper")).toBeVisible();
|
||||
|
||||
const lastMemberInfo = page.locator("#membersInfoWrapper > .singleMemberInfo:last-child");
|
||||
const lastMemberInfo = page.locator("#membersInfoWrapper > #singleMemberInfo:last-child");
|
||||
await expect(lastMemberInfo).toBeVisible();
|
||||
|
||||
const pendingSpan = lastMemberInfo.locator("span").locator("span").filter({ hasText: "Pending" });
|
||||
@@ -106,7 +106,7 @@ test.describe("Invite, accept and remove organization member", async () => {
|
||||
|
||||
// await expect(page.locator("#membersInfoWrapper")).toBeVisible();
|
||||
|
||||
// const lastMemberInfo = page.locator("#membersInfoWrapper > .singleMemberInfo:last-child");
|
||||
// const lastMemberInfo = page.locator("#membersInfoWrapper > #singleMemberInfo:last-child");
|
||||
// await expect(lastMemberInfo).toBeVisible();
|
||||
|
||||
// const deleteMemberButton = lastMemberInfo.locator("#deleteMemberButton");
|
||||
|
||||
@@ -77,8 +77,8 @@ deployment:
|
||||
limits:
|
||||
memory: 2Gi
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
memory: 1Gi
|
||||
env:
|
||||
DOCKER_CRON_ENABLED:
|
||||
value: "0"
|
||||
|
||||
@@ -31,7 +31,6 @@ class FormbricksViewModel : ViewModel() {
|
||||
|
||||
<head>
|
||||
<title>Formbricks WebView Survey</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
|
||||
<body style="overflow: hidden; height: 100vh; display: flex; flex-direction: column; justify-content: flex-end;">
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { type TAttributeUpdateInput } from "@formbricks/types/attributes";
|
||||
import { type Result } from "@formbricks/types/error-handlers";
|
||||
import { type ApiErrorResponse } from "@formbricks/types/errors";
|
||||
import { makeRequest } from "../../utils/make-request";
|
||||
|
||||
export class AttributeAPI {
|
||||
private appUrl: string;
|
||||
private environmentId: string;
|
||||
private isDebug: boolean;
|
||||
constructor(appUrl: string, environmentId: string, isDebug: boolean) {
|
||||
this.appUrl = appUrl;
|
||||
this.environmentId = environmentId;
|
||||
this.isDebug = isDebug;
|
||||
}
|
||||
|
||||
async update(
|
||||
attributeUpdateInput: Omit<TAttributeUpdateInput, "environmentId">
|
||||
): Promise<Result<{ changed: boolean; message: string; messages?: string[] }, ApiErrorResponse>> {
|
||||
// transform all attributes to string if attributes are present into a new attributes copy
|
||||
const attributes: Record<string, string> = {};
|
||||
for (const key in attributeUpdateInput.attributes) {
|
||||
attributes[key] = String(attributeUpdateInput.attributes[key]);
|
||||
}
|
||||
|
||||
return makeRequest(
|
||||
this.appUrl,
|
||||
`/api/v1/client/${this.environmentId}/contacts/${attributeUpdateInput.userId}/attributes`,
|
||||
"PUT",
|
||||
{ attributes },
|
||||
this.isDebug
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { type TDisplayCreateInput } from "@formbricks/types/displays";
|
||||
import { type Result } from "@formbricks/types/error-handlers";
|
||||
import { type ApiErrorResponse } from "@formbricks/types/errors";
|
||||
import { makeRequest } from "../../utils/make-request";
|
||||
|
||||
export class DisplayAPI {
|
||||
private appUrl: string;
|
||||
private environmentId: string;
|
||||
private isDebug: boolean;
|
||||
|
||||
constructor(appUrl: string, environmentId: string, isDebug: boolean) {
|
||||
this.appUrl = appUrl;
|
||||
this.environmentId = environmentId;
|
||||
this.isDebug = isDebug;
|
||||
}
|
||||
|
||||
async create(
|
||||
displayInput: Omit<TDisplayCreateInput, "environmentId">
|
||||
): Promise<Result<{ id: string }, ApiErrorResponse>> {
|
||||
return makeRequest(
|
||||
this.appUrl,
|
||||
`/api/v1/client/${this.environmentId}/displays`,
|
||||
"POST",
|
||||
displayInput,
|
||||
this.isDebug
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,8 @@
|
||||
import { type ApiConfig } from "../../types";
|
||||
import { AttributeAPI } from "./attribute";
|
||||
import { DisplayAPI } from "./display";
|
||||
import { EnvironmentAPI } from "./environment";
|
||||
import { ResponseAPI } from "./response";
|
||||
import { StorageAPI } from "./storage";
|
||||
import { UserAPI } from "./user";
|
||||
|
||||
export class Client {
|
||||
response: ResponseAPI;
|
||||
display: DisplayAPI;
|
||||
storage: StorageAPI;
|
||||
attribute: AttributeAPI;
|
||||
user: UserAPI;
|
||||
environment: EnvironmentAPI;
|
||||
|
||||
@@ -18,10 +10,6 @@ export class Client {
|
||||
const { appUrl, environmentId, isDebug } = options;
|
||||
const isDebugMode = isDebug ?? false;
|
||||
|
||||
this.response = new ResponseAPI(appUrl, environmentId, isDebugMode);
|
||||
this.display = new DisplayAPI(appUrl, environmentId, isDebugMode);
|
||||
this.attribute = new AttributeAPI(appUrl, environmentId, isDebugMode);
|
||||
this.storage = new StorageAPI(appUrl, environmentId);
|
||||
this.user = new UserAPI(appUrl, environmentId, isDebugMode);
|
||||
this.environment = new EnvironmentAPI(appUrl, environmentId, isDebugMode);
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { type Result } from "@formbricks/types/error-handlers";
|
||||
import { type ApiErrorResponse } from "@formbricks/types/errors";
|
||||
import { type TResponseInput, type TResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { makeRequest } from "../../utils/make-request";
|
||||
|
||||
type TResponseUpdateInputWithResponseId = TResponseUpdateInput & { responseId: string };
|
||||
|
||||
export class ResponseAPI {
|
||||
private appUrl: string;
|
||||
private environmentId: string;
|
||||
private isDebug: boolean;
|
||||
constructor(appUrl: string, environmentId: string, isDebug: boolean) {
|
||||
this.appUrl = appUrl;
|
||||
this.environmentId = environmentId;
|
||||
this.isDebug = isDebug;
|
||||
}
|
||||
|
||||
async create(
|
||||
responseInput: Omit<TResponseInput, "environmentId">
|
||||
): Promise<Result<{ id: string }, ApiErrorResponse>> {
|
||||
return makeRequest(
|
||||
this.appUrl,
|
||||
`/api/v1/client/${this.environmentId}/responses`,
|
||||
"POST",
|
||||
responseInput,
|
||||
this.isDebug
|
||||
);
|
||||
}
|
||||
|
||||
async update({
|
||||
responseId,
|
||||
finished,
|
||||
endingId,
|
||||
data,
|
||||
ttc,
|
||||
variables,
|
||||
language,
|
||||
}: TResponseUpdateInputWithResponseId): Promise<Result<object, ApiErrorResponse>> {
|
||||
return makeRequest(
|
||||
this.appUrl,
|
||||
`/api/v1/client/${this.environmentId}/responses/${responseId}`,
|
||||
"PUT",
|
||||
{
|
||||
finished,
|
||||
endingId,
|
||||
data,
|
||||
ttc,
|
||||
variables,
|
||||
language,
|
||||
},
|
||||
this.isDebug
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
/* eslint-disable no-console -- used for error logging */
|
||||
import type { TUploadFileConfig, TUploadFileResponse } from "@formbricks/types/storage";
|
||||
|
||||
export class StorageAPI {
|
||||
private appUrl: string;
|
||||
private environmentId: string;
|
||||
|
||||
constructor(appUrl: string, environmentId: string) {
|
||||
this.appUrl = appUrl;
|
||||
this.environmentId = environmentId;
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
file: {
|
||||
type: string;
|
||||
name: string;
|
||||
base64: string;
|
||||
},
|
||||
{ allowedFileExtensions, surveyId }: TUploadFileConfig | undefined = {}
|
||||
): Promise<string> {
|
||||
if (!file.name || !file.type || !file.base64) {
|
||||
throw new Error(`Invalid file object`);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
fileName: file.name,
|
||||
fileType: file.type,
|
||||
allowedFileExtensions,
|
||||
surveyId,
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.appUrl}/api/v1/client/${this.environmentId}/storage`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed with status: ${String(response.status)}`);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as TUploadFileResponse;
|
||||
|
||||
const { data } = json;
|
||||
|
||||
const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data;
|
||||
|
||||
let localUploadDetails: Record<string, string> = {};
|
||||
|
||||
if (signingData) {
|
||||
const { signature, timestamp, uuid } = signingData;
|
||||
|
||||
localUploadDetails = {
|
||||
fileType: file.type,
|
||||
fileName: encodeURIComponent(updatedFileName),
|
||||
surveyId: surveyId ?? "",
|
||||
signature,
|
||||
timestamp: String(timestamp),
|
||||
uuid,
|
||||
};
|
||||
}
|
||||
|
||||
const formData: Record<string, string> = {};
|
||||
const formDataForS3 = new FormData();
|
||||
|
||||
if (presignedFields) {
|
||||
Object.entries(presignedFields).forEach(([key, value]) => {
|
||||
formDataForS3.append(key, value);
|
||||
});
|
||||
|
||||
try {
|
||||
const binaryString = atob(file.base64.split(",")[1]);
|
||||
const uint8Array = Uint8Array.from([...binaryString].map((char) => char.charCodeAt(0)));
|
||||
const blob = new Blob([uint8Array], { type: file.type });
|
||||
|
||||
formDataForS3.append("file", blob);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw new Error("Error uploading file");
|
||||
}
|
||||
}
|
||||
|
||||
formData.fileBase64String = file.base64;
|
||||
|
||||
let uploadResponse: Response = {} as Response;
|
||||
|
||||
const signedUrlCopy = signedUrl.replace("http://localhost:3000", this.appUrl);
|
||||
|
||||
try {
|
||||
uploadResponse = await fetch(signedUrlCopy, {
|
||||
method: "POST",
|
||||
body: presignedFields
|
||||
? formDataForS3
|
||||
: JSON.stringify({
|
||||
...formData,
|
||||
...localUploadDetails,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error uploading file", err);
|
||||
}
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
// if local storage is used, we'll use the json response:
|
||||
if (signingData) {
|
||||
const uploadJson = (await uploadResponse.json()) as { message: string };
|
||||
const error = new Error(uploadJson.message);
|
||||
error.name = "FileTooLargeError";
|
||||
throw error;
|
||||
}
|
||||
|
||||
// if s3 is used, we'll use the text response:
|
||||
const errorText = await uploadResponse.text();
|
||||
if (presignedFields && errorText.includes("EntityTooLarge")) {
|
||||
const error = new Error("File size exceeds the size limit for your plan");
|
||||
error.name = "FileTooLargeError";
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(`Upload failed with status: ${String(uploadResponse.status)}`);
|
||||
}
|
||||
|
||||
return fileUrl;
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@ export const makeRequest = async <T>(
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(isDebug && { "Cache-Control": "no-cache" }),
|
||||
},
|
||||
...(isDebug && { cache: "no-store" }),
|
||||
body,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
"clean": "rimraf node_modules .turbo"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@trivago/prettier-plugin-sort-imports": "5.2.0",
|
||||
"prettier": "3.4.2",
|
||||
"prettier-plugin-tailwindcss": "0.6.9",
|
||||
"prettier-plugin-sort-json": "4.0.0"
|
||||
"@trivago/prettier-plugin-sort-imports": "5.2.2",
|
||||
"prettier": "3.5.3",
|
||||
"prettier-plugin-tailwindcss": "0.6.11",
|
||||
"prettier-plugin-sort-json": "4.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ private extension FormbricksViewModel {
|
||||
|
||||
<head>
|
||||
<title>Formbricks WebView Survey</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
|
||||
<body style="overflow: hidden; height: 100vh; display: flex; flex-direction: column; justify-content: flex-end;">
|
||||
|
||||
@@ -73,7 +73,7 @@ const logout = async (): Promise<void> => {
|
||||
* @param properties - Optional properties to set, like the hidden fields (deprecated, hidden fields will be removed in a future version)
|
||||
*/
|
||||
const track = async (code: string, properties?: TTrackProperties): Promise<void> => {
|
||||
queue.add(Action.trackCodeAction, true, code, properties as unknown);
|
||||
queue.add<string | TTrackProperties | undefined>(Action.trackCodeAction, true, code, properties);
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
@@ -96,5 +96,6 @@ const formbricks = {
|
||||
registerRouteChange,
|
||||
};
|
||||
|
||||
export type TFormbricks = typeof formbricks;
|
||||
type TFormbricks = typeof formbricks;
|
||||
export type { TFormbricks };
|
||||
export default formbricks;
|
||||
|
||||
@@ -4,13 +4,13 @@ import { checkSetup } from "@/lib/common/setup";
|
||||
import { wrapThrowsAsync } from "@/lib/common/utils";
|
||||
import type { Result } from "@/types/error";
|
||||
|
||||
export type TCommandQueueCommand = (
|
||||
export type TCommand = (
|
||||
...args: any[]
|
||||
) => Promise<Result<void, unknown>> | Result<void, unknown> | Promise<void>;
|
||||
|
||||
export class CommandQueue {
|
||||
private queue: {
|
||||
command: TCommandQueueCommand;
|
||||
command: TCommand;
|
||||
checkSetup: boolean;
|
||||
commandArgs: any[];
|
||||
}[] = [];
|
||||
@@ -18,7 +18,7 @@ export class CommandQueue {
|
||||
private resolvePromise: (() => void) | null = null;
|
||||
private commandPromise: Promise<void> | null = null;
|
||||
|
||||
public add<A>(command: TCommandQueueCommand, shouldCheckSetup = true, ...args: A[]): void {
|
||||
public add<A>(command: TCommand, shouldCheckSetup = true, ...args: A[]): void {
|
||||
this.queue.push({ command, checkSetup: shouldCheckSetup, commandArgs: args });
|
||||
|
||||
if (!this.running) {
|
||||
|
||||
@@ -156,7 +156,6 @@ export const setup = async (
|
||||
addWidgetContainer();
|
||||
|
||||
if (
|
||||
!isDebug &&
|
||||
existingConfig?.environment &&
|
||||
existingConfig.environmentId === configInput.environmentId &&
|
||||
existingConfig.appUrl === configInput.appUrl
|
||||
@@ -180,7 +179,11 @@ export const setup = async (
|
||||
let environmentState: TEnvironmentState = existingConfig.environment;
|
||||
let userState: TUserState = existingConfig.user;
|
||||
|
||||
if (isEnvironmentStateExpired) {
|
||||
if (isEnvironmentStateExpired || isDebug) {
|
||||
if (isDebug) {
|
||||
logger.debug("Debug mode is active, refetching environment state");
|
||||
}
|
||||
|
||||
const environmentStateResponse = await fetchEnvironmentState({
|
||||
appUrl: configInput.appUrl,
|
||||
environmentId: configInput.environmentId,
|
||||
@@ -202,10 +205,14 @@ export const setup = async (
|
||||
}
|
||||
}
|
||||
|
||||
if (isUserStateExpired) {
|
||||
if (isUserStateExpired || isDebug) {
|
||||
// If the existing person state (expired) has a userId, we need to fetch the person state
|
||||
// If the existing person state (expired) has no userId, we need to set the person state to the default
|
||||
|
||||
if (isDebug) {
|
||||
logger.debug("Debug mode is active, refetching user state");
|
||||
}
|
||||
|
||||
if (userState.data.userId) {
|
||||
const updatesResponse = await sendUpdatesToBackend({
|
||||
appUrl: configInput.appUrl,
|
||||
@@ -231,7 +238,7 @@ export const setup = async (
|
||||
responseMessage: "Unknown error",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
} else if (!isDebug) {
|
||||
userState = DEFAULT_USER_STATE_NO_USER_ID;
|
||||
}
|
||||
}
|
||||
@@ -272,7 +279,6 @@ export const setup = async (
|
||||
throw environmentStateResponse.error;
|
||||
}
|
||||
|
||||
// const personState = DEFAULT_USER_STATE_NO_USER_ID;
|
||||
let userState: TUserState = DEFAULT_USER_STATE_NO_USER_ID;
|
||||
|
||||
if ("userId" in configInput && configInput.userId) {
|
||||
|
||||
@@ -235,16 +235,16 @@ export const handleHiddenFields = (
|
||||
hiddenFields?: TTrackProperties["hiddenFields"]
|
||||
): TTrackProperties["hiddenFields"] => {
|
||||
const logger = Logger.getInstance();
|
||||
const { enabled: enabledHiddenFields, fieldIds: hiddenFieldIds } = hiddenFieldsConfig;
|
||||
const { enabled: enabledHiddenFields, fieldIds: surveyHiddenFieldIds } = hiddenFieldsConfig;
|
||||
|
||||
let hiddenFieldsObject: TTrackProperties["hiddenFields"] = {};
|
||||
|
||||
if (!enabledHiddenFields) {
|
||||
logger.error("Hidden fields are not enabled for this survey");
|
||||
} else if (hiddenFieldIds && hiddenFields) {
|
||||
} else if (surveyHiddenFieldIds && hiddenFields) {
|
||||
const unknownHiddenFields: string[] = [];
|
||||
hiddenFieldsObject = Object.keys(hiddenFields).reduce<TTrackProperties["hiddenFields"]>((acc, key) => {
|
||||
if (hiddenFieldIds.includes(key)) {
|
||||
if (surveyHiddenFieldIds.includes(key)) {
|
||||
acc[key] = hiddenFields[key];
|
||||
} else {
|
||||
unknownHiddenFields.push(key);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { filterSurveys , getIsDebug } from "@/lib/common/utils";
|
||||
import { filterSurveys, getIsDebug } from "@/lib/common/utils";
|
||||
import type { TConfigInput, TEnvironmentState } from "@/types/config";
|
||||
import { type ApiErrorResponse, type Result, err, ok } from "@/types/error";
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ vi.mock("@/lib/common/logger", () => ({
|
||||
// Mock filterSurveys
|
||||
vi.mock("@/lib/common/utils", () => ({
|
||||
filterSurveys: vi.fn(),
|
||||
getIsDebug: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock Config
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock("@/lib/common/logger", () => ({
|
||||
|
||||
vi.mock("@/lib/common/utils", () => ({
|
||||
filterSurveys: vi.fn(),
|
||||
getIsDebug: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/api", () => ({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { filterSurveys , getIsDebug } from "@/lib/common/utils";
|
||||
import { filterSurveys, getIsDebug } from "@/lib/common/utils";
|
||||
import { type TUpdates, type TUserState } from "@/types/config";
|
||||
import { type ApiErrorResponse, type Result, type ResultError, err, ok, okVoid } from "@/types/error";
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type Formbricks from "@formbricks/js-core";
|
||||
import { type TFormbricks as TFormbricksCore } from "@formbricks/js-core";
|
||||
import { loadFormbricksToProxy } from "./lib/load-formbricks";
|
||||
|
||||
type TFormbricks = typeof Formbricks;
|
||||
type TFormbricks = Omit<TFormbricksCore, "track"> & {
|
||||
track: (code: string) => Promise<void>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
formbricks: TFormbricks | undefined;
|
||||
@@ -14,7 +17,7 @@ const formbricksProxyHandler: ProxyHandler<TFormbricks> = {
|
||||
},
|
||||
};
|
||||
|
||||
const formbricks: TFormbricks = new Proxy({} as TFormbricks, formbricksProxyHandler);
|
||||
const formbricks: TFormbricksCore = new Proxy({} as TFormbricks, formbricksProxyHandler);
|
||||
|
||||
// eslint-disable-next-line import/no-default-export -- Required for UMD
|
||||
export default formbricks;
|
||||
|
||||
@@ -174,8 +174,8 @@ export const MANAGEMENT_API_RATE_LIMIT = {
|
||||
};
|
||||
|
||||
export const SHARE_RATE_LIMIT = {
|
||||
interval: 60 * 60, // 60 minutes
|
||||
allowedPerInterval: 100,
|
||||
interval: 60 * 1, // 1 minutes
|
||||
allowedPerInterval: 30,
|
||||
};
|
||||
export const FORGET_PASSWORD_RATE_LIMIT = {
|
||||
interval: 60 * 60, // 60 minutes
|
||||
|
||||
@@ -104,9 +104,6 @@ type TGetSignedUrlResponse =
|
||||
};
|
||||
|
||||
const getS3SignedUrl = async (fileKey: string): Promise<string> => {
|
||||
const [_, accessType] = fileKey.split("/");
|
||||
const expiresIn = accessType === "public" ? 60 * 60 : 10 * 60;
|
||||
|
||||
const getObjectCommand = new GetObjectCommand({
|
||||
Bucket: S3_BUCKET_NAME,
|
||||
Key: fileKey,
|
||||
@@ -114,7 +111,7 @@ const getS3SignedUrl = async (fileKey: string): Promise<string> => {
|
||||
|
||||
try {
|
||||
const s3Client = getS3Client();
|
||||
return await getSignedUrl(s3Client, getObjectCommand, { expiresIn });
|
||||
return await getSignedUrl(s3Client, getObjectCommand, { expiresIn: 30 * 60 });
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -219,7 +219,6 @@ const renderHtml = (options: Partial<SurveyContainerProps> & { appUrl?: string }
|
||||
<meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0">
|
||||
<head>
|
||||
<title>Formbricks WebView Survey</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body style="overflow: hidden; height: 100vh; margin: 0;">
|
||||
</body>
|
||||
|
||||
@@ -135,7 +135,6 @@ export function ConsentQuestion({
|
||||
onClick={() => {
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onSubmit({ [question.id]: value }, updatedTtcObj);
|
||||
onBack();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -107,7 +107,6 @@ export function CTAQuestion({
|
||||
onClick={() => {
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onSubmit({ [question.id]: "" }, updatedTtcObj);
|
||||
onBack();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -943,11 +943,19 @@ export const ZSurvey = z
|
||||
"placeholder",
|
||||
];
|
||||
|
||||
const fieldsToValidate =
|
||||
let fieldsToValidate =
|
||||
questionIndex === 0 || isBackButtonHidden
|
||||
? initialFieldsToValidate
|
||||
: [...initialFieldsToValidate, "backButtonLabel"];
|
||||
|
||||
// Skip buttonLabel validation for required NPS and Rating questions
|
||||
if (
|
||||
(question.type === TSurveyQuestionTypeEnum.NPS || question.type === TSurveyQuestionTypeEnum.Rating) &&
|
||||
question.required
|
||||
) {
|
||||
fieldsToValidate = fieldsToValidate.filter((field) => field !== "buttonLabel");
|
||||
}
|
||||
|
||||
for (const field of fieldsToValidate) {
|
||||
// Skip label validation for consent questions as its called checkbox label
|
||||
if (field === "label" && question.type === TSurveyQuestionTypeEnum.Consent) {
|
||||
|
||||
1289
pnpm-lock.yaml
generated
1289
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user