mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-20 19:30:41 -05:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf6acec10b | |||
| 62e2540511 | |||
| 12bf5b71cf | |||
| f9b84b718c |
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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/
|
||||
|
||||
+1
-1
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,7 +22,7 @@ const selectProject = {
|
||||
config: true,
|
||||
placement: true,
|
||||
clickOutsideClose: true,
|
||||
overlay: true,
|
||||
darkOverlay: true,
|
||||
environments: true,
|
||||
styling: true,
|
||||
logo: true,
|
||||
|
||||
@@ -85,7 +85,7 @@ export const mockProject: TProject = {
|
||||
inAppSurveyBranding: false,
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: false,
|
||||
overlay: "none",
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
languages: [],
|
||||
config: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "カスタムスタイルを有効化",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ă",
|
||||
|
||||
@@ -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": "Включить пользовательское оформление",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "启用自定义样式",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
+11
-32
@@ -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");
|
||||
+7
-14
@@ -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: [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
)
|
||||
|
||||
-2
@@ -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);
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Vendored
+1
-1
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||

|
||||
</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.
|
||||
|
||||

|
||||
</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.
|
||||
|
||||

|
||||
</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.
|
||||
|
||||

|
||||
</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.
|
||||
|
||||

|
||||
</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.
|
||||
|
||||

|
||||
|
||||
## 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
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
-45
@@ -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';
|
||||
@@ -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`);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Generated
+759
-1050
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user