Compare commits

..

4 Commits

Author SHA1 Message Date
Dhruwang bf6acec10b fix: transaltions 2026-02-04 18:39:05 +05:30
Dhruwang 62e2540511 Merge branch 'main' of https://github.com/formbricks/formbricks into chore-update-hun-20260203 2026-02-04 18:36:52 +05:30
Dhruwang 12bf5b71cf fix: translation check 2026-02-04 18:35:15 +05:30
Balázs Úr f9b84b718c fix: Hungarian translations 2026-02-03 13:17:34 +01:00
100 changed files with 1229 additions and 2654 deletions
+10 -9
View File
@@ -32,20 +32,21 @@ jobs:
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js 22.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
- name: Setup Node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: 22.x
node-version: 18
- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup pnpm
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
with:
version: 9.15.9
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile
- name: Validate translation keys
run: |
+16
View File
@@ -1,4 +1,20 @@
module.exports = {
extends: ["@formbricks/eslint-config/legacy-next.js"],
ignorePatterns: ["**/package.json", "**/tsconfig.json"],
overrides: [
{
files: ["locales/*.json"],
plugins: ["i18n-json"],
rules: {
"i18n-json/identical-keys": [
"error",
{
filePath: require("path").join(__dirname, "locales", "en-US.json"),
checkExtraKeys: false,
checkMissingKeys: true,
},
],
},
},
],
};
+18 -10
View File
@@ -1,4 +1,4 @@
FROM node:24-alpine3.23 AS base
FROM node:22-alpine3.22 AS base
#
## step 1: Prune monorepo
@@ -20,7 +20,7 @@ FROM base AS installer
# Enable corepack and prepare pnpm
RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable
RUN corepack prepare pnpm@10.28.2 --activate
RUN corepack prepare pnpm@9.15.9 --activate
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
@@ -69,14 +69,20 @@ RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=sentry_auth_token \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
# Extract Prisma version
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
#
## step 3: setup production runner
#
FROM base AS runner
# Update npm to latest, then create user
# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
RUN npm install --ignore-scripts -g npm@latest \
RUN npm install --ignore-scripts -g corepack@latest && \
corepack enable
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
# && addgroup --system --gid 1001 nodejs \
&& addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs
@@ -107,13 +113,15 @@ RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./package
COPY --from=installer /app/packages/database/dist ./packages/database/dist
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
# Copy prisma client packages
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
COPY --from=installer /prisma_version.txt .
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
@@ -126,9 +134,7 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
# Install prisma CLI globally for database migrations and fix permissions for nextjs user
RUN npm install --ignore-scripts -g prisma@6 \
&& chown -R nextjs:nextjs /usr/local/lib/node_modules/prisma
RUN npm install -g prisma@6
# Create a startup script to handle the conditional logic
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
@@ -138,8 +144,10 @@ EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
USER nextjs
# Prepare pnpm as the nextjs user to ensure it's available at runtime
# Prepare volumes for uploads and SAML connections
RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
RUN corepack prepare pnpm@9.15.9 --activate && \
mkdir -p /home/nextjs/apps/web/uploads/ && \
mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/uploads/
@@ -25,7 +25,7 @@ const mockProject: TProject = {
},
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
languages: [],
logo: null,
@@ -64,7 +64,7 @@ const mockProject = {
linkSurveyBranding: true,
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
languages: [],
} as unknown as TProject;
@@ -1,314 +0,0 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironmentStateData } from "./data";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
environment: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@/modules/survey/lib/utils", () => ({
transformPrismaSurvey: vi.fn((survey) => survey),
}));
const environmentId = "cjld2cjxh0000qzrmn831i7rn";
const mockEnvironmentData = {
id: environmentId,
type: "production",
appSetupCompleted: true,
project: {
id: "project-123",
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
styling: { allowStyleOverwrite: false },
organization: {
id: "org-123",
billing: {
plan: "free",
limits: { monthly: { responses: 100 } },
},
},
},
actionClasses: [
{
id: "action-1",
type: "code",
name: "Test Action",
key: "test-action",
noCodeConfig: null,
},
],
surveys: [
{
id: "survey-1",
name: "Test Survey",
type: "app",
status: "inProgress",
welcomeCard: { enabled: false },
questions: [],
blocks: null,
variables: [],
showLanguageSwitch: false,
languages: [],
endings: [],
autoClose: null,
styling: null,
recaptcha: { enabled: false },
segment: null,
recontactDays: null,
displayLimit: null,
displayOption: "displayOnce",
hiddenFields: { enabled: false },
isBackButtonHidden: false,
triggers: [],
displayPercentage: null,
delay: 0,
projectOverwrites: null,
},
],
};
describe("getEnvironmentStateData", () => {
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return environment state data when environment exists", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockEnvironmentData as never);
const result = await getEnvironmentStateData(environmentId);
expect(result).toEqual({
environment: {
id: environmentId,
type: "production",
appSetupCompleted: true,
project: {
id: "project-123",
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
styling: { allowStyleOverwrite: false },
},
},
organization: {
id: "org-123",
billing: {
plan: "free",
limits: { monthly: { responses: 100 } },
},
},
surveys: mockEnvironmentData.surveys,
actionClasses: mockEnvironmentData.actionClasses,
});
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
where: { id: environmentId },
select: expect.objectContaining({
id: true,
type: true,
appSetupCompleted: true,
project: expect.any(Object),
actionClasses: expect.any(Object),
surveys: expect.any(Object),
}),
});
});
test("should throw ResourceNotFoundError when environment is not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow("environment");
});
test("should throw ResourceNotFoundError when project is not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: null,
} as never);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw ResourceNotFoundError when organization is not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: {
...mockEnvironmentData.project,
organization: null,
},
} as never);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma database errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Connection failed", {
code: "P2024",
clientVersion: "5.0.0",
});
vi.mocked(prisma.environment.findUnique).mockRejectedValue(prismaError);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalled();
});
test("should rethrow unexpected errors", async () => {
const unexpectedError = new Error("Unexpected error");
vi.mocked(prisma.environment.findUnique).mockRejectedValue(unexpectedError);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow("Unexpected error");
expect(logger.error).toHaveBeenCalled();
});
test("should handle empty surveys array", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
surveys: [],
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.surveys).toEqual([]);
});
test("should handle empty actionClasses array", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
actionClasses: [],
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.actionClasses).toEqual([]);
});
test("should transform surveys using transformPrismaSurvey", async () => {
const multipleSurveys = [
...mockEnvironmentData.surveys,
{
...mockEnvironmentData.surveys[0],
id: "survey-2",
name: "Second Survey",
},
];
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
surveys: multipleSurveys,
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.surveys).toHaveLength(2);
});
test("should correctly map project properties to environment.project", async () => {
const customProject = {
...mockEnvironmentData.project,
recontactDays: 14,
clickOutsideClose: false,
overlay: "dark",
placement: "center",
inAppSurveyBranding: false,
styling: { allowStyleOverwrite: true, brandColor: "#ff0000" },
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: customProject,
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.environment.project).toEqual({
id: "project-123",
recontactDays: 14,
clickOutsideClose: false,
overlay: "dark",
placement: "center",
inAppSurveyBranding: false,
styling: { allowStyleOverwrite: true, brandColor: "#ff0000" },
});
});
test("should validate environmentId input", async () => {
// Invalid CUID should throw validation error
await expect(getEnvironmentStateData("invalid-id")).rejects.toThrow();
});
test("should handle different environment types", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
type: "development",
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.environment.type).toBe("development");
});
test("should handle appSetupCompleted false", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
appSetupCompleted: false,
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.environment.appSetupCompleted).toBe(false);
});
test("should correctly extract organization billing data", async () => {
const customBilling = {
plan: "enterprise",
stripeCustomerId: "cus_123",
limits: {
monthly: { responses: 10000, miu: 50000 },
projects: 100,
},
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: {
...mockEnvironmentData.project,
organization: {
id: "org-enterprise",
billing: customBilling,
},
},
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.organization).toEqual({
id: "org-enterprise",
billing: customBilling,
});
});
});
@@ -54,7 +54,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
id: true,
recontactDays: true,
clickOutsideClose: true,
overlay: true,
darkOverlay: true,
placement: true,
inAppSurveyBranding: true,
styling: true,
@@ -174,7 +174,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
id: environmentData.project.id,
recontactDays: environmentData.project.recontactDays,
clickOutsideClose: environmentData.project.clickOutsideClose,
overlay: environmentData.project.overlay,
darkOverlay: environmentData.project.darkOverlay,
placement: environmentData.project.placement,
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
styling: environmentData.project.styling,
@@ -58,7 +58,7 @@ const mockProject: TJsEnvironmentStateProject = {
inAppSurveyBranding: true,
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
styling: {
allowStyleOverwrite: false,
},
@@ -1,15 +1,13 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
@@ -33,38 +31,6 @@ const handleDatabaseError = (error: Error, url: string, endpoint: string, respon
return responses.internalServerErrorResponse("Unknown error occurred", true);
};
const validateResponse = (
response: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
) => {
// Validate response data against validation rules
const mergedData = {
...response.data,
...responseUpdateInput.data,
};
const isFinished = responseUpdateInput.finished ?? false;
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
isFinished,
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
export const PUT = withV1ApiWrapper({
handler: async ({
req,
@@ -147,11 +113,6 @@ export const PUT = withV1ApiWrapper({
};
}
const validationResult = validateResponse(response, survey, inputValidation.data);
if (validationResult) {
return validationResult;
}
// update response with quota evaluation
let updatedResponse;
try {
@@ -6,14 +6,12 @@ import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
@@ -35,27 +33,6 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) => {
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
const params = await props.params;
@@ -146,11 +123,6 @@ export const POST = withV1ApiWrapper({
};
}
const validationResult = validateResponse(responseInputData, survey);
if (validationResult) {
return validationResult;
}
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInput["meta"] = {
+1 -1
View File
@@ -1,4 +1,4 @@
import { ImageResponse } from "next/og";
import { ImageResponse } from "@vercel/og";
import { NextRequest } from "next/server";
export const GET = async (req: NextRequest) => {
@@ -8,7 +8,10 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { sendToPipeline } from "@/app/lib/pipelines";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import {
formatValidationErrorsForV1Api,
validateResponseData,
} from "@/modules/api/v2/management/responses/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
@@ -146,7 +149,6 @@ export const PUT = withV1ApiWrapper({
result.survey.blocks,
responseUpdate.data,
responseUpdate.language ?? "en",
responseUpdate.finished,
result.survey.questions
);
@@ -7,7 +7,10 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import {
formatValidationErrorsForV1Api,
validateResponseData,
} from "@/modules/api/v2/management/responses/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import {
@@ -155,7 +158,6 @@ export const POST = withV1ApiWrapper({
surveyResult.survey.blocks,
responseInput.data,
responseInput.language ?? "en",
responseInput.finished,
surveyResult.survey.questions
);
@@ -11,7 +11,6 @@ import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -107,23 +106,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
);
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);
if (validationErrors) {
return responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
);
}
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInputV2["meta"] = {
+3 -3
View File
@@ -258,7 +258,6 @@ checksums:
common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b
common/no_code: f602144ab7d28a5b19a446bf74b4dcc4
common/no_files_uploaded: c97be829e195a41b2f6b6717b87a232b
common/no_overlay: 03cde9e91f08e4dd539d788e1e01407f
common/no_quotas_found: 19dea6bcc39b579351073b3974990cb6
common/no_result_found: fedddbc0149972ea072a9e063198a16d
common/no_results: 0e9b73265c6542240f5a3bf6b43e9280
@@ -285,7 +284,6 @@ checksums:
common/organization_teams_not_found: ce29fcb7a4e8b4582f92b65dea9b7d4e
common/other: 79acaa6cd481262bea4e743a422529d2
common/others: 39160224ce0e35eb4eb252c997edf4d8
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
common/password: 223a61cf906ab9c40d22612c588dff48
common/paused: edb1f7b7219e1c9b7aa67159090d6991
@@ -931,7 +929,7 @@ checksums:
environments/settings/general/from_your_organization: 4b7970431edb3d0f13c394dbd755a055
environments/settings/general/invitation_sent_once_more: e6e5ea066810f9dcb65788aa4f05d6e2
environments/settings/general/invite_deleted_successfully: 1c7dca6d0f6870d945288e38cfd2f943
environments/settings/general/invite_expires_on: 6fd2356ad91a5f189070c43855904bb4
environments/settings/general/invited_on: 83476ce4bcdfc3ccf524d1cd91b758a8
environments/settings/general/invites_failed: 180ffb8db417050227cc2b2ea74b7aae
environments/settings/general/leave_organization: e74132cb4a0dc98c41e61ea3b2dd268b
environments/settings/general/leave_organization_description: 2d0cd65e4e78a9b2835cf88c4de407fb
@@ -1152,6 +1150,7 @@ checksums:
environments/surveys/edit/caution_explanation_responses_are_safe: 090ff00b7922a49c273e67c5f364730d
environments/surveys/edit/caution_recommendation: b15090fe878ff17f2ee7cc2082dd9018
environments/surveys/edit/caution_text: 3291e962c0e4c4656832837ddc512918
environments/surveys/edit/centered_modal_overlay_color: 1124ba61ee2ecb18a7175ff780dc3b60
environments/surveys/edit/change_anyway: 6377497d40373f6d0f082670194981ab
environments/surveys/edit/change_background: fa71a993869f7d3ac553c547c12c3e9b
environments/surveys/edit/change_question_type: 2d555ae48df8dbedfc6a4e1ad492f4aa
@@ -1942,6 +1941,7 @@ checksums:
environments/workspace/look/add_background_color_description: adb6fcb392862b3d0e9420d9b5405ddb
environments/workspace/look/app_survey_placement: f09cddac6bbb77d4694df223c6edf6b6
environments/workspace/look/app_survey_placement_settings_description: d81bcff7a866a2f83ff76936dbad4770
environments/workspace/look/centered_modal_overlay_color: 1124ba61ee2ecb18a7175ff780dc3b60
environments/workspace/look/email_customization: ae399f381183a4fe0ffd41ab496b5d8f
environments/workspace/look/email_customization_description: 5ccaf1769b2c39d7e87f3a08d056a374
environments/workspace/look/enable_custom_styling: 4774d8fb009c27044aa0191ebcccdcc2
+9 -9
View File
@@ -48,7 +48,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -106,7 +106,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -171,7 +171,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -196,7 +196,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -250,7 +250,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -324,7 +324,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -378,7 +378,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -403,7 +403,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -448,7 +448,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
+1 -1
View File
@@ -22,7 +22,7 @@ const selectProject = {
config: true,
placement: true,
clickOutsideClose: true,
overlay: true,
darkOverlay: true,
environments: true,
styling: true,
logo: true,
+1 -1
View File
@@ -85,7 +85,7 @@ export const mockProject: TProject = {
inAppSurveyBranding: false,
placement: "bottomRight",
clickOutsideClose: false,
overlay: "none",
darkOverlay: false,
environments: [],
languages: [],
config: {
+3 -3
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "Kein Hintergrundbild gefunden.",
"no_code": "No Code",
"no_files_uploaded": "Keine Dateien hochgeladen",
"no_overlay": "Kein Overlay",
"no_quotas_found": "Keine Kontingente gefunden",
"no_result_found": "Kein Ergebnis gefunden",
"no_results": "Keine Ergebnisse",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Organisations-Teams nicht gefunden",
"other": "Andere",
"others": "Andere",
"overlay_color": "Overlay-Farbe",
"overview": "Überblick",
"password": "Passwort",
"paused": "Pausiert",
@@ -992,7 +990,7 @@
"from_your_organization": "von deiner Organisation",
"invitation_sent_once_more": "Einladung nochmal gesendet.",
"invite_deleted_successfully": "Einladung erfolgreich gelöscht",
"invite_expires_on": "Einladung läuft ab am {date}",
"invited_on": "Eingeladen am {date}",
"invites_failed": "Einladungen fehlgeschlagen",
"leave_organization": "Organisation verlassen",
"leave_organization_description": "Du wirst diese Organisation verlassen und den Zugriff auf alle Umfragen und Antworten verlieren. Du kannst nur wieder beitreten, wenn Du erneut eingeladen wirst.",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "Ältere und neuere Antworten vermischen sich, was zu irreführenden Datensummen führen kann.",
"caution_recommendation": "Dies kann im Umfrageübersicht zu Dateninkonsistenzen führen. Wir empfehlen stattdessen, die Umfrage zu duplizieren.",
"caution_text": "Änderungen werden zu Inkonsistenzen führen",
"centered_modal_overlay_color": "Zentrierte modale Überlagerungsfarbe",
"change_anyway": "Trotzdem ändern",
"change_background": "Hintergrund ändern",
"change_question_type": "Fragetyp ändern",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Füge dem Logo-Container eine Hintergrundfarbe hinzu.",
"app_survey_placement": "Platzierung der App-Umfrage",
"app_survey_placement_settings_description": "Ändere, wo Umfragen in deiner Web-App oder Website angezeigt werden.",
"centered_modal_overlay_color": "Zentrierte modale Überlagerungsfarbe",
"email_customization": "E-Mail-Anpassung",
"email_customization_description": "Ändere das Aussehen und die Gestaltung von E-Mails, die Formbricks in deinem Namen versendet.",
"enable_custom_styling": "Benutzerdefiniertes Styling aktivieren",
+7 -7
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "No background image found.",
"no_code": "No code",
"no_files_uploaded": "No files were uploaded",
"no_overlay": "No overlay",
"no_quotas_found": "No quotas found",
"no_result_found": "No result found",
"no_results": "No results",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Organization teams not found",
"other": "Other",
"others": "Others",
"overlay_color": "Overlay color",
"overview": "Overview",
"password": "Password",
"paused": "Paused",
@@ -992,7 +990,7 @@
"from_your_organization": "from your organization",
"invitation_sent_once_more": "Invitation sent once more.",
"invite_deleted_successfully": "Invite deleted successfully",
"invite_expires_on": "Invite expires on {date}",
"invited_on": "Invited on {date}",
"invites_failed": "Invites failed",
"leave_organization": "Leave organization",
"leave_organization_description": "You wil leave this organization and loose access to all surveys and responses. You can only rejoin if you are invited again.",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "Older and newer responses get mixed which can lead to misleading data summaries.",
"caution_recommendation": "This may cause data inconsistencies in the survey summary. We recommend duplicating the survey instead.",
"caution_text": "Changes will lead to inconsistencies",
"centered_modal_overlay_color": "Centered modal overlay color",
"change_anyway": "Change anyway",
"change_background": "Change background",
"change_question_type": "Change question type",
@@ -1269,13 +1268,14 @@
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
"date_format": "Date format",
"days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.",
"display_type": "Display type",
"dropdown": "Dropdown",
"delete_anyways": "Delete anyways",
"delete_block": "Delete block",
"delete_choice": "Delete choice",
"disable_the_visibility_of_survey_progress": "Disable the visibility of survey progress.",
"display_an_estimate_of_completion_time_for_survey": "Display an estimate of completion time for survey",
"display_number_of_responses_for_survey": "Display number of responses for survey",
"display_type": "Display type",
"divide": "Divide /",
"does_not_contain": "Does not contain",
"does_not_end_with": "Does not end with",
@@ -1283,7 +1283,6 @@
"does_not_include_all_of": "Does not include all of",
"does_not_include_one_of": "Does not include one of",
"does_not_start_with": "Does not start with",
"dropdown": "Dropdown",
"duplicate_block": "Duplicate block",
"duplicate_question": "Duplicate question",
"edit_link": "Edit link",
@@ -1416,11 +1415,11 @@
"limit_the_maximum_file_size": "Limit the maximum file size for uploads.",
"limit_upload_file_size_to": "Limit upload file size to",
"link_survey_description": "Share a link to a survey page or embed it in a web page or email.",
"list": "List",
"load_segment": "Load segment",
"logic_error_warning": "Changing will cause logic errors",
"logic_error_warning_text": "Changing the question type will remove the logic conditions from this question",
"logo_settings": "Logo settings",
"list": "List",
"long_answer": "Long answer",
"long_answer_toggle_description": "Allow respondents to write longer, multi-line answers.",
"lower_label": "Lower Label",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Add a background color to the logo container.",
"app_survey_placement": "App Survey Placement",
"app_survey_placement_settings_description": "Change where surveys will be shown in your web app or website.",
"centered_modal_overlay_color": "Centered modal overlay color",
"email_customization": "Email Customization",
"email_customization_description": "Change the look and feel of emails Formbricks sends out on your behalf.",
"enable_custom_styling": "Enable custom styling",
@@ -3095,4 +3095,4 @@
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
"usability_score_name": "System Usability Score (SUS)"
}
}
}
+3 -3
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "No se encontró imagen de fondo.",
"no_code": "Sin código",
"no_files_uploaded": "No se subieron archivos",
"no_overlay": "Sin superposición",
"no_quotas_found": "No se encontraron cuotas",
"no_result_found": "No se encontró resultado",
"no_results": "Sin resultados",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Equipos de la organización no encontrados",
"other": "Otro",
"others": "Otros",
"overlay_color": "Color de superposición",
"overview": "Resumen",
"password": "Contraseña",
"paused": "Pausado",
@@ -992,7 +990,7 @@
"from_your_organization": "de tu organización",
"invitation_sent_once_more": "Invitación enviada una vez más.",
"invite_deleted_successfully": "Invitación eliminada correctamente",
"invite_expires_on": "La invitación expira el {date}",
"invited_on": "Invitado el {date}",
"invites_failed": "Las invitaciones fallaron",
"leave_organization": "Abandonar organización",
"leave_organization_description": "Abandonarás esta organización y perderás acceso a todas las encuestas y respuestas. Solo podrás volver a unirte si te invitan de nuevo.",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "Las respuestas antiguas y nuevas se mezclan, lo que puede llevar a resúmenes de datos engañosos.",
"caution_recommendation": "Esto puede causar inconsistencias de datos en el resumen de la encuesta. Recomendamos duplicar la encuesta en su lugar.",
"caution_text": "Los cambios provocarán inconsistencias",
"centered_modal_overlay_color": "Color de superposición del modal centrado",
"change_anyway": "Cambiar de todos modos",
"change_background": "Cambiar fondo",
"change_question_type": "Cambiar tipo de pregunta",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Añade un color de fondo al contenedor del logotipo.",
"app_survey_placement": "Ubicación de encuesta de aplicación",
"app_survey_placement_settings_description": "Cambia dónde se mostrarán las encuestas en tu aplicación web o sitio web.",
"centered_modal_overlay_color": "Color de superposición del modal centrado",
"email_customization": "Personalización de correo electrónico",
"email_customization_description": "Cambia el aspecto de los correos electrónicos que Formbricks envía en tu nombre.",
"enable_custom_styling": "Habilitar estilo personalizado",
+3 -3
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "Aucune image de fond trouvée.",
"no_code": "Sans code",
"no_files_uploaded": "Aucun fichier n'a été téléchargé.",
"no_overlay": "Aucune superposition",
"no_quotas_found": "Aucun quota trouvé",
"no_result_found": "Aucun résultat trouvé",
"no_results": "Aucun résultat",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Équipes d'organisation non trouvées",
"other": "Autre",
"others": "Autres",
"overlay_color": "Couleur de superposition",
"overview": "Aperçu",
"password": "Mot de passe",
"paused": "En pause",
@@ -992,7 +990,7 @@
"from_your_organization": "de votre organisation",
"invitation_sent_once_more": "Invitation envoyée une fois de plus.",
"invite_deleted_successfully": "Invitation supprimée avec succès",
"invite_expires_on": "L'invitation expire le {date}",
"invited_on": "Invité le {date}",
"invites_failed": "Invitations échouées",
"leave_organization": "Quitter l'organisation",
"leave_organization_description": "Vous quitterez cette organisation et perdrez l'accès à toutes les enquêtes et réponses. Vous ne pourrez revenir que si vous êtes de nouveau invité.",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "Les réponses anciennes et nouvelles se mélangent, ce qui peut entraîner des résumés de données trompeurs.",
"caution_recommendation": "Cela peut entraîner des incohérences de données dans le résumé du sondage. Nous recommandons de dupliquer le sondage à la place.",
"caution_text": "Les changements entraîneront des incohérences.",
"centered_modal_overlay_color": "Couleur de superposition modale centrée",
"change_anyway": "Changer de toute façon",
"change_background": "Changer l'arrière-plan",
"change_question_type": "Changer le type de question",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Ajoutez une couleur d'arrière-plan au conteneur du logo.",
"app_survey_placement": "Placement du sondage d'application",
"app_survey_placement_settings_description": "Modifiez l'emplacement où les sondages seront affichés dans votre application web ou site web.",
"centered_modal_overlay_color": "Couleur de superposition modale centrée",
"email_customization": "Personnalisation des e-mails",
"email_customization_description": "Modifiez l'apparence des e-mails que Formbricks envoie en votre nom.",
"enable_custom_styling": "Activer le style personnalisé",
+3 -3
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "Nem található háttérkép.",
"no_code": "Kód nélkül",
"no_files_uploaded": "Nem lettek fájlok feltöltve",
"no_overlay": "Nincs átfedés",
"no_quotas_found": "Nem találhatók kvóták",
"no_result_found": "Nem található eredmény",
"no_results": "Nincs találat",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "A szervezeti csapatok nem találhatók",
"other": "Egyéb",
"others": "Egyebek",
"overlay_color": "Átfedés színe",
"overview": "Áttekintés",
"password": "Jelszó",
"paused": "Szüneteltetve",
@@ -992,7 +990,7 @@
"from_your_organization": "a szervezetétől",
"invitation_sent_once_more": "A meghívó még egyszer elküldve.",
"invite_deleted_successfully": "A meghívó sikeresen törölve",
"invite_expires_on": "A meghívó lejár: {date}",
"invited_on": "Meghívva ekkor: {date}",
"invites_failed": "A meghívás sikertelen",
"leave_organization": "Szervezet elhagyása",
"leave_organization_description": "Elhagyja ezt a szervezetet, és elveszíti az összes kérdőívhez és válaszhoz való hozzáférését. Csak akkor tud ismét csatlakozni, ha újra meghívják.",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "A régebbi és az újabb válaszok összekeverednek, ami félrevezető adatösszegzésekhez vezethet.",
"caution_recommendation": "Ez adatellentmondásokat okozhat a kérdőív összegzésében. Azt javasoljuk, hogy inkább kettőzze meg a kérdőívet.",
"caution_text": "A változtatások következetlenségekhez vezetnek",
"centered_modal_overlay_color": "Középre helyezett kizárólagos rátét színe",
"change_anyway": "Változtatás mindenképp",
"change_background": "Háttér megváltoztatása",
"change_question_type": "Kérdés típusának megváltoztatása",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Hátérszín hozzáadása a logó tárolódobozához.",
"app_survey_placement": "Alkalmazás-kérdőív elhelyezése",
"app_survey_placement_settings_description": "Annak megváltoztatása, hogy a kérdőívek hol jelennek meg a webalkalmazásban vagy a webhelyen.",
"centered_modal_overlay_color": "Középre helyezett kizárólagos rátét színe",
"email_customization": "E-mail személyre szabás",
"email_customization_description": "Azon e-mailek megjelenésének megváltoztatása, amelyeket a Formbricks az Ön nevében küld ki.",
"enable_custom_styling": "Egyéni stílus engedélyezése",
+3 -3
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "背景画像が見つかりません。",
"no_code": "ノーコード",
"no_files_uploaded": "ファイルがアップロードされていません",
"no_overlay": "オーバーレイなし",
"no_quotas_found": "クォータが見つかりません",
"no_result_found": "結果が見つかりません",
"no_results": "結果なし",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "組織のチームが見つかりません",
"other": "その他",
"others": "その他",
"overlay_color": "オーバーレイの色",
"overview": "概要",
"password": "パスワード",
"paused": "一時停止",
@@ -992,7 +990,7 @@
"from_your_organization": "あなたの組織から",
"invitation_sent_once_more": "招待状を再度送信しました。",
"invite_deleted_successfully": "招待を正常に削除しました",
"invite_expires_on": "招待は{date}に期限切れ",
"invited_on": "{date}に招待",
"invites_failed": "招待に失敗しました",
"leave_organization": "組織を離れる",
"leave_organization_description": "この組織を離れ、すべてのフォームと回答へのアクセス権を失います。再度招待された場合にのみ再参加できます。",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "古い回答と新しい回答が混ざり、データの概要が誤解を招く可能性があります。",
"caution_recommendation": "これにより、フォームの概要にデータの不整合が生じる可能性があります。代わりにフォームを複製することをお勧めします。",
"caution_text": "変更は不整合を引き起こします",
"centered_modal_overlay_color": "中央モーダルのオーバーレイ色",
"change_anyway": "とにかく変更",
"change_background": "背景を変更",
"change_question_type": "質問の種類を変更",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "ロゴコンテナに背景色を追加します。",
"app_survey_placement": "アプリ内フォームの配置",
"app_survey_placement_settings_description": "Webアプリまたはウェブサイトでフォームを表示する場所を変更します。",
"centered_modal_overlay_color": "中央モーダルのオーバーレイ色",
"email_customization": "メールのカスタマイズ",
"email_customization_description": "Formbricksがあなたに代わって送信するメールの外観を変更します。",
"enable_custom_styling": "カスタムスタイルを有効化",
+3 -3
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "Geen achtergrondafbeelding gevonden.",
"no_code": "Geen code",
"no_files_uploaded": "Er zijn geen bestanden geüpload",
"no_overlay": "Geen overlay",
"no_quotas_found": "Geen quota gevonden",
"no_result_found": "Geen resultaat gevonden",
"no_results": "Geen resultaten",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Organisatieteams niet gevonden",
"other": "Ander",
"others": "Anderen",
"overlay_color": "Overlaykleur",
"overview": "Overzicht",
"password": "Wachtwoord",
"paused": "Gepauzeerd",
@@ -992,7 +990,7 @@
"from_your_organization": "vanuit uw organisatie",
"invitation_sent_once_more": "Uitnodiging nogmaals verzonden.",
"invite_deleted_successfully": "Uitnodiging succesvol verwijderd",
"invite_expires_on": "Uitnodiging verloopt op {date}",
"invited_on": "Uitgenodigd op {date}",
"invites_failed": "Uitnodigingen zijn mislukt",
"leave_organization": "Verlaat de organisatie",
"leave_organization_description": "U verlaat deze organisatie en verliest de toegang tot alle enquêtes en reacties. Je kunt alleen weer meedoen als je opnieuw wordt uitgenodigd.",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "Oudere en nieuwere antwoorden lopen door elkaar heen, wat kan leiden tot misleidende gegevenssamenvattingen.",
"caution_recommendation": "Dit kan inconsistenties in de gegevens in de onderzoekssamenvatting veroorzaken. Wij raden u aan de enquête te dupliceren.",
"caution_text": "Veranderingen zullen tot inconsistenties leiden",
"centered_modal_overlay_color": "Gecentreerde modale overlaykleur",
"change_anyway": "Hoe dan ook veranderen",
"change_background": "Achtergrond wijzigen",
"change_question_type": "Vraagtype wijzigen",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Voeg een achtergrondkleur toe aan de logocontainer.",
"app_survey_placement": "App-enquête plaatsing",
"app_survey_placement_settings_description": "Wijzig waar enquêtes worden weergegeven in uw web-app of website.",
"centered_modal_overlay_color": "Gecentreerde modale overlaykleur",
"email_customization": "E-mail aanpassing",
"email_customization_description": "Wijzig het uiterlijk van e-mails die Formbricks namens u verstuurt.",
"enable_custom_styling": "Aangepaste styling inschakelen",
+3 -3
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "Imagem de fundo não encontrada.",
"no_code": "Sem código",
"no_files_uploaded": "Nenhum arquivo foi enviado",
"no_overlay": "Sem sobreposição",
"no_quotas_found": "Nenhuma cota encontrada",
"no_result_found": "Nenhum resultado encontrado",
"no_results": "Nenhum resultado",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Equipes da organização não encontradas",
"other": "outro",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão Geral",
"password": "Senha",
"paused": "Pausado",
@@ -992,7 +990,7 @@
"from_your_organization": "da sua organização",
"invitation_sent_once_more": "Convite enviado de novo.",
"invite_deleted_successfully": "Convite deletado com sucesso",
"invite_expires_on": "O convite expira em {date}",
"invited_on": "Convidado em {date}",
"invites_failed": "Convites falharam",
"leave_organization": "Sair da organização",
"leave_organization_description": "Você vai sair dessa organização e perder acesso a todas as pesquisas e respostas. Você só pode voltar se for convidado de novo.",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "Respostas antigas e novas são misturadas, o que pode levar a resumos de dados enganosos.",
"caution_recommendation": "Isso pode causar inconsistências de dados no resumo da pesquisa. Recomendamos duplicar a pesquisa em vez disso.",
"caution_text": "Mudanças vão levar a inconsistências",
"centered_modal_overlay_color": "cor de sobreposição modal centralizada",
"change_anyway": "Mudar mesmo assim",
"change_background": "Mudar fundo",
"change_question_type": "Mudar tipo de pergunta",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Adicione uma cor de fundo ao container do logo.",
"app_survey_placement": "Posicionamento da pesquisa de app",
"app_survey_placement_settings_description": "Altere onde as pesquisas serão exibidas em seu aplicativo web ou site.",
"centered_modal_overlay_color": "Cor de sobreposição modal centralizada",
"email_customization": "Personalização de e-mail",
"email_customization_description": "Altere a aparência dos e-mails que o Formbricks envia em seu nome.",
"enable_custom_styling": "Habilitar estilização personalizada",
+3 -3
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "Nenhuma imagem de fundo encontrada.",
"no_code": "Sem código",
"no_files_uploaded": "Nenhum ficheiro foi carregado",
"no_overlay": "Sem sobreposição",
"no_quotas_found": "Nenhum quota encontrado",
"no_result_found": "Nenhum resultado encontrado",
"no_results": "Nenhum resultado",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Equipas da organização não encontradas",
"other": "Outro",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão geral",
"password": "Palavra-passe",
"paused": "Em pausa",
@@ -992,7 +990,7 @@
"from_your_organization": "da sua organização",
"invitation_sent_once_more": "Convite enviado mais uma vez.",
"invite_deleted_successfully": "Convite eliminado com sucesso",
"invite_expires_on": "O convite expira em {date}",
"invited_on": "Convidado em {date}",
"invites_failed": "Convites falharam",
"leave_organization": "Sair da organização",
"leave_organization_description": "Vai sair desta organização e perder o acesso a todos os inquéritos e respostas. Só pode voltar a juntar-se se for convidado novamente.",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "As respostas mais antigas e mais recentes se misturam, o que pode levar a resumos de dados enganosos.",
"caution_recommendation": "Isso pode causar inconsistências de dados no resumo do inquérito. Recomendamos duplicar o inquérito em vez disso.",
"caution_text": "As alterações levarão a inconsistências",
"centered_modal_overlay_color": "Cor da sobreposição modal centralizada",
"change_anyway": "Alterar mesmo assim",
"change_background": "Alterar fundo",
"change_question_type": "Alterar tipo de pergunta",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Adicione uma cor de fundo ao contentor do logótipo.",
"app_survey_placement": "Colocação do inquérito (app)",
"app_survey_placement_settings_description": "Altere onde os inquéritos serão apresentados na sua aplicação web ou website.",
"centered_modal_overlay_color": "Cor da sobreposição modal centralizada",
"email_customization": "Personalização de e-mail",
"email_customization_description": "Altere a aparência dos e-mails que a Formbricks envia em seu nome.",
"enable_custom_styling": "Ativar estilização personalizada",
+3 -3
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "Nu a fost găsită nicio imagine de fundal.",
"no_code": "Fără Cod",
"no_files_uploaded": "Nu au fost încărcate fișiere",
"no_overlay": "Fără overlay",
"no_quotas_found": "Nicio cotă găsită",
"no_result_found": "Niciun rezultat găsit",
"no_results": "Nicio rezultat",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Echipele organizației nu au fost găsite",
"other": "Altele",
"others": "Altele",
"overlay_color": "Culoare overlay",
"overview": "Prezentare generală",
"password": "Parolă",
"paused": "Pauză",
@@ -992,7 +990,7 @@
"from_your_organization": "din organizația ta",
"invitation_sent_once_more": "Invitație trimisă din nou.",
"invite_deleted_successfully": "Invitație ștearsă cu succes",
"invite_expires_on": "Invitația expiră pe {date}",
"invited_on": "Invitat pe {date}",
"invites_failed": "Invitații eșuate",
"leave_organization": "Părăsește organizația",
"leave_organization_description": "Vei părăsi această organizație și vei pierde accesul la toate sondajele și răspunsurile. Poți să te alături din nou doar dacă ești invitat.",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "Răspunsurile mai vechi și mai noi se amestecă, ceea ce poate duce la rezumate de date înșelătoare.",
"caution_recommendation": "Aceasta poate cauza inconsistențe de date în rezultatul sondajului. Vă recomandăm să duplicați sondajul în schimb.",
"caution_text": "Schimbările vor duce la inconsecvențe",
"centered_modal_overlay_color": "Culoare suprapunere modală centralizată",
"change_anyway": "Schimbă oricum",
"change_background": "Schimbați fundalul",
"change_question_type": "Schimbă tipul întrebării",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Adăugați o culoare de fundal la containerul siglei.",
"app_survey_placement": "Amplasarea sondajului în aplicație",
"app_survey_placement_settings_description": "Schimbați unde vor fi afișate sondajele în aplicația sau site-ul dvs. web.",
"centered_modal_overlay_color": "Culoare suprapunere modală centralizată",
"email_customization": "Personalizare email",
"email_customization_description": "Schimbați aspectul și stilul emailurilor trimise de Formbricks în numele dvs.",
"enable_custom_styling": "Activați stilizarea personalizată",
+3 -3
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "Фоновое изображение не найдено.",
"no_code": "Нет кода",
"no_files_uploaded": "Файлы не были загружены",
"no_overlay": "Без наложения",
"no_quotas_found": "Квоты не найдены",
"no_result_found": "Результат не найден",
"no_results": "Нет результатов",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Команды организации не найдены",
"other": "Другое",
"others": "Другие",
"overlay_color": "Цвет наложения",
"overview": "Обзор",
"password": "Пароль",
"paused": "Приостановлено",
@@ -992,7 +990,7 @@
"from_your_organization": "из вашей организации",
"invitation_sent_once_more": "Приглашение отправлено ещё раз.",
"invite_deleted_successfully": "Приглашение успешно удалено",
"invite_expires_on": "Приглашение истекает {date}",
"invited_on": "Приглашён {date}",
"invites_failed": "Не удалось отправить приглашения",
"leave_organization": "Покинуть организацию",
"leave_organization_description": "Вы покинете эту организацию и потеряете доступ ко всем опросам и ответам. Вы сможете вернуться только по новому приглашению.",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "Старые и новые ответы смешиваются, что может привести к искажённым итоговым данным.",
"caution_recommendation": "Это может привести к несоответствиям в итогах опроса. Рекомендуем вместо этого дублировать опрос.",
"caution_text": "Изменения приведут к несоответствиям",
"centered_modal_overlay_color": "Цвет оверлея центрированного модального окна",
"change_anyway": "Всё равно изменить",
"change_background": "Изменить фон",
"change_question_type": "Изменить тип вопроса",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Добавьте цвет фона для контейнера с логотипом.",
"app_survey_placement": "Размещение опроса в приложении",
"app_survey_placement_settings_description": "Измените, где будут отображаться опросы в вашем веб-приложении или на сайте.",
"centered_modal_overlay_color": "Цвет оверлея центрированного модального окна",
"email_customization": "Настройка email",
"email_customization_description": "Измените внешний вид писем, которые Formbricks отправляет от вашего имени.",
"enable_custom_styling": "Включить пользовательское оформление",
+3 -3
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "Ingen bakgrundsbild hittades.",
"no_code": "Ingen kod",
"no_files_uploaded": "Inga filer laddades upp",
"no_overlay": "Ingen overlay",
"no_quotas_found": "Inga kvoter hittades",
"no_result_found": "Inget resultat hittades",
"no_results": "Inga resultat",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Organisationsteam hittades inte",
"other": "Annat",
"others": "Andra",
"overlay_color": "Overlay-färg",
"overview": "Översikt",
"password": "Lösenord",
"paused": "Pausad",
@@ -992,7 +990,7 @@
"from_your_organization": "från din organisation",
"invitation_sent_once_more": "Inbjudan skickad igen.",
"invite_deleted_successfully": "Inbjudan borttagen",
"invite_expires_on": "Inbjudan går ut den {date}",
"invited_on": "Inbjuden den {date}",
"invites_failed": "Inbjudningar misslyckades",
"leave_organization": "Lämna organisation",
"leave_organization_description": "Du kommer att lämna denna organisation och förlora åtkomst till alla enkäter och svar. Du kan endast återansluta om du blir inbjuden igen.",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "Äldre och nyare svar blandas vilket kan leda till vilseledande datasammanfattningar.",
"caution_recommendation": "Detta kan orsaka datainkonsekvenser i enkätsammanfattningen. Vi rekommenderar att duplicera enkäten istället.",
"caution_text": "Ändringar kommer att leda till inkonsekvenser",
"centered_modal_overlay_color": "Centrerad modal överläggsfärg",
"change_anyway": "Ändra ändå",
"change_background": "Ändra bakgrund",
"change_question_type": "Ändra frågetyp",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Lägg till en bakgrundsfärg i logobehållaren.",
"app_survey_placement": "App-enkätplacering",
"app_survey_placement_settings_description": "Ändra var enkäter visas i din webbapp eller på din webbplats.",
"centered_modal_overlay_color": "Centrerad modal överläggsfärg",
"email_customization": "E-postanpassning",
"email_customization_description": "Ändra utseendet på de e-postmeddelanden som Formbricks skickar åt dig.",
"enable_custom_styling": "Aktivera anpassad styling",
+3 -3
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "未找到 背景 图片。",
"no_code": "无代码",
"no_files_uploaded": "没有 文件 被 上传",
"no_overlay": "无覆盖层",
"no_quotas_found": "未找到配额",
"no_result_found": "没有 结果",
"no_results": "没有 结果",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "未找到 组织 团队",
"other": "其他",
"others": "其他",
"overlay_color": "覆盖层颜色",
"overview": "概览",
"password": "密码",
"paused": "暂停",
@@ -992,7 +990,7 @@
"from_your_organization": "来自你的组织",
"invitation_sent_once_more": "再次发送邀请。",
"invite_deleted_successfully": "邀请 删除 成功",
"invite_expires_on": "邀请将于 {date} 过期",
"invited_on": "邀于 {date}",
"invites_failed": "邀请失败",
"leave_organization": "离开 组织",
"leave_organization_description": "您将离开此组织,并失去对所有调查和响应的访问权限。只有再次被邀请后,您才能重新加入。",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "旧 与 新 的 回复 混合 , 这 可能 导致 数据 总结 有误 。",
"caution_recommendation": "这 可能 会 导致 调查 统计 数据 的 不一致 。 我们 建议 复制 调查 。",
"caution_text": "更改 会导致 不一致",
"centered_modal_overlay_color": "居中 模态遮罩层颜色",
"change_anyway": "还是更改",
"change_background": "更改 背景",
"change_question_type": "更改 问题类型",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "为 logo 容器添加背景色。",
"app_survey_placement": "应用调查放置位置",
"app_survey_placement_settings_description": "更改调查在您的 Web 应用或网站中显示的位置。",
"centered_modal_overlay_color": "居中模态遮罩层颜色",
"email_customization": "邮件自定义",
"email_customization_description": "更改 Formbricks 代表您发送邮件的外观和风格。",
"enable_custom_styling": "启用自定义样式",
+3 -3
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "找不到背景圖片。",
"no_code": "無程式碼",
"no_files_uploaded": "沒有上傳任何檔案",
"no_overlay": "無覆蓋層",
"no_quotas_found": "找不到 配額",
"no_result_found": "找不到結果",
"no_results": "沒有結果",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "找不到組織團隊",
"other": "其他",
"others": "其他",
"overlay_color": "覆蓋層顏色",
"overview": "概覽",
"password": "密碼",
"paused": "已暫停",
@@ -992,7 +990,7 @@
"from_your_organization": "來自您的組織",
"invitation_sent_once_more": "已再次發送邀請。",
"invite_deleted_successfully": "邀請已成功刪除",
"invite_expires_on": "邀請於 '{'date'}' 過期",
"invited_on": "邀請於 '{'date'}'",
"invites_failed": "邀請失敗",
"leave_organization": "離開組織",
"leave_organization_description": "您將離開此組織並失去對所有問卷和回應的存取權限。只有再次收到邀請,您才能重新加入。",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "較舊和較新的回應會混在一起,可能導致數據摘要失準。",
"caution_recommendation": "這可能導致調查摘要中的數據不一致。我們建議複製這個調查。",
"caution_text": "變更會導致不一致",
"centered_modal_overlay_color": "置中彈窗覆蓋顏色",
"change_anyway": "仍然變更",
"change_background": "變更背景",
"change_question_type": "變更問題類型",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "為標誌容器新增背景顏色。",
"app_survey_placement": "應用程式問卷位置",
"app_survey_placement_settings_description": "變更問卷在您的網頁應用程式或網站中顯示的位置。",
"centered_modal_overlay_color": "置中彈窗覆蓋顏色",
"email_customization": "電子郵件自訂化",
"email_customization_description": "變更 Formbricks 代表您發送的電子郵件外觀與風格。",
"enable_custom_styling": "啟用自訂樣式",
@@ -1,6 +1,5 @@
import { z } from "zod";
import { sendToPipeline } from "@/app/lib/pipelines";
import { formatValidationErrorsForV2Api, validateResponseData } from "@/modules/api/lib/validation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { responses } from "@/modules/api/v2/lib/response";
@@ -16,6 +15,7 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { formatValidationErrorsForApi, validateResponseData } from "../lib/validation";
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
@@ -198,7 +198,6 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
questionsResponse.data.blocks,
body.data,
body.language ?? "en",
body.finished,
questionsResponse.data.questions
);
@@ -207,7 +206,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
request,
{
type: "bad_request",
details: formatValidationErrorsForV2Api(validationErrors),
details: formatValidationErrorsForApi(validationErrors),
},
auditLog
);
@@ -5,10 +5,10 @@ import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TValidationErrorMap } from "@formbricks/types/surveys/validation-rules";
import {
formatValidationErrorsForApi,
formatValidationErrorsForV1Api,
formatValidationErrorsForV2Api,
validateResponseData,
} from "@/modules/api/lib/validation";
} from "./validation";
const mockTransformQuestionsToBlocks = vi.fn();
const mockGetElementsFromBlocks = vi.fn();
@@ -95,7 +95,7 @@ describe("validateResponseData", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData([], mockResponseData, "en", true, mockQuestions);
validateResponseData([], mockResponseData, "en", mockQuestions);
expect(mockTransformQuestionsToBlocks).toHaveBeenCalledWith(mockQuestions, []);
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(transformedBlocks);
@@ -105,15 +105,15 @@ describe("validateResponseData", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, mockResponseData, "en", true, mockQuestions);
validateResponseData(mockBlocks, mockResponseData, "en", mockQuestions);
expect(mockTransformQuestionsToBlocks).not.toHaveBeenCalled();
});
test("should return null when both blocks and questions are empty", () => {
expect(validateResponseData([], mockResponseData, "en", true, [])).toBeNull();
expect(validateResponseData(null, mockResponseData, "en", true, [])).toBeNull();
expect(validateResponseData(undefined, mockResponseData, "en", true, null)).toBeNull();
expect(validateResponseData([], mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(null, mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(undefined, mockResponseData, "en", null)).toBeNull();
});
test("should use default language code", () => {
@@ -124,36 +124,15 @@ describe("validateResponseData", () => {
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, mockResponseData, "en");
});
test("should validate only present fields when finished is false", () => {
const partialResponseData: TResponseData = { element1: "test" };
const partialElements = [mockElements[0]];
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, partialResponseData, "en", false);
expect(mockValidateBlockResponses).toHaveBeenCalledWith(partialElements, partialResponseData, "en");
});
test("should validate all fields when finished is true", () => {
const partialResponseData: TResponseData = { element1: "test" };
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, partialResponseData, "en", true);
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, partialResponseData, "en");
});
});
describe("formatValidationErrorsForV2Api", () => {
describe("formatValidationErrorsForApi", () => {
test("should convert error map to V2 API format", () => {
const errorMap: TValidationErrorMap = {
element1: [{ ruleId: "minLength", ruleType: "minLength", message: "Min length required" }],
};
const result = formatValidationErrorsForV2Api(errorMap);
const result = formatValidationErrorsForApi(errorMap);
expect(result).toEqual([
{
@@ -172,7 +151,7 @@ describe("formatValidationErrorsForV2Api", () => {
],
};
const result = formatValidationErrorsForV2Api(errorMap);
const result = formatValidationErrorsForApi(errorMap);
expect(result).toHaveLength(2);
expect(result[0].field).toBe("response.data.element1");
@@ -185,7 +164,7 @@ describe("formatValidationErrorsForV2Api", () => {
element2: [{ ruleId: "maxLength", ruleType: "maxLength", message: "Max length" }],
};
const result = formatValidationErrorsForV2Api(errorMap);
const result = formatValidationErrorsForApi(errorMap);
expect(result).toHaveLength(2);
expect(result[0].field).toBe("response.data.element1");
@@ -10,20 +10,17 @@ import { ApiErrorDetails } from "@/modules/api/v2/types/api-error";
/**
* Validates response data against survey validation rules
* Handles partial responses (in-progress) by only validating present fields when finished is false
*
* @param blocks - Survey blocks containing elements with validation rules (preferred)
* @param questions - Survey questions (legacy format, used as fallback if blocks are empty)
* @param responseData - Response data to validate (keyed by element ID)
* @param languageCode - Language code for error messages (defaults to "en")
* @param finished - Whether the response is finished (defaults to true for management APIs)
* @param questions - Survey questions (legacy format, used as fallback if blocks are empty)
* @returns Validation error map keyed by element ID, or null if validation passes
*/
export const validateResponseData = (
blocks: TSurveyBlock[] | undefined | null,
responseData: TResponseData,
languageCode: string = "en",
finished: boolean = true,
questions?: TSurveyQuestion[] | undefined | null
): TValidationErrorMap | null => {
// Use blocks if available, otherwise transform questions to blocks
@@ -40,26 +37,22 @@ export const validateResponseData = (
}
// Extract elements from blocks
const allElements = getElementsFromBlocks(blocksToUse);
const elements = getElementsFromBlocks(blocksToUse);
// If response is not finished, only validate elements that are present in the response data
// This prevents "required" errors for fields the user hasn't reached yet
const elementsToValidate = finished ? allElements : allElements.filter((element) => Object.keys(responseData).includes(element.id));
// Validate selected elements
const errorMap = validateBlockResponses(elementsToValidate, responseData, languageCode);
// Validate all elements
const errorMap = validateBlockResponses(elements, responseData, languageCode);
// Return null if no errors (validation passed), otherwise return error map
return Object.keys(errorMap).length === 0 ? null : errorMap;
};
/**
* Converts validation error map to V2 API error response format
* Converts validation error map to API error response format (V2)
*
* @param errorMap - Validation error map from validateResponseData
* @returns V2 API error response details
* @returns API error response details
*/
export const formatValidationErrorsForV2Api = (errorMap: TValidationErrorMap) => {
export const formatValidationErrorsForApi = (errorMap: TValidationErrorMap) => {
const details: ApiErrorDetails = [];
for (const [elementId, errors] of Object.entries(errorMap)) {
@@ -1,7 +1,6 @@
import { Response } from "@prisma/client";
import { NextRequest } from "next/server";
import { sendToPipeline } from "@/app/lib/pipelines";
import { formatValidationErrorsForV2Api, validateResponseData } from "@/modules/api/lib/validation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { responses } from "@/modules/api/v2/lib/response";
@@ -14,6 +13,7 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
import { formatValidationErrorsForApi, validateResponseData } from "./lib/validation";
export const GET = async (request: NextRequest) =>
authenticatedApiClient({
@@ -134,7 +134,6 @@ export const POST = async (request: Request) =>
surveyQuestions.data.blocks,
body.data,
body.language ?? "en",
body.finished,
surveyQuestions.data.questions
);
@@ -143,7 +142,7 @@ export const POST = async (request: Request) =>
request,
{
type: "bad_request",
details: formatValidationErrorsForV2Api(validationErrors),
details: formatValidationErrorsForApi(validationErrors),
},
auditLog
);
@@ -58,7 +58,7 @@ describe("updateProjectBranding", () => {
},
placement: "bottomRight" as const,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [{ id: "test-env-id" }],
languages: [],
logo: null,
@@ -183,7 +183,7 @@ export async function PreviewEmailTemplate({
{ctaElement.buttonExternal && ctaElement.ctaButtonLabel && ctaElement.buttonUrl && (
<Container className="mx-0 mt-4 flex max-w-none items-center justify-end">
<EmailButton
className="text-question-color flex items-center rounded-md border-0 bg-transparent px-3 py-3 text-base font-medium leading-4 no-underline shadow-none"
className="text-question-color flex items-center rounded-md border-0 bg-transparent px-3 py-3 text-base leading-4 font-medium no-underline shadow-none"
href={ctaElement.buttonUrl}>
<Text className="inline">
{getLocalizedValue(ctaElement.ctaButtonLabel, defaultLanguageCode)}{" "}
@@ -306,13 +306,13 @@ export async function PreviewEmailTemplate({
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
className="rounded-custom mr-3 mb-3 inline-block h-[150px] w-[250px]"
key={choice.id}
src={choice.imageUrl}
/>
) : (
<Link
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
className="rounded-custom mr-3 mb-3 inline-block h-[150px] w-[250px]"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id}
target="_blank">
@@ -360,11 +360,11 @@ export async function PreviewEmailTemplate({
<Container className="mx-0">
<Section className="w-full table-auto">
<Row>
<Column className="w-40 break-words px-4 py-2" />
<Column className="w-40 px-4 py-2 break-words" />
{firstQuestion.columns.map((column) => {
return (
<Column
className="text-question-color max-w-40 break-words px-4 py-2 text-center"
className="text-question-color max-w-40 px-4 py-2 text-center break-words"
key={column.id}>
{getLocalizedValue(column.label, "default")}
</Column>
@@ -376,7 +376,7 @@ export async function PreviewEmailTemplate({
<Row
className={`${rowIndex % 2 === 0 ? "bg-input-color" : ""} rounded-custom`}
key={row.id}>
<Column className="w-40 break-words px-4 py-2">
<Column className="w-40 px-4 py-2 break-words">
{getLocalizedValue(row.label, "default")}
</Column>
{firstQuestion.columns.map((column) => {
@@ -241,7 +241,7 @@ describe("utils.ts", () => {
config: {},
placement: "bottomRight" as const,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
styling: {},
logo: null,
environments: [
@@ -389,7 +389,7 @@ describe("utils.ts", () => {
config: {},
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
styling: {},
logo: null,
environments: [
@@ -502,7 +502,7 @@ describe("utils.ts", () => {
config: {},
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
styling: {},
logo: null,
environments: [],
@@ -588,7 +588,7 @@ describe("utils.ts", () => {
config: {},
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
styling: {},
logo: null,
environments: [],
@@ -627,7 +627,7 @@ describe("utils.ts", () => {
config: {},
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
styling: {},
logo: null,
environments: [],
+2 -2
View File
@@ -150,7 +150,7 @@ export const getEnvironmentWithRelations = reactCache(async (environmentId: stri
config: true,
placement: true,
clickOutsideClose: true,
overlay: true,
darkOverlay: true,
styling: true,
logo: true,
customHeadScripts: true,
@@ -220,7 +220,7 @@ export const getEnvironmentWithRelations = reactCache(async (environmentId: stri
config: data.project.config,
placement: data.project.placement,
clickOutsideClose: data.project.clickOutsideClose,
overlay: data.project.overlay,
darkOverlay: data.project.darkOverlay,
styling: data.project.styling,
logo: data.project.logo,
customHeadScripts: data.project.customHeadScripts,
@@ -2,7 +2,6 @@
import { OrganizationRole } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId, ZUuid } from "@formbricks/types/common";
import { AuthenticationError, OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships";
@@ -24,7 +23,7 @@ import {
getMembershipsByUserId,
getOrganizationOwnerCount,
} from "@/modules/organization/settings/teams/lib/membership";
import { deleteInvite, getInvite, inviteUser, refreshInviteExpiration, resendInvite } from "./lib/invite";
import { deleteInvite, getInvite, inviteUser, resendInvite } from "./lib/invite";
const ZDeleteInviteAction = z.object({
inviteId: ZUuid,
@@ -58,57 +57,30 @@ const ZCreateInviteTokenAction = z.object({
inviteId: ZUuid,
});
export const createInviteTokenAction = authenticatedActionClient.schema(ZCreateInviteTokenAction).action(
withAuditLogging(
"updated",
"invite",
async ({
parsedInput,
ctx,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateInviteTokenAction>;
}) => {
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
export const createInviteTokenAction = authenticatedActionClient
.schema(ZCreateInviteTokenAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromInviteId(parsedInput.inviteId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
// Get old expiresAt for audit logging before update
const oldInvite = await prisma.invite.findUnique({
where: { id: parsedInput.inviteId },
select: { email: true, expiresAt: true },
});
if (!oldInvite) {
throw new ValidationError("Invite not found");
}
// Refresh the invitation expiration
const updatedInvite = await refreshInviteExpiration(parsedInput.inviteId);
// Set audit context
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
ctx.auditLoggingCtx.oldObject = { expiresAt: oldInvite.expiresAt };
ctx.auditLoggingCtx.newObject = { expiresAt: updatedInvite.expiresAt };
const inviteToken = createInviteToken(parsedInput.inviteId, updatedInvite.email, {
expiresIn: "7d",
});
return { inviteToken: encodeURIComponent(inviteToken) };
const invite = await getInvite(parsedInput.inviteId);
if (!invite) {
throw new ValidationError("Invite not found");
}
)
);
const inviteToken = createInviteToken(parsedInput.inviteId, invite.email, {
expiresIn: "7d",
});
return { inviteToken: encodeURIComponent(inviteToken) };
});
const ZDeleteMembershipAction = z.object({
userId: ZId,
@@ -219,7 +191,6 @@ export const resendInviteAction = authenticatedActionClient.schema(ZResendInvite
invite?.creator?.name ?? "",
updatedInvite.name ?? ""
);
return updatedInvite;
}
)
@@ -80,7 +80,6 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
if (createInviteTokenResponse?.data) {
setShareInviteToken(createInviteTokenResponse.data.inviteToken);
setShowShareInviteModal(true);
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(createInviteTokenResponse);
toast.error(errorMessage);
@@ -100,7 +99,6 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
});
if (resendInviteResponse?.data) {
toast.success(t("environments.settings.general.invitation_sent_once_more"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(resendInviteResponse);
toast.error(errorMessage);
@@ -47,8 +47,8 @@ export const MembersInfo = ({
<Badge type="gray" text="Expired" size="tiny" data-testid="expired-badge" />
) : (
<TooltipRenderer
tooltipContent={`${t("environments.settings.general.invite_expires_on", {
date: getFormattedDateTimeString(member.expiresAt),
tooltipContent={`${t("environments.settings.general.invited_on", {
date: getFormattedDateTimeString(member.createdAt),
})}`}>
<Badge type="warning" text="Pending" size="tiny" />
</TooltipRenderer>
@@ -9,14 +9,7 @@ import {
} from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { TInvitee } from "../types/invites";
import {
deleteInvite,
getInvite,
getInvitesByOrganizationId,
inviteUser,
refreshInviteExpiration,
resendInvite,
} from "./invite";
import { deleteInvite, getInvite, getInvitesByOrganizationId, inviteUser, resendInvite } from "./invite";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -53,129 +46,32 @@ const mockInvite: Invite = {
teamIds: [],
};
describe("refreshInviteExpiration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("updates expiresAt to approximately 7 days from now", async () => {
const now = Date.now();
const expectedExpiresAt = new Date(now + 1000 * 60 * 60 * 24 * 7);
vi.mocked(prisma.invite.update).mockResolvedValue({
...mockInvite,
expiresAt: expectedExpiresAt,
});
const result = await refreshInviteExpiration("invite-1");
expect(prisma.invite.update).toHaveBeenCalledWith({
where: { id: "invite-1" },
data: {
expiresAt: expect.any(Date),
},
});
expect(result.expiresAt.getTime()).toBeGreaterThanOrEqual(now + 1000 * 60 * 60 * 24 * 7 - 1000);
expect(result.expiresAt.getTime()).toBeLessThanOrEqual(now + 1000 * 60 * 60 * 24 * 7 + 1000);
});
test("throws ResourceNotFoundError if invite not found (P2025)", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "1.0.0",
});
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
await expect(refreshInviteExpiration("invite-1")).rejects.toThrow(ResourceNotFoundError);
});
test("throws DatabaseError on other prisma errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("db", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
await expect(refreshInviteExpiration("invite-1")).rejects.toThrow(DatabaseError);
});
test("throws error if non-prisma error", async () => {
const error = new Error("db");
vi.mocked(prisma.invite.update).mockRejectedValue(error);
await expect(refreshInviteExpiration("invite-1")).rejects.toThrow("db");
});
test("returns full invite object with all fields", async () => {
const updatedInvite = {
...mockInvite,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
};
vi.mocked(prisma.invite.update).mockResolvedValue(updatedInvite);
const result = await refreshInviteExpiration("invite-1");
expect(result).toEqual(updatedInvite);
expect(result.id).toBe("invite-1");
expect(result.email).toBe("test@example.com");
expect(result.name).toBe("Test User");
});
});
describe("resendInvite", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns email and name after updating expiration", async () => {
const updatedInvite = {
...mockInvite,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
};
vi.mocked(prisma.invite.update).mockResolvedValue(updatedInvite);
test("returns email and name if invite exists", async () => {
vi.mocked(prisma.invite.findUnique).mockResolvedValue({ ...mockInvite, creator: {} });
vi.mocked(prisma.invite.update).mockResolvedValue({ ...mockInvite, organizationId: "org-1" });
const result = await resendInvite("invite-1");
expect(result).toEqual({ email: mockInvite.email, name: mockInvite.name });
expect(prisma.invite.update).toHaveBeenCalledWith({
where: { id: "invite-1" },
data: {
expiresAt: expect.any(Date),
},
});
});
test("calls refreshInviteExpiration helper", async () => {
const updatedInvite = {
...mockInvite,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
};
vi.mocked(prisma.invite.update).mockResolvedValue(updatedInvite);
await resendInvite("invite-1");
expect(prisma.invite.update).toHaveBeenCalledTimes(1);
});
test("throws ResourceNotFoundError if invite not found", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "1.0.0",
});
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
vi.mocked(prisma.invite.findUnique).mockResolvedValue(null);
await expect(resendInvite("invite-1")).rejects.toThrow(ResourceNotFoundError);
});
test("throws DatabaseError on other prisma errors", async () => {
test("throws DatabaseError on prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("db", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
vi.mocked(prisma.invite.findUnique).mockRejectedValue(prismaError);
await expect(resendInvite("invite-1")).rejects.toThrow(DatabaseError);
});
test("throws error if non-prisma error", async () => {
test("throws error if prisma error", async () => {
const error = new Error("db");
vi.mocked(prisma.invite.update).mockRejectedValue(error);
vi.mocked(prisma.invite.findUnique).mockRejectedValue(error);
await expect(resendInvite("invite-1")).rejects.toThrow("db");
});
});
@@ -13,21 +13,44 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { validateInputs } from "@/lib/utils/validate";
import { type InviteWithCreator, type TInvite, type TInvitee } from "../types/invites";
export const refreshInviteExpiration = async (inviteId: string): Promise<Invite> => {
export const resendInvite = async (inviteId: string): Promise<Pick<Invite, "email" | "name">> => {
try {
const updatedInvite = await prisma.invite.update({
where: { id: inviteId },
data: {
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 7 days
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
},
select: {
email: true,
name: true,
creator: true,
},
});
return updatedInvite;
if (!invite) {
throw new ResourceNotFoundError("Invite", inviteId);
}
const updatedInvite = await prisma.invite.update({
where: {
id: inviteId,
},
data: {
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
},
select: {
id: true,
email: true,
name: true,
organizationId: true,
},
});
return {
email: updatedInvite.email,
name: updatedInvite.name,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2025") {
throw new ResourceNotFoundError("Invite", inviteId);
}
throw new DatabaseError(error.message);
}
@@ -35,16 +58,6 @@ export const refreshInviteExpiration = async (inviteId: string): Promise<Invite>
}
};
export const resendInvite = async (inviteId: string): Promise<Pick<Invite, "email" | "name">> => {
// Refresh expiration and return the updated invite (single query)
const updatedInvite = await refreshInviteExpiration(inviteId);
return {
email: updatedInvite.email,
name: updatedInvite.name,
};
};
export const getInvitesByOrganizationId = reactCache(
async (organizationId: string, page?: number): Promise<TInvite[]> => {
validateInputs([organizationId, z.string()], [page, z.number().optional()]);
@@ -23,7 +23,7 @@ const baseProject = {
config: { channel: null, industry: null },
placement: "bottomRight",
clickOutsideClose: false,
overlay: "none",
darkOverlay: false,
environments: [
{
id: "cmi2sra0j000004l73fvh7lhe",
@@ -24,7 +24,7 @@ const selectProject = {
config: true,
placement: true,
clickOutsideClose: true,
overlay: true,
darkOverlay: true,
environments: true,
styling: true,
logo: true,
@@ -15,7 +15,6 @@ import { FormControl, FormField, FormItem, FormLabel, FormProvider } from "@/mod
import { Label } from "@/modules/ui/components/label";
import { getPlacementStyle } from "@/modules/ui/components/preview-survey/lib/utils";
import { RadioGroup, RadioGroupItem } from "@/modules/ui/components/radio-group";
import { StylingTabs } from "@/modules/ui/components/styling-tabs";
interface EditPlacementProps {
project: Project;
@@ -25,7 +24,7 @@ interface EditPlacementProps {
const ZProjectPlacementInput = z.object({
placement: z.enum(["bottomRight", "topRight", "topLeft", "bottomLeft", "center"]),
overlay: z.enum(["none", "light", "dark"]),
darkOverlay: z.boolean(),
clickOutsideClose: z.boolean(),
});
@@ -41,35 +40,28 @@ export const EditPlacementForm = ({ project, isReadOnly }: EditPlacementProps) =
{ name: t("common.bottom_left"), value: "bottomLeft", disabled: false },
{ name: t("common.centered_modal"), value: "center", disabled: false },
];
const form = useForm<EditPlacementFormValues>({
defaultValues: {
placement: project.placement,
overlay: project.overlay ?? "none",
darkOverlay: project.darkOverlay ?? false,
clickOutsideClose: project.clickOutsideClose ?? false,
},
resolver: zodResolver(ZProjectPlacementInput),
});
const currentPlacement = form.watch("placement");
const overlay = form.watch("overlay");
const darkOverlay = form.watch("darkOverlay");
const clickOutsideClose = form.watch("clickOutsideClose");
const isSubmitting = form.formState.isSubmitting;
const hasOverlay = overlay !== "none";
const getOverlayStyle = () => {
if (overlay === "dark") return "bg-slate-700/80";
if (overlay === "light") return "bg-slate-400/50";
return "bg-slate-200";
};
const overlayStyle = currentPlacement === "center" && darkOverlay ? "bg-slate-700/80" : "bg-slate-200";
const onSubmit: SubmitHandler<EditPlacementFormValues> = async (data) => {
const updatedProjectResponse = await updateProjectAction({
projectId: project.id,
data: {
placement: data.placement,
overlay: data.overlay,
darkOverlay: data.darkOverlay,
clickOutsideClose: data.clickOutsideClose,
},
});
@@ -121,9 +113,9 @@ export const EditPlacementForm = ({ project, isReadOnly }: EditPlacementProps) =
/>
<div
className={cn(
hasOverlay && !clickOutsideClose ? "cursor-not-allowed" : "",
clickOutsideClose ? "" : "cursor-not-allowed",
"relative ml-8 h-40 w-full rounded",
getOverlayStyle()
overlayStyle
)}>
<div
className={cn(
@@ -133,69 +125,85 @@ export const EditPlacementForm = ({ project, isReadOnly }: EditPlacementProps) =
</div>
</div>
<div className="mt-6 space-y-2">
<FormField
control={form.control}
name="overlay"
render={({ field }) => (
<FormItem>
<FormControl>
<StylingTabs
id="overlay"
options={[
{ value: "none", label: t("common.no_overlay") },
{ value: "light", label: t("common.light_overlay") },
{ value: "dark", label: t("common.dark_overlay") },
]}
defaultSelected={field.value}
onChange={(value) => field.onChange(value)}
label={t("common.overlay_color")}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{hasOverlay && (
<div className="mt-6 space-y-2">
<FormField
control={form.control}
name="clickOutsideClose"
render={({ field }) => (
<FormItem>
<FormLabel className="font-semibold">
{t("common.allow_users_to_exit_by_clicking_outside_the_survey")}
</FormLabel>
<FormControl>
<RadioGroup
disabled={isReadOnly}
onValueChange={(value) => {
field.onChange(value === "allow");
}}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="disallow" value="disallow" checked={!field.value} />
<Label
htmlFor="disallow"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.disallow")}
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="allow" value="allow" checked={field.value} />
<Label
htmlFor="allow"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.allow")}
</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</div>
{currentPlacement === "center" && (
<>
<div className="mt-6 space-y-2">
<FormField
control={form.control}
name="darkOverlay"
render={({ field }) => (
<FormItem>
<FormLabel className="font-semibold">
{t("environments.workspace.look.centered_modal_overlay_color")}
</FormLabel>
<FormControl>
<RadioGroup
onValueChange={(value) => {
field.onChange(value === "darkOverlay");
}}
disabled={isReadOnly}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="lightOverlay" value="lightOverlay" checked={!field.value} />
<Label
htmlFor="lightOverlay"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.light_overlay")}
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="darkOverlay" value="darkOverlay" checked={field.value} />
<Label
htmlFor="darkOverlay"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.dark_overlay")}
</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="mt-6 space-y-2">
<FormField
control={form.control}
name="clickOutsideClose"
render={({ field }) => (
<FormItem>
<FormLabel className="font-semibold">
{t("common.allow_users_to_exit_by_clicking_outside_the_survey")}
</FormLabel>
<FormControl>
<RadioGroup
disabled={isReadOnly}
onValueChange={(value) => {
field.onChange(value === "allow");
}}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="disallow" value="disallow" checked={!field.value} />
<Label
htmlFor="disallow"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.disallow")}
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="allow" value="allow" checked={field.value} />
<Label
htmlFor="allow"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.allow")}
</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</div>
</>
)}
<Button className="mt-4 w-fit" size="sm" loading={isSubmitting} disabled={isReadOnly}>
@@ -21,7 +21,7 @@ const baseProject: Project = {
config: { channel: null, industry: null } as any,
placement: "bottomRight",
clickOutsideClose: false,
overlay: "none",
darkOverlay: false,
logo: null,
brandColor: null,
highlightBorderColor: null,
@@ -391,7 +391,7 @@ export const ElementFormInput = ({
return (
<div className="w-full">
{label && (
<div className="mb-2 mt-3 flex items-center justify-between">
<div className="mt-3 mb-2 flex items-center justify-between">
<Label htmlFor={id}>{label}</Label>
{id === "headline" && currentElement && updateElement && (
<div className="flex items-center space-x-2">
@@ -521,7 +521,7 @@ export const ElementFormInput = ({
return (
<div className="w-full">
{label && (
<div className="mb-2 mt-3 flex items-center justify-between">
<div className="mt-3 mb-2 flex items-center justify-between">
<Label htmlFor={id}>{label}</Label>
</div>
)}
@@ -568,7 +568,7 @@ export const ElementFormInput = ({
<div className="h-10 w-full"></div>
<div
ref={highlightContainerRef}
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent ${
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent ${
localSurvey.languages?.length > 1 ? "pr-24" : ""
}`}
dir="auto"
@@ -265,7 +265,7 @@ export const BlockCard = ({
</div>
<button
className="opacity-0 hover:cursor-move group-hover:opacity-100"
className="opacity-0 group-hover:opacity-100 hover:cursor-move"
aria-label="Drag to reorder block">
<GripIcon className="h-4 w-4" />
</button>
@@ -69,7 +69,6 @@ export const EndScreenForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!endingCard.headline?.default || endingCard.headline.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
<div>
{endingCard.subheader !== undefined && (
@@ -88,7 +87,6 @@ export const EndScreenForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!endingCard.subheader?.default || endingCard.subheader.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
</div>
@@ -141,7 +139,7 @@ export const EndScreenForm = ({
</Label>
</div>
{showEndingCardCTA && (
<div className="mt-4 space-y-4 rounded-md border bg-slate-100 p-4 pt-2">
<div className="mt-4 space-y-4 rounded-md border border-1 bg-slate-100 p-4 pt-2">
<div className="space-y-2">
<ElementFormInput
id="buttonLabel"
@@ -185,7 +183,7 @@ export const EndScreenForm = ({
<div className="group relative">
{/* The highlight container is absolutely positioned behind the input */}
<div
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent`}
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent`}
dir="auto"
key={highlightedJSX.toString()}>
{highlightedJSX}
@@ -172,7 +172,7 @@ export const FileUploadElementForm = ({
updateElement(elementIdx, { maxSizeInMB: Number.parseInt(e.target.value, 10) });
}}
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
className="mr-2 ml-2 inline w-20 bg-white text-center text-sm"
/>
MB
</p>
@@ -27,7 +27,6 @@ interface PictureSelectionFormProps {
isInvalid: boolean;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
}
export const PictureSelectionForm = ({
@@ -40,7 +39,6 @@ export const PictureSelectionForm = ({
isInvalid,
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
}: PictureSelectionFormProps): JSX.Element => {
const environmentId = localSurvey.environmentId;
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
@@ -90,7 +88,6 @@ export const PictureSelectionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
autoFocus={!element.headline?.default || element.headline.default.trim() === ""}
/>
<div ref={parent}>
@@ -109,7 +106,6 @@ export const PictureSelectionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
autoFocus={!element.subheader?.default || element.subheader.default.trim() === ""}
/>
</div>
@@ -1,18 +1,17 @@
"use client";
import { useTranslation } from "react-i18next";
import { TOverlay, TPlacement } from "@formbricks/types/common";
import { TPlacement } from "@formbricks/types/common";
import { cn } from "@/lib/cn";
import { Label } from "@/modules/ui/components/label";
import { getPlacementStyle } from "@/modules/ui/components/preview-survey/lib/utils";
import { RadioGroup, RadioGroupItem } from "@/modules/ui/components/radio-group";
import { StylingTabs } from "@/modules/ui/components/styling-tabs";
interface TPlacementProps {
currentPlacement: TPlacement;
setCurrentPlacement: (placement: TPlacement) => void;
setOverlay: (overlay: TOverlay) => void;
overlay: TOverlay;
setOverlay: (overlay: string) => void;
overlay: string;
setClickOutsideClose: (clickOutside: boolean) => void;
clickOutsideClose: boolean;
}
@@ -33,15 +32,8 @@ export const Placement = ({
{ name: t("common.bottom_left"), value: "bottomLeft", disabled: false },
{ name: t("common.centered_modal"), value: "center", disabled: false },
];
const hasOverlay = overlay !== "none";
const getOverlayStyle = () => {
if (overlay === "dark") return "bg-slate-700/80";
if (overlay === "light") return "bg-slate-400/50";
return "bg-slate-200";
};
const overlayStyle =
currentPlacement === "center" && overlay === "dark" ? "bg-slate-700/80" : "bg-slate-200";
return (
<>
<div className="flex">
@@ -58,9 +50,9 @@ export const Placement = ({
<div
data-testid="placement-preview"
className={cn(
hasOverlay && !clickOutsideClose ? "cursor-not-allowed" : "",
clickOutsideClose ? "" : "cursor-not-allowed",
"relative ml-8 h-40 w-full rounded",
getOverlayStyle()
overlayStyle
)}>
<div
className={cn(
@@ -69,46 +61,53 @@ export const Placement = ({
)}></div>
</div>
</div>
<div className="mt-6 space-y-2">
<StylingTabs
id="overlay"
options={[
{ value: "none", label: t("common.no_overlay") },
{ value: "light", label: t("common.light_overlay") },
{ value: "dark", label: t("common.dark_overlay") },
]}
defaultSelected={overlay}
onChange={(value) => setOverlay(value)}
label={t("common.overlay_color")}
activeTabClassName="bg-slate-200"
inactiveTabClassName="bg-transparent"
/>
</div>
{hasOverlay && (
<div className="mt-6 space-y-2">
<Label className="font-semibold">
{t("common.allow_users_to_exit_by_clicking_outside_the_survey")}
</Label>
<RadioGroup
onValueChange={(value) => setClickOutsideClose(value === "allow")}
value={clickOutsideClose ? "allow" : "disallow"}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="disallow" value="disallow" />
<Label htmlFor="disallow" className="text-slate-900">
{t("common.disallow")}
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="allow" value="allow" />
<Label htmlFor="allow" className="text-slate-900">
{t("common.allow")}
</Label>
</div>
</RadioGroup>
</div>
{currentPlacement === "center" && (
<>
<div className="mt-6 space-y-2">
<Label className="font-semibold">
{t("environments.surveys.edit.centered_modal_overlay_color")}
</Label>
<RadioGroup
onValueChange={(overlay) => setOverlay(overlay)}
value={overlay}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="lightOverlay" value="light" />
<Label htmlFor="lightOverlay" className="text-slate-900">
{t("common.light_overlay")}
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="darkOverlay" value="dark" />
<Label htmlFor="darkOverlay" className="text-slate-900">
{t("common.dark_overlay")}
</Label>
</div>
</RadioGroup>
</div>
<div className="mt-6 space-y-2">
<Label className="font-semibold">
{t("common.allow_users_to_exit_by_clicking_outside_the_survey")}
</Label>
<RadioGroup
onValueChange={(value) => setClickOutsideClose(value === "allow")}
value={clickOutsideClose ? "allow" : "disallow"}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="disallow" value="disallow" />
<Label htmlFor="disallow" className="text-slate-900">
{t("common.disallow")}
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="allow" value="allow" />
<Label htmlFor="allow" className="text-slate-900">
{t("common.allow")}
</Label>
</div>
</RadioGroup>
</div>
</>
)}
</>
);
@@ -45,7 +45,7 @@ export const RedirectUrlForm = ({ localSurvey, endingCard, updateSurvey }: Redir
<div className="group relative">
{/* The highlight container is absolutely positioned behind the input */}
<div
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent`}
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent`}
dir="auto"
key={highlightedJSX.toString()}>
{highlightedJSX}
@@ -6,7 +6,7 @@ import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TOverlay, TPlacement } from "@formbricks/types/common";
import { TPlacement } from "@formbricks/types/common";
import { TSurvey, TSurveyProjectOverwrites } from "@formbricks/types/surveys/types";
import { Placement } from "@/modules/survey/editor/components/placement";
import { Label } from "@/modules/ui/components/label";
@@ -27,7 +27,7 @@ export const SurveyPlacementCard = ({
const [open, setOpen] = useState(false);
const { projectOverwrites } = localSurvey ?? {};
const { placement, clickOutsideClose, overlay } = projectOverwrites ?? {};
const { placement, clickOutsideClose, darkOverlay } = projectOverwrites ?? {};
const setProjectOverwrites = (projectOverwrites: TSurveyProjectOverwrites | null) => {
setLocalSurvey({ ...localSurvey, projectOverwrites: projectOverwrites });
@@ -41,7 +41,7 @@ export const SurveyPlacementCard = ({
setProjectOverwrites({
placement: "bottomRight",
clickOutsideClose: false,
overlay: "none",
darkOverlay: false,
});
}
}
@@ -56,11 +56,13 @@ export const SurveyPlacementCard = ({
}
};
const handleOverlay = (overlayValue: TOverlay) => {
const handleOverlay = (overlayType: string) => {
const darkOverlay = overlayType === "dark";
if (setProjectOverwrites) {
setProjectOverwrites({
...projectOverwrites,
overlay: overlayValue,
darkOverlay,
});
}
};
@@ -130,7 +132,7 @@ export const SurveyPlacementCard = ({
currentPlacement={placement}
setCurrentPlacement={handlePlacementChange}
setOverlay={handleOverlay}
overlay={overlay ?? "none"}
overlay={darkOverlay ? "dark" : "light"}
setClickOutsideClose={handleClickOutsideClose}
clickOutsideClose={!!clickOutsideClose}
/>
@@ -188,8 +188,6 @@ export const FollowUpModal = ({
subject: defaultValues?.subject ?? t("environments.surveys.edit.follow_ups_modal_action_subject"),
body: defaultValues?.body ?? getSurveyFollowUpActionDefaultBody(t),
attachResponseData: defaultValues?.attachResponseData ?? false,
includeVariables: defaultValues?.includeVariables ?? false,
includeHiddenFields: defaultValues?.includeHiddenFields ?? false,
},
resolver: zodResolver(ZCreateSurveyFollowUpFormSchema),
mode: "onChange",
+1 -1
View File
@@ -47,7 +47,7 @@ const mockProjectPrisma = {
linkSurveyBranding: false,
placement: "bottomRight",
clickOutsideClose: false,
overlay: "none",
darkOverlay: false,
segment: null,
surveyClosedMessage: null,
singleUseId: null,
@@ -1,7 +1,7 @@
"use client";
import { ReactNode, useEffect, useRef, useState } from "react";
import { TOverlay, TPlacement } from "@formbricks/types/common";
import { TPlacement } from "@formbricks/types/common";
import { cn } from "@/lib/cn";
import { getPlacementStyle } from "../lib/utils";
@@ -11,7 +11,7 @@ interface ModalProps {
placement: TPlacement;
previewMode: string;
clickOutsideClose: boolean;
overlay: TOverlay;
darkOverlay: boolean;
borderRadius?: number;
background?: string;
}
@@ -22,13 +22,14 @@ export const Modal = ({
placement,
previewMode,
clickOutsideClose,
overlay,
darkOverlay,
borderRadius,
background,
}: ModalProps) => {
const [show, setShow] = useState(true);
const modalRef = useRef<HTMLDivElement | null>(null);
const [windowWidth, setWindowWidth] = useState<number | null>(null);
const [overlayVisible, setOverlayVisible] = useState(placement === "center");
useEffect(() => {
if (typeof window !== "undefined") {
@@ -41,6 +42,10 @@ export const Modal = ({
}
}, []);
useEffect(() => {
setOverlayVisible(placement === "center");
}, [placement]);
const calculateScaling = () => {
if (windowWidth === null) return {};
@@ -79,11 +84,12 @@ export const Modal = ({
const scalingClasses = calculateScaling();
useEffect(() => {
if (!clickOutsideClose) return;
if (!clickOutsideClose || placement !== "center") return;
const handleClickOutside = (e: MouseEvent) => {
const previewBase = document.getElementById("preview-survey-base");
if (
clickOutsideClose &&
modalRef.current &&
previewBase &&
previewBase.contains(e.target as Node) &&
@@ -100,7 +106,7 @@ export const Modal = ({
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [clickOutsideClose]);
}, [clickOutsideClose, placement]);
useEffect(() => {
setShow(isOpen);
@@ -129,8 +135,7 @@ export const Modal = ({
aria-live="assertive"
className={cn(
"relative h-full w-full overflow-hidden rounded-b-md",
overlay === "dark" ? "bg-slate-700/80" : "",
overlay === "light" ? "bg-slate-400/50" : "",
overlayVisible ? (darkOverlay ? "bg-slate-700/80" : "bg-white/50") : "",
"transition-all duration-500 ease-in-out"
)}>
<div
@@ -51,11 +51,11 @@ export const PreviewSurvey = ({
const { projectOverwrites } = survey || {};
const { placement: surveyPlacement } = projectOverwrites || {};
const { overlay: surveyOverlay } = projectOverwrites || {};
const { darkOverlay: surveyDarkOverlay } = projectOverwrites || {};
const { clickOutsideClose: surveyClickOutsideClose } = projectOverwrites || {};
const placement = surveyPlacement || project.placement;
const overlay = surveyOverlay ?? project.overlay;
const darkOverlay = surveyDarkOverlay ?? project.darkOverlay;
const clickOutsideClose = surveyClickOutsideClose ?? project.clickOutsideClose;
const styling: TSurveyStyling | TProjectStyling = useMemo(() => {
@@ -241,7 +241,7 @@ export const PreviewSurvey = ({
isOpen={isModalOpen}
placement={placement}
previewMode="mobile"
overlay={overlay}
darkOverlay={darkOverlay}
clickOutsideClose={clickOutsideClose}
borderRadius={styling?.roundness ?? 8}
background={styling?.cardBackgroundColor?.light}>
@@ -345,7 +345,7 @@ export const PreviewSurvey = ({
isOpen={isModalOpen}
placement={placement}
clickOutsideClose={clickOutsideClose}
overlay={overlay}
darkOverlay={darkOverlay}
previewMode="desktop"
borderRadius={styling.roundness ?? 8}
background={styling.cardBackgroundColor?.light}>
@@ -18,8 +18,6 @@ interface StylingTabsProps<T> {
label?: string;
subLabel?: string;
activeTabClassName?: string;
inactiveTabClassName?: string;
}
export const StylingTabs = <T extends string | number>({
@@ -31,8 +29,6 @@ export const StylingTabs = <T extends string | number>({
tabsContainerClassName,
label,
subLabel,
activeTabClassName,
inactiveTabClassName,
}: StylingTabsProps<T>) => {
const [selectedOption, setSelectedOption] = useState<T | undefined>(defaultSelected);
@@ -61,8 +57,7 @@ export const StylingTabs = <T extends string | number>({
className={cn(
"flex flex-1 cursor-pointer items-center justify-center gap-4 rounded-md py-2 text-center text-sm",
selectedOption === option.value ? "bg-slate-100" : "bg-white",
"focus:ring-brand-dark focus:outline-none focus:ring-2 focus:ring-opacity-50",
selectedOption === option.value ? activeTabClassName : inactiveTabClassName
"focus:ring-brand-dark focus:outline-none focus:ring-2 focus:ring-opacity-50"
)}>
<input
type="radio"
@@ -96,11 +96,11 @@ export const ThemeStylingPreviewSurvey = ({
};
const { placement: surveyPlacement } = projectOverwrites || {};
const { overlay: surveyOverlay } = projectOverwrites || {};
const { darkOverlay: surveyDarkOverlay } = projectOverwrites || {};
const { clickOutsideClose: surveyClickOutsideClose } = projectOverwrites || {};
const placement = surveyPlacement || project.placement;
const overlay = surveyOverlay ?? project.overlay;
const darkOverlay = surveyDarkOverlay ?? project.darkOverlay;
const clickOutsideClose = surveyClickOutsideClose ?? project.clickOutsideClose;
const highlightBorderColor = project.styling.highlightBorderColor?.light;
@@ -162,7 +162,7 @@ export const ThemeStylingPreviewSurvey = ({
isOpen
placement={placement}
clickOutsideClose={clickOutsideClose}
overlay={overlay}
darkOverlay={darkOverlay}
previewMode="desktop"
background={project.styling.cardBackgroundColor?.light}
borderRadius={project.styling.roundness ?? 8}>
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@formbricks/web",
"version": "0.0.0",
"packageManager": "pnpm@10.28.2",
"packageManager": "pnpm@9.15.9",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next coverage",
@@ -19,9 +19,9 @@
"i18n:generate": "npx lingo.dev@latest run && npx lingo.dev@latest lockfile --force"
},
"dependencies": {
"@aws-sdk/client-s3": "3.971.0",
"@aws-sdk/s3-presigned-post": "3.971.0",
"@aws-sdk/s3-request-presigner": "3.971.0",
"@aws-sdk/client-s3": "3.879.0",
"@aws-sdk/s3-presigned-post": "3.879.0",
"@aws-sdk/s3-request-presigner": "3.879.0",
"@boxyhq/saml-jackson": "1.52.2",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
@@ -1,107 +0,0 @@
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
test.describe("Survey Follow-Up Create & Edit", async () => {
// 3 minutes
test.setTimeout(1000 * 60 * 3);
test("Create a follow-up without optional toggles and verify it saves", async ({ page, users }) => {
const user = await users.create();
await user.login();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await test.step("Create a new survey", async () => {
await page.getByText("Start from scratch").click();
await page.getByRole("button", { name: "Create survey", exact: true }).click();
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/edit$/);
});
await test.step("Navigate to Follow-ups tab", async () => {
await page.getByText("Follow-ups").click();
// Verify the empty state is shown
await expect(page.getByText("Send automatic follow-ups")).toBeVisible();
});
await test.step("Create a new follow-up without enabling optional toggles", async () => {
// Click the "New follow-up" button in the empty state
await page.getByRole("button", { name: "New follow-up" }).click();
// Verify the modal is open
await expect(page.getByText("Create a new follow-up")).toBeVisible();
// Fill in the follow-up name
await page.getByPlaceholder("Name your follow-up").fill("Test Follow-Up");
// Leave trigger as default ("Respondent completes survey")
// Leave "Attach response data" toggle OFF (the key scenario for the bug)
// Leave "Include variables" and "Include hidden fields" unchecked
// Click Save
await page.getByRole("button", { name: "Save" }).click();
// The success toast should appear — this was the bug: previously save failed silently
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 5000 });
expect(successToast).toBeTruthy();
});
await test.step("Verify follow-up appears in the list", async () => {
// After creation, the modal closes and the follow-up should appear in the list
await expect(page.getByText("Test Follow-Up")).toBeVisible();
await expect(page.getByText("Any response")).toBeVisible();
await expect(page.getByText("Send email")).toBeVisible();
});
await test.step("Edit the follow-up and verify it saves", async () => {
// Click on the follow-up to edit it
await page.getByText("Test Follow-Up").click();
// Verify the edit modal opens
await expect(page.getByText("Edit this follow-up")).toBeVisible();
// Change the name
const nameInput = page.getByPlaceholder("Name your follow-up");
await nameInput.clear();
await nameInput.fill("Updated Follow-Up");
// Save the edit
await page.getByRole("button", { name: "Save" }).click();
// The success toast should appear
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 5000 });
expect(successToast).toBeTruthy();
// Verify the updated name appears in the list
await expect(page.getByText("Updated Follow-Up")).toBeVisible();
});
await test.step("Create a second follow-up with optional toggles enabled", async () => {
// Click "+ New follow-up" button (now in the non-empty state header)
await page.getByRole("button", { name: /New follow-up/ }).click();
// Verify the modal is open
await expect(page.getByText("Create a new follow-up")).toBeVisible();
// Fill in the follow-up name
await page.getByPlaceholder("Name your follow-up").fill("Follow-Up With Data");
// Enable "Attach response data" toggle
await page.locator("#attachResponseData").click();
// Check both optional checkboxes
await page.locator("#includeVariables").click();
await page.locator("#includeHiddenFields").click();
// Click Save
await page.getByRole("button", { name: "Save" }).click();
// The success toast should appear
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 5000 });
expect(successToast).toBeTruthy();
// Verify both follow-ups appear in the list
await expect(page.getByText("Updated Follow-Up")).toBeVisible();
await expect(page.getByText("Follow-Up With Data")).toBeVisible();
});
});
});
+4 -5
View File
@@ -704,6 +704,10 @@
"example": true,
"type": "boolean"
},
"darkOverlay": {
"example": false,
"type": "boolean"
},
"id": {
"example": "cm6orqtcl000319wj9wb7dltl",
"type": "string"
@@ -712,11 +716,6 @@
"example": true,
"type": "boolean"
},
"overlay": {
"enum": ["none", "light", "dark"],
"example": "none",
"type": "string"
},
"placement": {
"example": "bottomRight",
"type": "string"
+2 -7
View File
@@ -5411,15 +5411,10 @@ components:
type:
- boolean
- "null"
overlay:
darkOverlay:
type:
- string
- boolean
- "null"
enum:
- none
- light
- dark
- null
description: Project specific overwrites
styling:
type:
+1 -3
View File
@@ -80,8 +80,7 @@
"xm-and-surveys/surveys/general-features/email-followups",
"xm-and-surveys/surveys/general-features/quota-management",
"xm-and-surveys/surveys/general-features/spam-protection",
"xm-and-surveys/surveys/general-features/tags",
"xm-and-surveys/surveys/general-features/validation-rules"
"xm-and-surveys/surveys/general-features/tags"
]
},
{
@@ -162,7 +161,6 @@
"xm-and-surveys/core-features/integrations/activepieces",
"xm-and-surveys/core-features/integrations/airtable",
"xm-and-surveys/core-features/integrations/google-sheets",
"xm-and-surveys/core-features/integrations/hubspot",
"xm-and-surveys/core-features/integrations/make",
"xm-and-surveys/core-features/integrations/n8n",
"xm-and-surveys/core-features/integrations/notion",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

@@ -1,183 +0,0 @@
---
title: "HubSpot"
description: "Learn how to integrate Formbricks with HubSpot to automatically create or update contacts when survey responses are submitted."
---
<Note>
Formbricks doesn't have a native HubSpot integration yet. This guide shows you how to connect Formbricks with HubSpot using automation platforms (Make.com, n8n) or custom webhooks.
</Note>
## Prerequisites
Before setting up the integration, you'll need:
1. **A Formbricks account** with at least one survey that collects email addresses
2. **A HubSpot account** with API access
3. **HubSpot API credentials** (see authentication options below)
4. **Automation service** like Make, n8n, or ActivePieces (for no-code methods)
### HubSpot Authentication Options
HubSpot offers two main ways to authenticate API requests:
#### Option A: OAuth 2.0 via Public App (Recommended)
OAuth is the recommended approach for production integrations. When using Make.com or n8n, they handle OAuth authentication for you through their built-in HubSpot connectors.
#### Option B: Legacy Private Apps (Simple Setup)
For custom webhook handlers, you can use a Legacy Private App which provides a static access token. While marked as "legacy," these apps remain fully supported by HubSpot.
To create a Legacy Private App:
<Steps>
<Step title="Open HubSpot Settings">
Go to your HubSpot account **Settings** and navigate to **Integrations** → **Private Apps**.
</Step>
<Step title="Create the App">
Click **Create a private app** and give it a name (e.g., "Formbricks Integration").
</Step>
<Step title="Configure Scopes">
Under **Scopes**, add `crm.objects.contacts.write` and `crm.objects.contacts.read`.
</Step>
<Step title="Get Your Access Token">
Click **Create app** and copy the access token.
</Step>
</Steps>
<Note>
For more information on HubSpot's authentication options, see the [HubSpot Authentication Overview](https://developers.hubspot.com/docs/guides/apps/authentication/intro-to-auth).
</Note>
---
## Method 1: Using Make.com (Recommended for No-Code)
<Note>
Before starting, ensure your survey has clear `questionId` values set. You can only update these before publishing. If your survey is already published, duplicate it and update the question IDs in the copy.
</Note>
<Steps>
<Step title="Set Up Your Survey">
Make sure your survey has meaningful `questionId` values for each question. This makes mapping responses to HubSpot fields easier.
![Update Question ID](/images/xm-and-surveys/core-features/integrations/make/update-question-id.webp)
</Step>
<Step title="Create a New Make.com Scenario">
Go to [Make.com](https://make.com) and create a new scenario. Search for **Formbricks** and select it as your trigger, then choose **Response Finished** as the trigger event.
![Search Formbricks](/images/xm-and-surveys/core-features/integrations/make/search-formbricks.webp)
</Step>
<Step title="Connect Formbricks to Make">
Click **Create a webhook**, enter your Formbricks API Host (default: `https://app.formbricks.com`), add your Formbricks API Key (see [API Key Setup](/api-reference/rest-api#how-to-generate-an-api-key)), and select the survey you want to connect.
![Enter API Key](/images/xm-and-surveys/core-features/integrations/make/enter-api-key-and-host.webp)
</Step>
<Step title="Add the HubSpot Module">
Click the **+** button after the Formbricks trigger, search for **HubSpot**, choose **Create or Update a Contact** as the action, and connect your HubSpot account.
</Step>
<Step title="Map Formbricks Fields to HubSpot">
Map the Formbricks response fields to HubSpot contact properties:
| HubSpot Field | Formbricks Field |
| ------------- | ---------------- |
| Email | `data.email` (your email question ID) |
| First Name | `data.firstName` (if collected) |
| Last Name | `data.lastName` (if collected) |
| Custom Property | Any other `data.*` field |
You can also map metadata: `meta.country`, `meta.userAgent.browser`, `survey.title`.
</Step>
<Step title="Test and Activate">
Submit a test response to your Formbricks survey, verify the contact appears in HubSpot, and turn on your Make scenario.
</Step>
</Steps>
---
## Method 2: Using n8n (Self-Hosted Option)
<Note>
The Formbricks n8n node is available as a community node. Install it via **Settings** → **Community Nodes** → install `@formbricks/n8n-nodes-formbricks`.
</Note>
<Steps>
<Step title="Set Up the Formbricks Trigger">
Create a new workflow in n8n, add the **Formbricks** trigger node, connect it with your Formbricks API Key and host, select **Response Finished** as the event, and choose your survey.
![Add Formbricks Trigger](/images/xm-and-surveys/core-features/integrations/n8n/add-formbricks-trigger.webp)
</Step>
<Step title="Add the HubSpot Node">
Add a new node and search for **HubSpot**, select **Create/Update Contact** as the operation, and connect your HubSpot account (n8n supports both OAuth and access token authentication).
</Step>
<Step title="Configure Field Mapping">
In the HubSpot node, map the fields:
```
Email: {{ $json.data.email }}
First Name: {{ $json.data.firstName }}
Last Name: {{ $json.data.lastName }}
```
For custom HubSpot properties, use the **Additional Fields** section to add mappings like `survey_source`, `response_id`, and `submission_date`.
</Step>
<Step title="Test Your Workflow">
Click **Listen for event** in the Formbricks trigger, submit a test survey response, verify the data flows through to HubSpot, and activate your workflow.
</Step>
</Steps>
---
## Method 3: Using Webhooks (Custom Integration)
For maximum flexibility, you can use Formbricks webhooks with a custom endpoint that calls the HubSpot API directly. This approach is ideal for developers who want full control.
<Note>
This method requires a HubSpot access token. You can use a Legacy Private App token (simplest) or implement OAuth 2.0 for production applications.
</Note>
<Steps>
<Step title="Create a Formbricks Webhook">
Go to **Configuration** → **Integrations** in Formbricks, click **Manage Webhooks** → **Add Webhook**, enter your endpoint URL, select **Response Finished** as the trigger, and choose the surveys to monitor.
![Integrations Tab](/images/xm-and-surveys/core-features/integrations/webhooks/integrations-tab.webp)
</Step>
<Step title="Build Your Webhook Handler">
Your webhook handler needs to:
- **Receive the Formbricks webhook** - Accept POST requests with the survey response payload
- **Extract contact data** - Parse the email and other fields from `data.data` (keyed by your `questionId` values)
- **Call the HubSpot API** - Use the [HubSpot Contacts API](https://developers.hubspot.com/docs/api/crm/contacts) to create or update contacts
- **Handle duplicates** - HubSpot returns a 409 error if a contact with that email exists; search and update instead
You can deploy using serverless functions (Vercel, AWS Lambda, Cloudflare Workers), traditional servers, or low-code platforms. For webhook signature verification, see the [Webhooks documentation](/xm-and-surveys/core-features/integrations/webhooks).
</Step>
<Step title="Deploy and Test">
Deploy your webhook handler to a publicly accessible URL, add the URL to your Formbricks webhook configuration, submit a test survey response, and verify the contact appears in HubSpot.
</Step>
</Steps>
---
## Troubleshooting
### Contact Not Created in HubSpot
1. **Check the email field**: Ensure your survey has an email question and you're mapping the correct `questionId`
2. **Verify API token**: Make sure your HubSpot access token has the required scopes (`crm.objects.contacts.write` and `crm.objects.contacts.read`)
3. **Check for duplicates**: HubSpot returns a 409 error if a contact with that email already exists
### Webhook Not Triggering
1. Verify the webhook URL is publicly accessible
2. Check that **Response Finished** trigger is selected
3. Ensure the survey is linked to the webhook
### Testing Your Integration
1. Use a unique test email for each test
2. Check HubSpot's **Contacts** page after submitting a response
3. Review your webhook handler logs for errors
---
Still struggling or something not working as expected? [Join our GitHub Discussions](https://github.com/formbricks/formbricks/discussions) and we're happy to help!
@@ -15,8 +15,6 @@ At Formbricks, we understand the importance of integrating with third-party appl
* [Google Sheets](/xm-and-surveys/core-features/integrations/google-sheets): Automatically send responses to a Google Sheet of your choice.
* [HubSpot](/xm-and-surveys/core-features/integrations/hubspot): Create or update HubSpot contacts automatically when survey responses are submitted.
* [Make](/xm-and-surveys/core-features/integrations/make): Leverage Make's powerful automation capabilities to automate your workflows.
* [n8n](/xm-and-surveys/core-features/integrations/n8n)(Open Source): Automate workflows with n8n's no-code automation tool
@@ -1,185 +0,0 @@
---
title: "Validation Rules"
description: "Validation rules help you ensure that respondents provide data in the correct format and within expected constraints"
icon: "check-double"
---
By adding validation rules to your questions, you can improve data quality, reduce errors, and create a better survey experience.
![Validation Rules Editor](/images/xm-and-surveys/core-features/validation-rules/editor.webp)
## How Validation Rules Work
Validation rules are evaluated when a respondent submits their answer. If the answer doesn't meet the validation criteria, an error message is displayed and the respondent must correct their input before proceeding.
You can combine multiple validation rules using **All are true** or **Any is true** logic:
- **All are true**: All rules must pass for the response to be valid
- **Any is true**: At least one rule must pass for the response to be valid
## Available Validation Rules by Question Type
### Free Text Questions
Free text questions support different validation rules based on the input type:
#### Text Input Type
| Rule | Description | Example |
|------|-------------|---------|
| At least (characters) | Requires at least N characters | At least 10 characters for detailed feedback |
| At most (characters) | Limits response to N characters | At most 500 characters for short answers |
| Matches Regex Pattern | Matches a regular expression pattern | Custom format validation |
| Is | Exact match required | Must equal "CONFIRM" |
| Is not | Must not match the value | Cannot be "N/A" |
| Contains | Must include the substring | Must contain "@company.com" |
| Does not contain | Must not include the substring | Cannot contain profanity |
#### Email Input Type
Email input automatically validates email format. Additional rules available:
- At least, At most (characters)
- Matches Regex Pattern, Is, Is not
- Contains, Does not contain
#### URL Input Type
URL input automatically validates URL format. Additional rules available:
- At least, At most (characters)
- Matches Regex Pattern, Is, Is not
- Contains, Does not contain
#### Phone Input Type
Phone input automatically validates phone number format. Additional rules available:
- At least, At most (characters)
- Matches Regex Pattern, Is, Is not
- Contains, Does not contain
#### Number Input Type
| Rule | Description | Example |
|------|-------------|---------|
| At least | Number must be at least N | Age must be at least 18 |
| At most | Number cannot exceed N | Quantity at most 100 |
| Is | Number must equal N | Quantity is 1 |
| Is not | Number must not equal N | Cannot be 0 |
### Multiple Choice (Multi-Select) Questions
| Rule | Description | Example |
|------|-------------|---------|
| At least (options selected) | Require at least N selections | At least 2 options selected |
| At most (options selected) | Limit to N selections | At most 3 options selected |
### Picture Selection Questions
| Rule | Description | Example |
|------|-------------|---------|
| At least (options selected) | Require at least N pictures | At least 1 design selected |
| At most (options selected) | Limit to N pictures | At most 2 favorites selected |
### Date Questions
| Rule | Description | Example |
|------|-------------|---------|
| Is later than | Date must be after specified date | Must be after today |
| Is earlier than | Date must be before specified date | Must be before Dec 31, 2025 |
| Is between | Date must be within range | Between Jan 1 and Dec 31 |
| Is not between | Date must be outside range | Cannot be during holidays |
<Note>
Date values should be specified in YYYY-MM-DD format (e.g., 2025-01-15).
</Note>
### Matrix Questions
| Rule | Description | Example |
|------|-------------|---------|
| Minimum rows answered | Require at least N rows to be answered | Answer at least 3 rows |
| Answer all rows | All rows must have a selection | Complete the entire matrix |
### Ranking Questions
| Rule | Description | Example |
|------|-------------|---------|
| Minimum options ranked | Require at least N items to be ranked | Rank your top 3 |
| Rank all options | All options must be ranked | Rank all 5 items |
### File Upload Questions
| Rule | Description | Example |
|------|-------------|---------|
| File extension is | Only allow specific file types | Only .pdf, .docx allowed |
| File extension is not | Block specific file types | No .exe files |
<Note>
File size limits are configured separately in the question settings using the "Maximum file size" option.
</Note>
### Address Questions
Each address field (Address Line 1, Address Line 2, City, State, ZIP, Country) can have its own validation rules:
- At least, At most (characters)
- Matches Regex Pattern
- Is, Is not
- Contains, Does not contain
### Contact Info Questions
Each contact field can have specific validation rules:
**First Name, Last Name, Company**:
- At least, At most (characters)
- Matches Regex Pattern, Is, Is not
- Contains, Does not contain
**Email**: Automatically validates email format, plus text rules above
**Phone**: Automatically validates phone format, plus text rules above
## Adding Validation Rules
<Steps>
<Step title="Open the Question Settings">
Click on the question you want to validate to open its settings panel.
</Step>
<Step title="Navigate to Validation Rules">
Scroll down to find the "Validation Rules" section and click to expand it.
</Step>
<Step title="Add a Rule">
Click the "Add rule" button to add a new validation rule.
</Step>
<Step title="Configure the Rule">
Select the rule type from the dropdown and enter the required value (if applicable).
</Step>
<Step title="Set Logic (Optional)">
If you have multiple rules, choose whether they should be combined with "All are true" or "Any is true" logic.
</Step>
<Step title="Save Your Survey">
Click "Save" to apply the validation rules to your survey.
</Step>
</Steps>
## Error Messages
Formbricks automatically generates user-friendly error messages based on your validation rules. Error messages are displayed below the input field when validation fails.
Example error messages:
- "Must be at least 10 characters"
- "Must be a valid email address"
- "Please select at least 2 options"
- "Date must be after 2025-01-01"
## Multi-Language Support
Validation rules work with multi-language surveys. Error messages are automatically displayed in the respondent's selected language.
## Combining Multiple Rules
When using multiple validation rules:
**All are true**: Use when all conditions must be met.
- Example: Text must be at least 10 characters AND contain "@email.com"
**Any is true**: Use when any condition is acceptable.
- Example: Date is earlier than 2025-01-01 OR is later than 2025-12-31
+4 -7
View File
@@ -73,9 +73,9 @@
]
},
"engines": {
"node": ">=20.0.0"
"node": ">=16.0.0"
},
"packageManager": "pnpm@10.28.2",
"packageManager": "pnpm@9.15.9",
"nextBundleAnalysis": {
"budget": 358400,
"budgetPercentIncreaseRed": 20,
@@ -90,13 +90,10 @@
"tar-fs": "2.1.4",
"typeorm": ">=0.3.26",
"systeminformation": "5.27.14",
"qs": ">=6.14.1",
"preact": ">=10.26.10",
"fast-xml-parser": ">=5.3.4",
"diff": ">=8.0.3"
"qs": ">=6.14.1"
},
"comments": {
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | tar-fs (Dependabot #205) - awaiting upstream dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | systeminformation (Dependabot #241) - awaiting @opentelemetry/host-metrics update | qs (Dependabot #245) - awaiting googleapis-common and stripe updates | preact (Dependabot #247) - awaiting next-auth update | fast-xml-parser (Dependabot #270) - awaiting @boxyhq/saml-jackson update | diff (Dependabot #269) - awaiting @microsoft/api-extractor update"
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | tar-fs (Dependabot #205) - awaiting upstream dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | systeminformation (Dependabot #241) - awaiting @opentelemetry/host-metrics update | qs (Dependabot #245) - awaiting googleapis-common and stripe updates"
},
"patchedDependencies": {
"next-auth@4.24.12": "patches/next-auth@4.24.12.patch"
+1
View File
@@ -10,6 +10,7 @@
"eslint-config-next": "15.3.2",
"eslint-config-prettier": "10.1.5",
"eslint-config-turbo": "2.5.3",
"eslint-plugin-i18n-json": "4.0.1",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
@@ -1,45 +0,0 @@
-- AlterTable: Add overlay column (enum), migrate data, drop darkOverlay
-- Step 1: Create the SurveyOverlay enum type
CREATE TYPE "SurveyOverlay" AS ENUM ('none', 'light', 'dark');
-- Step 2: Add the new overlay column with the enum type and default value
ALTER TABLE "Project" ADD COLUMN "overlay" "SurveyOverlay" NOT NULL DEFAULT 'none';
-- Step 3: Migrate existing data
-- For center placement: darkOverlay=true -> 'dark', darkOverlay=false -> 'light'
-- For other placements: always 'none' (since overlay wasn't shown before)
UPDATE "Project"
SET "overlay" = CASE
WHEN "placement" = 'center' AND "darkOverlay" = true THEN 'dark'::"SurveyOverlay"
WHEN "placement" = 'center' AND "darkOverlay" = false THEN 'light'::"SurveyOverlay"
ELSE 'none'::"SurveyOverlay"
END;
-- Step 4: Drop the old darkOverlay column
ALTER TABLE "Project" DROP COLUMN "darkOverlay";
-- Step 5: Migrate Survey.projectOverwrites JSON field
-- Only convert darkOverlay -> overlay when placement is explicitly 'center' in projectOverwrites
-- For all other cases, just remove darkOverlay (survey will inherit overlay from project)
-- Case 5a: Survey has placement: 'center' explicitly in projectOverwrites - convert darkOverlay to overlay
UPDATE "Survey"
SET "projectOverwrites" = jsonb_set(
"projectOverwrites"::jsonb - 'darkOverlay',
'{overlay}',
CASE
WHEN ("projectOverwrites"::jsonb->>'darkOverlay') = 'true' THEN '"dark"'::jsonb
ELSE '"light"'::jsonb
END
)
WHERE "projectOverwrites" IS NOT NULL
AND "projectOverwrites"::jsonb ? 'darkOverlay'
AND ("projectOverwrites"::jsonb->>'placement') = 'center';
-- Case 5b: Any remaining surveys with darkOverlay (placement != 'center' or not present) - just remove darkOverlay
-- These surveys will inherit the overlay setting from their project
UPDATE "Survey"
SET "projectOverwrites" = "projectOverwrites"::jsonb - 'darkOverlay'
WHERE "projectOverwrites" IS NOT NULL
AND "projectOverwrites"::jsonb ? 'darkOverlay';
+1 -7
View File
@@ -593,12 +593,6 @@ enum WidgetPlacement {
center
}
enum SurveyOverlay {
none
light
dark
}
/// Main grouping mechanism for resources in Formbricks.
/// Each organization can have multiple projects to separate different applications or products.
///
@@ -627,7 +621,7 @@ model Project {
inAppSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in in-app surveys
placement WidgetPlacement @default(bottomRight)
clickOutsideClose Boolean @default(true)
overlay SurveyOverlay @default(none)
darkOverlay Boolean @default(false)
languages Language[]
/// [Logo]
logo Json?
@@ -170,7 +170,7 @@ const runSingleMigration = async (migration: MigrationScript, index: number): Pr
// Run Prisma migrate
// throws when migrate deploy fails
await execAsync(`prisma migrate deploy --schema="${PRISMA_SCHEMA_PATH}"`);
await execAsync(`pnpm prisma migrate deploy --schema="${PRISMA_SCHEMA_PATH}"`);
logger.info(`Successfully applied schema migration: ${migration.name}`);
} catch (err) {
logger.error(err, `Schema migration ${migration.name} failed`);
+1 -2
View File
@@ -2,7 +2,6 @@
import { SurveyStatus, SurveyType } from "@prisma/client";
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZOverlay } from "../../types/common";
// eslint-disable-next-line import/no-relative-packages -- Need to import from parent package
import { ZLogo } from "../../types/styling";
import { ZSurveyBlocks } from "../../types/surveys/blocks";
@@ -154,7 +153,7 @@ const ZSurveyBase = z.object({
highlightBorderColor: ZColor.nullish(),
placement: ZPlacement.nullish(),
clickOutsideClose: z.boolean().nullish(),
overlay: ZOverlay.nullish(),
darkOverlay: z.boolean().nullish(),
})
.nullable()
.openapi({
+1 -1
View File
@@ -115,7 +115,7 @@ export const setup = async (
const expiresAt = existingConfig.status.expiresAt;
if (expiresAt && !isNowExpired(new Date(expiresAt))) {
if (expiresAt && isNowExpired(new Date(expiresAt))) {
console.error("🧱 Formbricks - Error state is not expired, skipping initialization");
return okVoid();
}
@@ -86,7 +86,7 @@ export const mockConfig: TConfig = {
id: mockProjectId,
recontactDays: 14,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
placement: "bottomRight",
inAppSurveyBranding: true,
styling: {
@@ -262,7 +262,7 @@ describe("api.ts", () => {
id: "project123",
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
placement: "bottomRight",
inAppSurveyBranding: true,
styling: {
@@ -6,7 +6,7 @@ import { addCleanupEventListeners, addEventListeners } from "@/lib/common/event-
import { Logger } from "@/lib/common/logger";
import { handleErrorOnFirstSetup, setup, tearDown } from "@/lib/common/setup";
import { setIsSetup } from "@/lib/common/status";
import { filterSurveys, getIsDebug, isNowExpired } from "@/lib/common/utils";
import { filterSurveys, isNowExpired } from "@/lib/common/utils";
import type * as Utils from "@/lib/common/utils";
import { fetchEnvironmentState } from "@/lib/environment/state";
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
@@ -56,7 +56,6 @@ vi.mock("@/lib/common/utils", async (importOriginal) => {
...originalModule,
filterSurveys: vi.fn(),
isNowExpired: vi.fn(),
getIsDebug: vi.fn(),
};
});
@@ -87,7 +86,6 @@ describe("setup.ts", () => {
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
getInstanceLoggerMock = vi.spyOn(Logger, "getInstance").mockReturnValue(mockLogger as unknown as Logger);
(getIsDebug as unknown as Mock).mockReturnValue(false);
});
afterEach(() => {
@@ -119,8 +117,7 @@ describe("setup.ts", () => {
}
});
test("skips setup if existing config is in error state and not expired (debug mode)", async () => {
(getIsDebug as unknown as Mock).mockReturnValue(true);
test("skips setup if existing config is in error state and not expired", async () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
environmentId: "env_123",
@@ -134,7 +131,7 @@ describe("setup.ts", () => {
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
(isNowExpired as unknown as Mock).mockReturnValue(false); // Not expired
(isNowExpired as unknown as Mock).mockReturnValue(true);
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
expect(result.ok).toBe(true);
@@ -143,59 +140,6 @@ describe("setup.ts", () => {
);
});
test("skips initialization if error state is active (not expired)", async () => {
(getIsDebug as unknown as Mock).mockReturnValue(false);
const mockConfig = {
get: vi.fn().mockReturnValue({
environmentId: "env_123",
appUrl: "https://my.url",
environment: {},
user: { data: {}, expiresAt: null },
status: { value: "error", expiresAt: new Date(Date.now() + 10000) },
}),
resetConfig: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
(isNowExpired as unknown as Mock).mockReturnValue(false); // Time is NOT up
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
expect(result.ok).toBe(true);
// Should NOT fetch environment or user state
expect(fetchEnvironmentState).not.toHaveBeenCalled();
expect(mockConfig.resetConfig).not.toHaveBeenCalled();
});
test("continues initialization if error state is expired", async () => {
(getIsDebug as unknown as Mock).mockReturnValue(false);
const mockConfig = {
get: vi.fn().mockReturnValue({
environmentId: "env_123",
appUrl: "https://my.url",
environment: { data: { surveys: [] }, expiresAt: new Date() },
user: { data: {}, expiresAt: null },
status: { value: "error", expiresAt: new Date(Date.now() - 10000) },
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
(isNowExpired as unknown as Mock).mockReturnValue(true); // Time IS up
// Mock successful fetch to allow setup to proceed
(fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({
ok: true,
data: { data: { surveys: [] }, expiresAt: new Date() },
});
(filterSurveys as unknown as Mock).mockReturnValue([]);
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
expect(result.ok).toBe(true);
expect(fetchEnvironmentState).toHaveBeenCalled();
});
test("uses existing config if environmentId/appUrl match, checks for expiration sync", async () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
@@ -170,7 +170,7 @@ describe("utils.ts", () => {
id: mockProjectId,
recontactDays: 7, // fallback if survey doesn't have it
clickOutsideClose: false,
overlay: "none",
darkOverlay: false,
placement: "bottomRight",
inAppSurveyBranding: true,
styling: { allowStyleOverwrite: false },
@@ -97,7 +97,7 @@ describe("widget-file", () => {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
placement: "bottomRight",
inAppSurveyBranding: true,
},
@@ -163,7 +163,7 @@ describe("widget-file", () => {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
placement: "bottomRight",
inAppSurveyBranding: true,
},
@@ -209,7 +209,7 @@ describe("widget-file", () => {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
placement: "bottomRight",
inAppSurveyBranding: true,
},
+2 -2
View File
@@ -84,7 +84,7 @@ export const renderWidget = async (
const projectOverwrites = survey.projectOverwrites ?? {};
const clickOutside = projectOverwrites.clickOutsideClose ?? project.clickOutsideClose;
const overlay = projectOverwrites.overlay ?? project.overlay;
const darkOverlay = projectOverwrites.darkOverlay ?? project.darkOverlay;
const placement = projectOverwrites.placement ?? project.placement;
const isBrandingEnabled = project.inAppSurveyBranding;
const formbricksSurveys = await loadFormbricksSurveysExternally();
@@ -110,7 +110,7 @@ export const renderWidget = async (
survey,
isBrandingEnabled,
clickOutside,
overlay,
darkOverlay,
languageCode,
placement,
styling: getStyling(project, survey),
+1 -1
View File
@@ -32,7 +32,7 @@ export type TEnvironmentStateSurvey = Pick<
export type TEnvironmentStateProject = Pick<
Project,
"id" | "recontactDays" | "clickOutsideClose" | "overlay" | "placement" | "inAppSurveyBranding"
"id" | "recontactDays" | "clickOutsideClose" | "darkOverlay" | "placement" | "inAppSurveyBranding"
> & {
styling: TProjectStyling;
};
+3 -3
View File
@@ -37,9 +37,9 @@
"author": "Formbricks <hola@formbricks.com>",
"dependencies": {
"@formbricks/logger": "workspace:*",
"@aws-sdk/client-s3": "3.971.0",
"@aws-sdk/s3-presigned-post": "3.971.0",
"@aws-sdk/s3-request-presigner": "3.971.0"
"@aws-sdk/client-s3": "3.879.0",
"@aws-sdk/s3-presigned-post": "3.879.0",
"@aws-sdk/s3-request-presigner": "3.879.0"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
@@ -33,7 +33,7 @@ export function Headline({
<label htmlFor={elementId} className="text-heading mb-[3px] flex flex-col">
{hasRequiredRule && isQuestionCard && (
<span
className="mb-[3px] text-xs font-normal leading-6 opacity-60"
className="mb-[3px] text-xs leading-6 font-normal opacity-60"
tabIndex={-1}
data-testid="fb__surveys__headline-optional-text-test">
{t("common.required")}
@@ -52,13 +52,11 @@ export function RenderSurvey(props: SurveyContainerProps) {
return null;
}
const hasOverlay = props.overlay && props.overlay !== "none";
return (
<SurveyContainer
mode={props.mode ?? "modal"}
placement={props.placement}
overlay={props.overlay}
darkOverlay={props.darkOverlay}
clickOutside={props.clickOutside}
onClose={close}
isOpen={isOpen}
@@ -66,7 +64,7 @@ export function RenderSurvey(props: SurveyContainerProps) {
{/* @ts-expect-error -- TODO: fix this */}
<Survey
{...props}
clickOutside={hasOverlay ? props.clickOutside : true}
clickOutside={props.placement === "center" ? props.clickOutside : true}
onClose={close}
onFinished={() => {
props.onFinished?.();
@@ -425,10 +425,7 @@ export function Survey({
const firstEndingId = survey.endings.length > 0 ? survey.endings[0].id : undefined;
if (blockId === "start")
return {
nextBlockId: localSurvey.blocks[0]?.id || firstEndingId,
calculatedVariables: {},
};
return { nextBlockId: localSurvey.blocks[0]?.id || firstEndingId, calculatedVariables: {} };
if (!currentBlock) {
console.error(
@@ -679,7 +676,7 @@ export function Survey({
setBlockId(nextBlockId);
} else if (finished) {
// Survey is finished, show the first ending or set to a value > blocks.length
const firstEndingId = localSurvey.endings[0]?.id as string | undefined;
const firstEndingId = localSurvey.endings[0]?.id;
if (firstEndingId) {
setBlockId(firstEndingId);
} else {
@@ -693,7 +690,7 @@ export function Survey({
};
const onBack = (): void => {
let prevBlockId: string | undefined;
let prevBlockId;
// use history if available
if (history.length > 0) {
const newHistory = [...history];
@@ -1,11 +1,11 @@
import { useEffect, useRef } from "preact/hooks";
import { type TOverlay, type TPlacement } from "@formbricks/types/common";
import { type TPlacement } from "@formbricks/types/common";
import { cn } from "@/lib/utils";
interface SurveyContainerProps {
mode: "modal" | "inline";
placement?: TPlacement;
overlay?: TOverlay;
darkOverlay?: boolean;
children: React.ReactNode;
onClose?: () => void;
clickOutside?: boolean;
@@ -16,7 +16,7 @@ interface SurveyContainerProps {
export function SurveyContainer({
mode,
placement = "bottomRight",
overlay = "none",
darkOverlay = false,
children,
onClose,
clickOutside,
@@ -24,16 +24,16 @@ export function SurveyContainer({
dir = "auto",
}: Readonly<SurveyContainerProps>) {
const modalRef = useRef<HTMLDivElement>(null);
const isCenter = placement === "center";
const isModal = mode === "modal";
const hasOverlay = overlay !== "none";
useEffect(() => {
if (!isModal) return;
if (!clickOutside) return;
if (!hasOverlay) return;
if (!isCenter) return;
const handleClickOutside = (e: MouseEvent) => {
if (
clickOutside &&
isOpen &&
modalRef.current &&
!(modalRef.current as HTMLElement).contains(e.target as Node) &&
@@ -42,12 +42,11 @@ export function SurveyContainer({
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [clickOutside, onClose, isModal, isOpen]);
}, [clickOutside, onClose, isCenter, isModal, isOpen]);
const getPlacementStyle = (placement: TPlacement): string => {
switch (placement) {
@@ -81,14 +80,15 @@ export function SurveyContainer({
<div
aria-live="assertive"
className={cn(
hasOverlay ? "pointer-events-auto" : "pointer-events-none",
isModal && "z-999999 fixed inset-0 flex items-end"
isCenter ? "pointer-events-auto" : "pointer-events-none",
isModal && "fixed inset-0 z-999999 flex items-end"
)}>
<div
className={cn(
"relative h-full w-full transition-all duration-500 ease-in-out",
isModal && overlay === "dark" ? "bg-slate-700/80" : "",
isModal && overlay === "light" ? "bg-slate-400/50" : ""
"relative h-full w-full",
!isCenter ? "bg-none transition-all duration-500 ease-in-out" : "",
isModal && isCenter && darkOverlay ? "bg-slate-700/80" : "",
isModal && isCenter && !darkOverlay ? "bg-white/50" : ""
)}>
<div
ref={modalRef}
+4 -4
View File
@@ -17,7 +17,7 @@ export const renderSurveyInline = (props: SurveyContainerProps) => {
export const renderSurvey = (props: SurveyContainerProps) => {
// render SurveyNew
// if survey type is link, we don't pass the placement, overlay, clickOutside, onClose
// if survey type is link, we don't pass the placement, darkOverlay, clickOutside, onClose
const { mode, containerId, languageCode } = props;
@@ -36,9 +36,9 @@ export const renderSurvey = (props: SurveyContainerProps) => {
throw new Error(`renderSurvey: Element with id ${containerId} not found.`);
}
// if survey type is link, we don't pass the placement, overlay, clickOutside, onClose
// if survey type is link, we don't pass the placement, darkOverlay, clickOutside, onClose
if (props.survey.type === "link") {
const { placement, overlay, onClose, clickOutside, ...surveyInlineProps } = props;
const { placement, darkOverlay, onClose, clickOutside, ...surveyInlineProps } = props;
render(
h(
@@ -52,7 +52,7 @@ export const renderSurvey = (props: SurveyContainerProps) => {
);
} else {
// For non-link surveys, pass placement through so it can be used in StackedCard
const { overlay, onClose, clickOutside, ...surveyInlineProps } = props;
const { darkOverlay, onClose, clickOutside, ...surveyInlineProps } = props;
render(
h(
-4
View File
@@ -20,10 +20,6 @@ export const ZPlacement = z.enum(["bottomLeft", "bottomRight", "topLeft", "topRi
export type TPlacement = z.infer<typeof ZPlacement>;
export const ZOverlay = z.enum(["none", "light", "dark"]);
export type TOverlay = z.infer<typeof ZOverlay>;
export const ZId = z.string().cuid2();
export const ZUuid = z.string().uuid();
+2 -2
View File
@@ -40,7 +40,7 @@ export interface SurveyInlineProps extends SurveyBaseProps {
export interface SurveyModalProps extends SurveyBaseProps {
clickOutside: boolean;
overlay: "none" | "light" | "dark";
darkOverlay: boolean;
placement: "bottomLeft" | "bottomRight" | "topLeft" | "topRight" | "center";
}
@@ -56,7 +56,7 @@ export interface SurveyContainerProps extends Omit<SurveyBaseProps, "onFileUploa
onOpenExternalURL?: (url: string) => void | Promise<void>;
mode?: "modal" | "inline";
containerId?: string;
overlay?: "none" | "light" | "dark";
darkOverlay?: boolean;
placement?: "bottomLeft" | "bottomRight" | "topLeft" | "topRight" | "center";
action?: string;
singleUseId?: string;
+1 -1
View File
@@ -52,7 +52,7 @@ export const ZJsEnvironmentStateProject = ZProject.pick({
id: true,
recontactDays: true,
clickOutsideClose: true,
overlay: true,
darkOverlay: true,
placement: true,
inAppSurveyBranding: true,
styling: true,
+3 -3
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { ZColor, ZOverlay, ZPlacement } from "./common";
import { ZColor, ZPlacement } from "./common";
import { ZEnvironment } from "./environment";
import { ZBaseStyling, ZLogo } from "./styling";
@@ -65,7 +65,7 @@ export const ZProject = z.object({
config: ZProjectConfig,
placement: ZPlacement,
clickOutsideClose: z.boolean(),
overlay: ZOverlay,
darkOverlay: z.boolean(),
environments: z.array(ZEnvironment),
languages: z.array(ZLanguage),
logo: ZLogo.nullish(),
@@ -84,7 +84,7 @@ export const ZProjectUpdateInput = z.object({
config: ZProjectConfig.optional(),
placement: ZPlacement.optional(),
clickOutsideClose: z.boolean().optional(),
overlay: ZOverlay.optional(),
darkOverlay: z.boolean().optional(),
environments: z.array(ZEnvironment).optional(),
styling: ZProjectStyling.optional(),
logo: ZLogo.optional(),
+4 -4
View File
@@ -1,7 +1,7 @@
import { type ZodIssue, z } from "zod";
import { ZSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { ZActionClass, ZActionClassNoCodeConfig } from "../action-classes";
import { ZColor, ZEndingCardUrl, ZId, ZOverlay, ZPlacement, ZUrl, getZSafeUrl } from "../common";
import { ZColor, ZEndingCardUrl, ZId, ZPlacement, ZUrl, getZSafeUrl } from "../common";
import { ZContactAttributes } from "../contact-attribute";
import { type TI18nString, ZI18nString } from "../i18n";
import { ZLanguage } from "../project";
@@ -228,7 +228,7 @@ export const ZSurveyProjectOverwrites = z.object({
highlightBorderColor: ZColor.nullish(),
placement: ZPlacement.nullish(),
clickOutsideClose: z.boolean().nullish(),
overlay: ZOverlay.nullish(),
darkOverlay: z.boolean().nullish(),
});
export type TSurveyProjectOverwrites = z.infer<typeof ZSurveyProjectOverwrites>;
@@ -2866,7 +2866,7 @@ const validateLogicFallback = (survey: TSurvey, questionIdx: number): z.ZodIssue
}
});
survey.endings.forEach((e: TSurveyEnding) => {
survey.endings.forEach((e) => {
possibleFallbackIds.push(e.id);
});
@@ -3697,7 +3697,7 @@ const validateBlockLogicFallback = (
}
});
survey.endings.forEach((e: TSurveyEnding) => {
survey.endings.forEach((e) => {
possibleFallbackIds.push(e.id);
});
+759 -1050
View File
File diff suppressed because it is too large Load Diff
-9
View File
@@ -1,12 +1,3 @@
packages:
- "apps/*"
- "packages/*"
# Allow lifecycle scripts for packages that need to build native binaries
# Required for pnpm v10+ which blocks scripts by default
onlyBuiltDependencies:
- sharp
- esbuild
- prisma
- "@prisma/client"
- "@prisma/engines"