mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-21 03:03:25 -05:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44dcd2c2f9 | |||
| 04220902b4 | |||
| 4649a2de3e | |||
| 56ce05fb94 | |||
| 1b81e68106 | |||
| 202958cac2 | |||
| 8e901fb3c9 | |||
| 29afb3e4e9 | |||
| 38a3b31761 | |||
| 2bfb79d999 | |||
| 7971b9b312 | |||
| 1143f58ba5 | |||
| 47fe3c73dd | |||
| 1c97ab3579 |
@@ -32,21 +32,20 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
node-version: 18
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
||||
- name: Setup Node.js 22.x
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
with:
|
||||
version: 9.15.9
|
||||
node-version: 22.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Validate translation keys
|
||||
run: |
|
||||
|
||||
@@ -1,20 +1,4 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+10
-18
@@ -1,4 +1,4 @@
|
||||
FROM node:22-alpine3.22 AS base
|
||||
FROM node:24-alpine3.23 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@9.15.9 --activate
|
||||
RUN corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
# Install necessary build tools and compilers
|
||||
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
||||
@@ -69,20 +69,14 @@ 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
|
||||
|
||||
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 \
|
||||
# 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 \
|
||||
&& addgroup -S nextjs \
|
||||
&& adduser -S -u 1001 -G nextjs nextjs
|
||||
|
||||
@@ -113,15 +107,13 @@ 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
|
||||
|
||||
@@ -134,7 +126,9 @@ 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
|
||||
|
||||
RUN npm install -g prisma@6
|
||||
# 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
|
||||
|
||||
# Create a startup script to handle the conditional logic
|
||||
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
||||
@@ -144,10 +138,8 @@ 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 corepack prepare pnpm@9.15.9 --activate && \
|
||||
mkdir -p /home/nextjs/apps/web/uploads/ && \
|
||||
RUN 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,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
environments: [],
|
||||
languages: [],
|
||||
logo: null,
|
||||
|
||||
@@ -64,7 +64,7 @@ const mockProject = {
|
||||
linkSurveyBranding: true,
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
languages: [],
|
||||
} as unknown as TProject;
|
||||
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
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,
|
||||
darkOverlay: true,
|
||||
overlay: 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,
|
||||
darkOverlay: environmentData.project.darkOverlay,
|
||||
overlay: environmentData.project.overlay,
|
||||
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,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
styling: {
|
||||
allowStyleOverwrite: false,
|
||||
},
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { TResponse, TResponseUpdateInput, ZResponseUpdateInput } 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 { 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";
|
||||
@@ -31,6 +33,38 @@ 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,
|
||||
@@ -113,6 +147,11 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const validationResult = validateResponse(response, survey, inputValidation.data);
|
||||
if (validationResult) {
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
// update response with quota evaluation
|
||||
let updatedResponse;
|
||||
try {
|
||||
|
||||
@@ -6,12 +6,14 @@ 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";
|
||||
@@ -33,6 +35,27 @@ 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;
|
||||
@@ -123,6 +146,11 @@ 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 "@vercel/og";
|
||||
import { ImageResponse } from "next/og";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const GET = async (req: NextRequest) => {
|
||||
|
||||
@@ -8,10 +8,7 @@ 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/v2/management/responses/lib/validation";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
||||
@@ -149,6 +146,7 @@ export const PUT = withV1ApiWrapper({
|
||||
result.survey.blocks,
|
||||
responseUpdate.data,
|
||||
responseUpdate.language ?? "en",
|
||||
responseUpdate.finished,
|
||||
result.survey.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -7,10 +7,7 @@ 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/v2/management/responses/lib/validation";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import {
|
||||
@@ -158,6 +155,7 @@ export const POST = withV1ApiWrapper({
|
||||
surveyResult.survey.blocks,
|
||||
responseInput.data,
|
||||
responseInput.language ?? "en",
|
||||
responseInput.finished,
|
||||
surveyResult.survey.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ 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";
|
||||
@@ -106,6 +107,23 @@ 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,6 +258,7 @@ 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
|
||||
@@ -284,6 +285,7 @@ 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
|
||||
@@ -929,7 +931,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/invited_on: 83476ce4bcdfc3ccf524d1cd91b758a8
|
||||
environments/settings/general/invite_expires_on: 6fd2356ad91a5f189070c43855904bb4
|
||||
environments/settings/general/invites_failed: 180ffb8db417050227cc2b2ea74b7aae
|
||||
environments/settings/general/leave_organization: e74132cb4a0dc98c41e61ea3b2dd268b
|
||||
environments/settings/general/leave_organization_description: 2d0cd65e4e78a9b2835cf88c4de407fb
|
||||
@@ -1150,7 +1152,6 @@ 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
|
||||
@@ -1941,7 +1942,6 @@ 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,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
environments: [],
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
@@ -106,7 +106,7 @@ describe("Project Service", () => {
|
||||
},
|
||||
placement: WidgetPlacement.bottomRight,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
environments: [],
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
@@ -171,7 +171,7 @@ describe("Project Service", () => {
|
||||
},
|
||||
placement: WidgetPlacement.bottomRight,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
environments: [],
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
@@ -196,7 +196,7 @@ describe("Project Service", () => {
|
||||
},
|
||||
placement: WidgetPlacement.bottomRight,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
environments: [],
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
@@ -250,7 +250,7 @@ describe("Project Service", () => {
|
||||
},
|
||||
placement: WidgetPlacement.bottomRight,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
environments: [],
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
@@ -324,7 +324,7 @@ describe("Project Service", () => {
|
||||
},
|
||||
placement: WidgetPlacement.bottomRight,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
environments: [],
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
@@ -378,7 +378,7 @@ describe("Project Service", () => {
|
||||
},
|
||||
placement: WidgetPlacement.bottomRight,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
environments: [],
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
@@ -403,7 +403,7 @@ describe("Project Service", () => {
|
||||
},
|
||||
placement: WidgetPlacement.bottomRight,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
environments: [],
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
@@ -448,7 +448,7 @@ describe("Project Service", () => {
|
||||
},
|
||||
placement: WidgetPlacement.bottomRight,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
environments: [],
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
|
||||
@@ -22,7 +22,7 @@ const selectProject = {
|
||||
config: true,
|
||||
placement: true,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: true,
|
||||
overlay: true,
|
||||
environments: true,
|
||||
styling: true,
|
||||
logo: true,
|
||||
|
||||
@@ -85,7 +85,7 @@ export const mockProject: TProject = {
|
||||
inAppSurveyBranding: false,
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: false,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
environments: [],
|
||||
languages: [],
|
||||
config: {
|
||||
|
||||
@@ -285,6 +285,7 @@
|
||||
"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",
|
||||
@@ -311,6 +312,7 @@
|
||||
"organization_teams_not_found": "Organisations-Teams nicht gefunden",
|
||||
"other": "Andere",
|
||||
"others": "Andere",
|
||||
"overlay_color": "Overlay-Farbe",
|
||||
"overview": "Überblick",
|
||||
"password": "Passwort",
|
||||
"paused": "Pausiert",
|
||||
@@ -990,7 +992,7 @@
|
||||
"from_your_organization": "von deiner Organisation",
|
||||
"invitation_sent_once_more": "Einladung nochmal gesendet.",
|
||||
"invite_deleted_successfully": "Einladung erfolgreich gelöscht",
|
||||
"invited_on": "Eingeladen am {date}",
|
||||
"invite_expires_on": "Einladung läuft ab 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.",
|
||||
@@ -1221,7 +1223,6 @@
|
||||
"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",
|
||||
@@ -2058,7 +2059,6 @@
|
||||
"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,6 +285,7 @@
|
||||
"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",
|
||||
@@ -311,6 +312,7 @@
|
||||
"organization_teams_not_found": "Organization teams not found",
|
||||
"other": "Other",
|
||||
"others": "Others",
|
||||
"overlay_color": "Overlay color",
|
||||
"overview": "Overview",
|
||||
"password": "Password",
|
||||
"paused": "Paused",
|
||||
@@ -990,7 +992,7 @@
|
||||
"from_your_organization": "from your organization",
|
||||
"invitation_sent_once_more": "Invitation sent once more.",
|
||||
"invite_deleted_successfully": "Invite deleted successfully",
|
||||
"invited_on": "Invited on {date}",
|
||||
"invite_expires_on": "Invite expires 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.",
|
||||
@@ -1221,7 +1223,6 @@
|
||||
"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",
|
||||
@@ -1268,14 +1269,13 @@
|
||||
"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,6 +1283,7 @@
|
||||
"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",
|
||||
@@ -1415,11 +1416,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",
|
||||
@@ -2058,7 +2059,6 @@
|
||||
"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,6 +285,7 @@
|
||||
"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",
|
||||
@@ -311,6 +312,7 @@
|
||||
"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",
|
||||
@@ -990,7 +992,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",
|
||||
"invited_on": "Invitado el {date}",
|
||||
"invite_expires_on": "La invitación expira 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.",
|
||||
@@ -1221,7 +1223,6 @@
|
||||
"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",
|
||||
@@ -2058,7 +2059,6 @@
|
||||
"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,6 +285,7 @@
|
||||
"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",
|
||||
@@ -311,6 +312,7 @@
|
||||
"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",
|
||||
@@ -990,7 +992,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",
|
||||
"invited_on": "Invité le {date}",
|
||||
"invite_expires_on": "L'invitation expire 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é.",
|
||||
@@ -1221,7 +1223,6 @@
|
||||
"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",
|
||||
@@ -2058,7 +2059,6 @@
|
||||
"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é",
|
||||
|
||||
+30
-29
@@ -254,7 +254,7 @@
|
||||
"label": "Címke",
|
||||
"language": "Nyelv",
|
||||
"learn_more": "Tudjon meg többet",
|
||||
"license_expired": "Licenc lejárt",
|
||||
"license_expired": "A licenc lejárt",
|
||||
"light_overlay": "Világos rávetítés",
|
||||
"limits_reached": "Korlátok elérve",
|
||||
"link": "Összekapcsolás",
|
||||
@@ -285,6 +285,7 @@
|
||||
"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",
|
||||
@@ -311,6 +312,7 @@
|
||||
"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",
|
||||
@@ -350,6 +352,7 @@
|
||||
"request_trial_license": "Próbalicenc kérése",
|
||||
"reset_to_default": "Visszaállítás az alapértelmezettre",
|
||||
"response": "Válasz",
|
||||
"response_id": "Válasz azonosító",
|
||||
"responses": "Válaszok",
|
||||
"restart": "Újraindítás",
|
||||
"role": "Szerep",
|
||||
@@ -462,7 +465,7 @@
|
||||
"you_have_reached_your_monthly_miu_limit_of": "Elérte a havi MIU-korlátját ennek:",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Elérte a havi válaszkorlátját ennek:",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Vissza lesz állítva a közösségi kiadásra ekkor: {date}.",
|
||||
"your_license_has_expired_please_renew": "A vállalati licenced lejárt. Kérjük, újítsd meg, hogy továbbra is használhasd a vállalati funkciókat."
|
||||
"your_license_has_expired_please_renew": "A vállalati licence lejárt. Újítsa meg, hogy továbbra is használhassa a vállalati funkciókat."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Elfogadás",
|
||||
@@ -811,7 +814,7 @@
|
||||
"webhook_deleted_successfully": "A webhorog sikeresen törölve",
|
||||
"webhook_name_placeholder": "Választható: címkézze meg a webhorgot az egyszerű azonosításért",
|
||||
"webhook_test_failed_due_to": "A webhorog tesztelése sikertelen a következő miatt:",
|
||||
"webhook_updated_successfully": "A webhorog sikeresen frissítve.",
|
||||
"webhook_updated_successfully": "A webhorog sikeresen frissítve",
|
||||
"webhook_url_placeholder": "Illessze be azt az URL-t, amelyen az eseményt aktiválni szeretné"
|
||||
},
|
||||
"website_or_app_integration_description": "A Formbricks integrálása a webhelyébe vagy alkalmazásába",
|
||||
@@ -820,7 +823,7 @@
|
||||
"segments": {
|
||||
"add_filter_below": "Szűrő hozzáadása lent",
|
||||
"add_your_first_filter_to_get_started": "Adja hozzá az első szűrőt a kezdéshez",
|
||||
"cannot_delete_segment_used_in_surveys": "Nem tudja eltávolítani ezt a szakaszt, mert még mindig használatban van ezekben a kérdőívekben:",
|
||||
"cannot_delete_segment_used_in_surveys": "Nem tudja törölni ezt a szakaszt, mert még mindig használatban van ezekben a kérdőívekben:",
|
||||
"clone_and_edit_segment": "Szakasz klónozása és szerkesztése",
|
||||
"create_group": "Csoport létrehozása",
|
||||
"create_your_first_segment": "Hozza létre az első szakaszt a kezdéshez",
|
||||
@@ -851,11 +854,11 @@
|
||||
"reset_all_filters": "Összes szűrő visszaállítása",
|
||||
"save_as_new_segment": "Mentés új szakaszként",
|
||||
"save_your_filters_as_a_segment_to_use_it_in_other_surveys": "A szűrők mentése szakaszként más kérdőívekben való használathoz",
|
||||
"segment_created_successfully": "A szakasz sikeresen létrehozva!",
|
||||
"segment_deleted_successfully": "A szakasz sikeresen törölve!",
|
||||
"segment_created_successfully": "A szakasz sikeresen létrehozva",
|
||||
"segment_deleted_successfully": "A szakasz sikeresen törölve",
|
||||
"segment_id": "Szakaszazonosító",
|
||||
"segment_saved_successfully": "A szakasz sikeresen elmentve",
|
||||
"segment_updated_successfully": "A szakasz sikeresen frissítve!",
|
||||
"segment_updated_successfully": "A szakasz sikeresen frissítve",
|
||||
"segments_help_you_target_users_with_same_characteristics_easily": "A szakaszok segítik a hasonló jellemzőkkel rendelkező felhasználók könnyű megcélzását",
|
||||
"target_audience": "Célközönség",
|
||||
"this_action_resets_all_filters_in_this_survey": "Ez a művelet visszaállítja az összes szűrőt ebben a kérdőívben.",
|
||||
@@ -989,7 +992,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",
|
||||
"invited_on": "Meghívva ekkor: {date}",
|
||||
"invite_expires_on": "A meghívó lejár: {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.",
|
||||
@@ -1004,8 +1007,8 @@
|
||||
"member_invited_successfully": "A tag sikeresen meghívva",
|
||||
"once_its_gone_its_gone": "Ha egyszer eltűnt, akkor eltűnt.",
|
||||
"only_org_owner_can_perform_action": "Csak a szervezet tulajdonosai férhetnek hozzá ehhez a beállításhoz.",
|
||||
"organization_created_successfully": "A szervezet sikeresen létrehozva!",
|
||||
"organization_deleted_successfully": "A szervezet sikeresen törölve.",
|
||||
"organization_created_successfully": "A szervezet sikeresen létrehozva",
|
||||
"organization_deleted_successfully": "A szervezet sikeresen törölve",
|
||||
"organization_invite_link_ready": "A szervezete meghívási hivatkozása készen áll!",
|
||||
"organization_name": "Szervezet neve",
|
||||
"organization_name_description": "Adjon a szervezetének egy leíró nevet.",
|
||||
@@ -1105,8 +1108,8 @@
|
||||
"select_member": "Tag kiválasztása",
|
||||
"select_workspace": "Munkaterület kiválasztása",
|
||||
"team_admin": "Csapatadminisztrátor",
|
||||
"team_created_successfully": "A csapat sikeresen létrehozva.",
|
||||
"team_deleted_successfully": "A csapat sikeresen törölve.",
|
||||
"team_created_successfully": "A csapat sikeresen létrehozva",
|
||||
"team_deleted_successfully": "A csapat sikeresen törölve",
|
||||
"team_deletion_not_allowed": "Önnek nem engedélyezett ennek a csapatnak a törlése.",
|
||||
"team_name": "Csapat neve",
|
||||
"team_name_settings_title": "{teamName} beállításai",
|
||||
@@ -1129,7 +1132,7 @@
|
||||
"copy_survey_error": "Nem sikerült másolni a kérdőívet",
|
||||
"copy_survey_link_to_clipboard": "Kérdőív hivatkozásának másolása a vágólapra",
|
||||
"copy_survey_partially_success": "{success} kérdőív sikeresen másolva, {error} sikertelen.",
|
||||
"copy_survey_success": "A kérdőív sikeresen másolva!",
|
||||
"copy_survey_success": "A kérdőív sikeresen másolva",
|
||||
"delete_survey_and_responses_warning": "Biztosan törölni szeretné ezt a kérdőívet és az összes válaszát?",
|
||||
"edit": {
|
||||
"1_choose_the_default_language_for_this_survey": "1. Válassza ki a kérdőív alapértelmezett nyelvét:",
|
||||
@@ -1220,7 +1223,6 @@
|
||||
"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",
|
||||
@@ -1273,7 +1275,7 @@
|
||||
"disable_the_visibility_of_survey_progress": "A kérdőív előrehaladási folyamata láthatóságának letiltása.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "A kérdőív becsült kitöltési idejének megjelenítése",
|
||||
"display_number_of_responses_for_survey": "A kérdőív válaszai számának megjelenítése",
|
||||
"display_type": "Megjelenítési típus",
|
||||
"display_type": "Megjelenített típus",
|
||||
"divide": "Osztás /",
|
||||
"does_not_contain": "Nem tartalmazza",
|
||||
"does_not_end_with": "Nem ezzel végződik",
|
||||
@@ -1281,7 +1283,7 @@
|
||||
"does_not_include_all_of": "Nem tartalmazza ezekből az összeset",
|
||||
"does_not_include_one_of": "Nem tartalmazza ezek egyikét",
|
||||
"does_not_start_with": "Nem ezzel kezdődik",
|
||||
"dropdown": "Legördülő menü",
|
||||
"dropdown": "Legördülő",
|
||||
"duplicate_block": "Blokk kettőzése",
|
||||
"duplicate_question": "Kérdés kettőzése",
|
||||
"edit_link": "Hivatkozás szerkesztése",
|
||||
@@ -1545,7 +1547,7 @@
|
||||
"send_survey_to_audience_who_match": "Kérdőív küldése az erre illeszkedő közönségnek…",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "A válaszadók küldése a választási lehetőség oldalára.",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "A globális elhelyezés beállítása a megjelenítési beállításokban.",
|
||||
"settings_saved_successfully": "A beállítások sikeresen elmentve.",
|
||||
"settings_saved_successfully": "A beállítások sikeresen elmentve",
|
||||
"seven_points": "7 pont",
|
||||
"show_block_settings": "Blokkbeállítások megjelenítése",
|
||||
"show_button": "Gomb megjelenítése",
|
||||
@@ -1712,7 +1714,7 @@
|
||||
"person_attributes": "A személy jellemzői a beküldés időpontjában",
|
||||
"phone": "Telefon",
|
||||
"respondent_skipped_questions": "A válaszadó kihagyta ezeket a kérdéseket.",
|
||||
"response_deleted_successfully": "A válasz sikeresen törölve.",
|
||||
"response_deleted_successfully": "A válasz sikeresen törölve",
|
||||
"single_use_id": "Egyszer használatos azonosító",
|
||||
"source": "Forrás",
|
||||
"state_region": "Állam vagy régió",
|
||||
@@ -1724,7 +1726,7 @@
|
||||
"search_by_survey_name": "Keresés kérőívnév alapján",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Ha nem titkosítja az egyszer használatos azonosítót, akkor a „suid=…” bármilyen értéke működik egy válasznál.",
|
||||
"custom_single_use_id_description": "Ha nem titkosítja az egyszer használatos azonosítókat, akkor a „suid=…” bármilyen értéke működik egy válasznál.",
|
||||
"custom_single_use_id_title": "Bármilyen értéket beállíthat egyszer használatos azonosítóként az URL-ben.",
|
||||
"custom_start_point": "Egyéni kezdési pont",
|
||||
"data_prefilling": "Adatok előre kitöltése",
|
||||
@@ -1949,8 +1951,8 @@
|
||||
"your_survey_is_public": "A kérdőíve nyilvános",
|
||||
"youre_not_plugged_in_yet": "Még nincs csatlakoztatva!"
|
||||
},
|
||||
"survey_deleted_successfully": "A kérdőív sikeresen törölve!",
|
||||
"survey_duplicated_successfully": "A kérdőív sikeresen megkettőzve.",
|
||||
"survey_deleted_successfully": "A kérdőív sikeresen törölve",
|
||||
"survey_duplicated_successfully": "A kérdőív sikeresen megkettőzve",
|
||||
"survey_duplication_error": "Nem sikerült megkettőzni a kérdőívet.",
|
||||
"templates": {
|
||||
"all_channels": "Összes csatorna",
|
||||
@@ -2013,8 +2015,8 @@
|
||||
"custom_scripts_updated_successfully": "Az egyéni parancsfájlok sikeres frissítve",
|
||||
"custom_scripts_warning": "A parancsfájlok teljes böngésző-hozzáféréssel kerülnek végrehajtásra. Csak megbízható forrásokból származó parancsfájlokat adjon hozzá.",
|
||||
"delete_workspace": "Munkaterület törlése",
|
||||
"delete_workspace_confirmation": "Biztosan törölni szeretné a(z) {projectName} projektet? Ezt a műveletet nem lehet visszavonni.",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "A(z) {projectName} projekt törlése, beleértve az összes kérdőívet, választ, személyt, műveletet és attribútumot is.",
|
||||
"delete_workspace_confirmation": "Biztosan törölni szeretné a(z) {projectName} munkaterületet? Ezt a műveletet nem lehet visszavonni.",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "A(z) {projectName} munkaterület törlése, beleértve az összes kérdőívet, választ, személyt, műveletet és attribútumot is.",
|
||||
"delete_workspace_settings_description": "A munkaterület törlése az összes kérdőívvel, válasszal, személlyel, művelettel és attribútummal együtt. Ezt nem lehet visszavonni.",
|
||||
"error_saving_workspace_information": "Hiba a munkaterület-információk mentésekor",
|
||||
"only_owners_or_managers_can_delete_workspaces": "Csak tulajdonosok vagy kezelők törölhetnek munkaterületeket",
|
||||
@@ -2057,7 +2059,6 @@
|
||||
"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",
|
||||
@@ -2097,9 +2098,9 @@
|
||||
"search_tags": "Címkék keresése…",
|
||||
"tag": "Címke",
|
||||
"tag_already_exists": "A címke már létezik",
|
||||
"tag_deleted": "Címke törölve",
|
||||
"tag_updated": "Címke frissítve",
|
||||
"tags_merged": "Címkék egyesítve"
|
||||
"tag_deleted": "A címke sikeresen törölve",
|
||||
"tag_updated": "A címke sikeresen frissítve",
|
||||
"tags_merged": "A címkék sikeresen egyesítve"
|
||||
},
|
||||
"teams": {
|
||||
"manage_teams": "Csapatok kezelése",
|
||||
@@ -2294,7 +2295,7 @@
|
||||
"career_development_survey_question_5_choice_5": "Üzemeltetés",
|
||||
"career_development_survey_question_5_choice_6": "Egyéb",
|
||||
"career_development_survey_question_5_headline": "Milyen funkcióban dolgozik?",
|
||||
"career_development_survey_question_5_subheader": "Válassza a következők egyikét",
|
||||
"career_development_survey_question_5_subheader": "Válassza ki a következő lehetőségek egyikét:",
|
||||
"career_development_survey_question_6_choice_1": "Egyéni közreműködő",
|
||||
"career_development_survey_question_6_choice_2": "Igazgató",
|
||||
"career_development_survey_question_6_choice_3": "Vezető igazgató",
|
||||
@@ -2302,7 +2303,7 @@
|
||||
"career_development_survey_question_6_choice_5": "Igazgató",
|
||||
"career_development_survey_question_6_choice_6": "Egyéb",
|
||||
"career_development_survey_question_6_headline": "Az alábbiak közül melyik írja le legjobban a jelenlegi munkája szintjét?",
|
||||
"career_development_survey_question_6_subheader": "Válassza a következők egyikét",
|
||||
"career_development_survey_question_6_subheader": "Válassza ki a következő lehetőségek egyikét:",
|
||||
"cess_survey_name": "Ügyfél-erőfeszítési pontszám kérdőív",
|
||||
"cess_survey_question_1_headline": "A(z) $[projectName] megkönnyíti számomra a [CÉL HOZZÁADÁSA] tevékenységet",
|
||||
"cess_survey_question_1_lower_label": "Egyáltalán nem értek egyet",
|
||||
|
||||
@@ -285,6 +285,7 @@
|
||||
"no_background_image_found": "背景画像が見つかりません。",
|
||||
"no_code": "ノーコード",
|
||||
"no_files_uploaded": "ファイルがアップロードされていません",
|
||||
"no_overlay": "オーバーレイなし",
|
||||
"no_quotas_found": "クォータが見つかりません",
|
||||
"no_result_found": "結果が見つかりません",
|
||||
"no_results": "結果なし",
|
||||
@@ -311,6 +312,7 @@
|
||||
"organization_teams_not_found": "組織のチームが見つかりません",
|
||||
"other": "その他",
|
||||
"others": "その他",
|
||||
"overlay_color": "オーバーレイの色",
|
||||
"overview": "概要",
|
||||
"password": "パスワード",
|
||||
"paused": "一時停止",
|
||||
@@ -990,7 +992,7 @@
|
||||
"from_your_organization": "あなたの組織から",
|
||||
"invitation_sent_once_more": "招待状を再度送信しました。",
|
||||
"invite_deleted_successfully": "招待を正常に削除しました",
|
||||
"invited_on": "{date}に招待",
|
||||
"invite_expires_on": "招待は{date}に期限切れ",
|
||||
"invites_failed": "招待に失敗しました",
|
||||
"leave_organization": "組織を離れる",
|
||||
"leave_organization_description": "この組織を離れ、すべてのフォームと回答へのアクセス権を失います。再度招待された場合にのみ再参加できます。",
|
||||
@@ -1221,7 +1223,6 @@
|
||||
"caution_explanation_responses_are_safe": "古い回答と新しい回答が混ざり、データの概要が誤解を招く可能性があります。",
|
||||
"caution_recommendation": "これにより、フォームの概要にデータの不整合が生じる可能性があります。代わりにフォームを複製することをお勧めします。",
|
||||
"caution_text": "変更は不整合を引き起こします",
|
||||
"centered_modal_overlay_color": "中央モーダルのオーバーレイ色",
|
||||
"change_anyway": "とにかく変更",
|
||||
"change_background": "背景を変更",
|
||||
"change_question_type": "質問の種類を変更",
|
||||
@@ -2058,7 +2059,6 @@
|
||||
"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,6 +285,7 @@
|
||||
"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",
|
||||
@@ -311,6 +312,7 @@
|
||||
"organization_teams_not_found": "Organisatieteams niet gevonden",
|
||||
"other": "Ander",
|
||||
"others": "Anderen",
|
||||
"overlay_color": "Overlaykleur",
|
||||
"overview": "Overzicht",
|
||||
"password": "Wachtwoord",
|
||||
"paused": "Gepauzeerd",
|
||||
@@ -990,7 +992,7 @@
|
||||
"from_your_organization": "vanuit uw organisatie",
|
||||
"invitation_sent_once_more": "Uitnodiging nogmaals verzonden.",
|
||||
"invite_deleted_successfully": "Uitnodiging succesvol verwijderd",
|
||||
"invited_on": "Uitgenodigd op {date}",
|
||||
"invite_expires_on": "Uitnodiging verloopt 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.",
|
||||
@@ -1221,7 +1223,6 @@
|
||||
"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",
|
||||
@@ -2058,7 +2059,6 @@
|
||||
"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,6 +285,7 @@
|
||||
"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",
|
||||
@@ -311,6 +312,7 @@
|
||||
"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",
|
||||
@@ -990,7 +992,7 @@
|
||||
"from_your_organization": "da sua organização",
|
||||
"invitation_sent_once_more": "Convite enviado de novo.",
|
||||
"invite_deleted_successfully": "Convite deletado com sucesso",
|
||||
"invited_on": "Convidado em {date}",
|
||||
"invite_expires_on": "O convite expira 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.",
|
||||
@@ -1221,7 +1223,6 @@
|
||||
"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",
|
||||
@@ -2058,7 +2059,6 @@
|
||||
"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,6 +285,7 @@
|
||||
"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",
|
||||
@@ -311,6 +312,7 @@
|
||||
"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",
|
||||
@@ -990,7 +992,7 @@
|
||||
"from_your_organization": "da sua organização",
|
||||
"invitation_sent_once_more": "Convite enviado mais uma vez.",
|
||||
"invite_deleted_successfully": "Convite eliminado com sucesso",
|
||||
"invited_on": "Convidado em {date}",
|
||||
"invite_expires_on": "O convite expira 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.",
|
||||
@@ -1221,7 +1223,6 @@
|
||||
"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",
|
||||
@@ -2058,7 +2059,6 @@
|
||||
"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,6 +285,7 @@
|
||||
"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",
|
||||
@@ -311,6 +312,7 @@
|
||||
"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ă",
|
||||
@@ -990,7 +992,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",
|
||||
"invited_on": "Invitat pe {date}",
|
||||
"invite_expires_on": "Invitația expiră 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.",
|
||||
@@ -1221,7 +1223,6 @@
|
||||
"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",
|
||||
@@ -2058,7 +2059,6 @@
|
||||
"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,6 +285,7 @@
|
||||
"no_background_image_found": "Фоновое изображение не найдено.",
|
||||
"no_code": "Нет кода",
|
||||
"no_files_uploaded": "Файлы не были загружены",
|
||||
"no_overlay": "Без наложения",
|
||||
"no_quotas_found": "Квоты не найдены",
|
||||
"no_result_found": "Результат не найден",
|
||||
"no_results": "Нет результатов",
|
||||
@@ -311,6 +312,7 @@
|
||||
"organization_teams_not_found": "Команды организации не найдены",
|
||||
"other": "Другое",
|
||||
"others": "Другие",
|
||||
"overlay_color": "Цвет наложения",
|
||||
"overview": "Обзор",
|
||||
"password": "Пароль",
|
||||
"paused": "Приостановлено",
|
||||
@@ -990,7 +992,7 @@
|
||||
"from_your_organization": "из вашей организации",
|
||||
"invitation_sent_once_more": "Приглашение отправлено ещё раз.",
|
||||
"invite_deleted_successfully": "Приглашение успешно удалено",
|
||||
"invited_on": "Приглашён {date}",
|
||||
"invite_expires_on": "Приглашение истекает {date}",
|
||||
"invites_failed": "Не удалось отправить приглашения",
|
||||
"leave_organization": "Покинуть организацию",
|
||||
"leave_organization_description": "Вы покинете эту организацию и потеряете доступ ко всем опросам и ответам. Вы сможете вернуться только по новому приглашению.",
|
||||
@@ -1221,7 +1223,6 @@
|
||||
"caution_explanation_responses_are_safe": "Старые и новые ответы смешиваются, что может привести к искажённым итоговым данным.",
|
||||
"caution_recommendation": "Это может привести к несоответствиям в итогах опроса. Рекомендуем вместо этого дублировать опрос.",
|
||||
"caution_text": "Изменения приведут к несоответствиям",
|
||||
"centered_modal_overlay_color": "Цвет оверлея центрированного модального окна",
|
||||
"change_anyway": "Всё равно изменить",
|
||||
"change_background": "Изменить фон",
|
||||
"change_question_type": "Изменить тип вопроса",
|
||||
@@ -2058,7 +2059,6 @@
|
||||
"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,6 +285,7 @@
|
||||
"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",
|
||||
@@ -311,6 +312,7 @@
|
||||
"organization_teams_not_found": "Organisationsteam hittades inte",
|
||||
"other": "Annat",
|
||||
"others": "Andra",
|
||||
"overlay_color": "Overlay-färg",
|
||||
"overview": "Översikt",
|
||||
"password": "Lösenord",
|
||||
"paused": "Pausad",
|
||||
@@ -990,7 +992,7 @@
|
||||
"from_your_organization": "från din organisation",
|
||||
"invitation_sent_once_more": "Inbjudan skickad igen.",
|
||||
"invite_deleted_successfully": "Inbjudan borttagen",
|
||||
"invited_on": "Inbjuden den {date}",
|
||||
"invite_expires_on": "Inbjudan går ut 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.",
|
||||
@@ -1221,7 +1223,6 @@
|
||||
"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",
|
||||
@@ -2058,7 +2059,6 @@
|
||||
"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,6 +285,7 @@
|
||||
"no_background_image_found": "未找到 背景 图片。",
|
||||
"no_code": "无代码",
|
||||
"no_files_uploaded": "没有 文件 被 上传",
|
||||
"no_overlay": "无覆盖层",
|
||||
"no_quotas_found": "未找到配额",
|
||||
"no_result_found": "没有 结果",
|
||||
"no_results": "没有 结果",
|
||||
@@ -311,6 +312,7 @@
|
||||
"organization_teams_not_found": "未找到 组织 团队",
|
||||
"other": "其他",
|
||||
"others": "其他",
|
||||
"overlay_color": "覆盖层颜色",
|
||||
"overview": "概览",
|
||||
"password": "密码",
|
||||
"paused": "暂停",
|
||||
@@ -990,7 +992,7 @@
|
||||
"from_your_organization": "来自你的组织",
|
||||
"invitation_sent_once_more": "再次发送邀请。",
|
||||
"invite_deleted_successfully": "邀请 删除 成功",
|
||||
"invited_on": "受邀于 {date}",
|
||||
"invite_expires_on": "邀请将于 {date} 过期",
|
||||
"invites_failed": "邀请失败",
|
||||
"leave_organization": "离开 组织",
|
||||
"leave_organization_description": "您将离开此组织,并失去对所有调查和响应的访问权限。只有再次被邀请后,您才能重新加入。",
|
||||
@@ -1221,7 +1223,6 @@
|
||||
"caution_explanation_responses_are_safe": "旧 与 新 的 回复 混合 , 这 可能 导致 数据 总结 有误 。",
|
||||
"caution_recommendation": "这 可能 会 导致 调查 统计 数据 的 不一致 。 我们 建议 复制 调查 。",
|
||||
"caution_text": "更改 会导致 不一致",
|
||||
"centered_modal_overlay_color": "居中 模态遮罩层颜色",
|
||||
"change_anyway": "还是更改",
|
||||
"change_background": "更改 背景",
|
||||
"change_question_type": "更改 问题类型",
|
||||
@@ -2058,7 +2059,6 @@
|
||||
"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,6 +285,7 @@
|
||||
"no_background_image_found": "找不到背景圖片。",
|
||||
"no_code": "無程式碼",
|
||||
"no_files_uploaded": "沒有上傳任何檔案",
|
||||
"no_overlay": "無覆蓋層",
|
||||
"no_quotas_found": "找不到 配額",
|
||||
"no_result_found": "找不到結果",
|
||||
"no_results": "沒有結果",
|
||||
@@ -311,6 +312,7 @@
|
||||
"organization_teams_not_found": "找不到組織團隊",
|
||||
"other": "其他",
|
||||
"others": "其他",
|
||||
"overlay_color": "覆蓋層顏色",
|
||||
"overview": "概覽",
|
||||
"password": "密碼",
|
||||
"paused": "已暫停",
|
||||
@@ -990,7 +992,7 @@
|
||||
"from_your_organization": "來自您的組織",
|
||||
"invitation_sent_once_more": "已再次發送邀請。",
|
||||
"invite_deleted_successfully": "邀請已成功刪除",
|
||||
"invited_on": "邀請於 '{'date'}'",
|
||||
"invite_expires_on": "邀請將於 '{'date'}' 過期",
|
||||
"invites_failed": "邀請失敗",
|
||||
"leave_organization": "離開組織",
|
||||
"leave_organization_description": "您將離開此組織並失去對所有問卷和回應的存取權限。只有再次收到邀請,您才能重新加入。",
|
||||
@@ -1221,7 +1223,6 @@
|
||||
"caution_explanation_responses_are_safe": "較舊和較新的回應會混在一起,可能導致數據摘要失準。",
|
||||
"caution_recommendation": "這可能導致調查摘要中的數據不一致。我們建議複製這個調查。",
|
||||
"caution_text": "變更會導致不一致",
|
||||
"centered_modal_overlay_color": "置中彈窗覆蓋顏色",
|
||||
"change_anyway": "仍然變更",
|
||||
"change_background": "變更背景",
|
||||
"change_question_type": "變更問題類型",
|
||||
@@ -2058,7 +2059,6 @@
|
||||
"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": "啟用自訂樣式",
|
||||
|
||||
+32
-11
@@ -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 "./validation";
|
||||
} from "@/modules/api/lib/validation";
|
||||
|
||||
const mockTransformQuestionsToBlocks = vi.fn();
|
||||
const mockGetElementsFromBlocks = vi.fn();
|
||||
@@ -95,7 +95,7 @@ describe("validateResponseData", () => {
|
||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
||||
mockValidateBlockResponses.mockReturnValue({});
|
||||
|
||||
validateResponseData([], mockResponseData, "en", mockQuestions);
|
||||
validateResponseData([], mockResponseData, "en", true, mockQuestions);
|
||||
|
||||
expect(mockTransformQuestionsToBlocks).toHaveBeenCalledWith(mockQuestions, []);
|
||||
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(transformedBlocks);
|
||||
@@ -105,15 +105,15 @@ describe("validateResponseData", () => {
|
||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
||||
mockValidateBlockResponses.mockReturnValue({});
|
||||
|
||||
validateResponseData(mockBlocks, mockResponseData, "en", mockQuestions);
|
||||
validateResponseData(mockBlocks, mockResponseData, "en", true, mockQuestions);
|
||||
|
||||
expect(mockTransformQuestionsToBlocks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return null when both blocks and questions are empty", () => {
|
||||
expect(validateResponseData([], mockResponseData, "en", [])).toBeNull();
|
||||
expect(validateResponseData(null, mockResponseData, "en", [])).toBeNull();
|
||||
expect(validateResponseData(undefined, mockResponseData, "en", null)).toBeNull();
|
||||
expect(validateResponseData([], mockResponseData, "en", true, [])).toBeNull();
|
||||
expect(validateResponseData(null, mockResponseData, "en", true, [])).toBeNull();
|
||||
expect(validateResponseData(undefined, mockResponseData, "en", true, null)).toBeNull();
|
||||
});
|
||||
|
||||
test("should use default language code", () => {
|
||||
@@ -124,15 +124,36 @@ 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("formatValidationErrorsForApi", () => {
|
||||
describe("formatValidationErrorsForV2Api", () => {
|
||||
test("should convert error map to V2 API format", () => {
|
||||
const errorMap: TValidationErrorMap = {
|
||||
element1: [{ ruleId: "minLength", ruleType: "minLength", message: "Min length required" }],
|
||||
};
|
||||
|
||||
const result = formatValidationErrorsForApi(errorMap);
|
||||
const result = formatValidationErrorsForV2Api(errorMap);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -151,7 +172,7 @@ describe("formatValidationErrorsForApi", () => {
|
||||
],
|
||||
};
|
||||
|
||||
const result = formatValidationErrorsForApi(errorMap);
|
||||
const result = formatValidationErrorsForV2Api(errorMap);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].field).toBe("response.data.element1");
|
||||
@@ -164,7 +185,7 @@ describe("formatValidationErrorsForApi", () => {
|
||||
element2: [{ ruleId: "maxLength", ruleType: "maxLength", message: "Max length" }],
|
||||
};
|
||||
|
||||
const result = formatValidationErrorsForApi(errorMap);
|
||||
const result = formatValidationErrorsForV2Api(errorMap);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].field).toBe("response.data.element1");
|
||||
+14
-7
@@ -10,17 +10,20 @@ 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
|
||||
@@ -37,22 +40,26 @@ export const validateResponseData = (
|
||||
}
|
||||
|
||||
// Extract elements from blocks
|
||||
const elements = getElementsFromBlocks(blocksToUse);
|
||||
const allElements = getElementsFromBlocks(blocksToUse);
|
||||
|
||||
// Validate all elements
|
||||
const errorMap = validateBlockResponses(elements, responseData, languageCode);
|
||||
// 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);
|
||||
|
||||
// Return null if no errors (validation passed), otherwise return error map
|
||||
return Object.keys(errorMap).length === 0 ? null : errorMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts validation error map to API error response format (V2)
|
||||
* Converts validation error map to V2 API error response format
|
||||
*
|
||||
* @param errorMap - Validation error map from validateResponseData
|
||||
* @returns API error response details
|
||||
* @returns V2 API error response details
|
||||
*/
|
||||
export const formatValidationErrorsForApi = (errorMap: TValidationErrorMap) => {
|
||||
export const formatValidationErrorsForV2Api = (errorMap: TValidationErrorMap) => {
|
||||
const details: ApiErrorDetails = [];
|
||||
|
||||
for (const [elementId, errors] of Object.entries(errorMap)) {
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -15,7 +16,6 @@ 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,6 +198,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
||||
questionsResponse.data.blocks,
|
||||
body.data,
|
||||
body.language ?? "en",
|
||||
body.finished,
|
||||
questionsResponse.data.questions
|
||||
);
|
||||
|
||||
@@ -206,7 +207,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: formatValidationErrorsForApi(validationErrors),
|
||||
details: formatValidationErrorsForV2Api(validationErrors),
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -13,7 +14,6 @@ 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,6 +134,7 @@ export const POST = async (request: Request) =>
|
||||
surveyQuestions.data.blocks,
|
||||
body.data,
|
||||
body.language ?? "en",
|
||||
body.finished,
|
||||
surveyQuestions.data.questions
|
||||
);
|
||||
|
||||
@@ -142,7 +143,7 @@ export const POST = async (request: Request) =>
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: formatValidationErrorsForApi(validationErrors),
|
||||
details: formatValidationErrorsForV2Api(validationErrors),
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("updateProjectBranding", () => {
|
||||
},
|
||||
placement: "bottomRight" as const,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
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 leading-4 font-medium no-underline shadow-none"
|
||||
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"
|
||||
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 mr-3 mb-3 inline-block h-[150px] w-[250px]"
|
||||
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
|
||||
key={choice.id}
|
||||
src={choice.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
className="rounded-custom mr-3 mb-3 inline-block h-[150px] w-[250px]"
|
||||
className="rounded-custom mb-3 mr-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 px-4 py-2 break-words" />
|
||||
<Column className="w-40 break-words px-4 py-2" />
|
||||
{firstQuestion.columns.map((column) => {
|
||||
return (
|
||||
<Column
|
||||
className="text-question-color max-w-40 px-4 py-2 text-center break-words"
|
||||
className="text-question-color max-w-40 break-words px-4 py-2 text-center"
|
||||
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 px-4 py-2 break-words">
|
||||
<Column className="w-40 break-words px-4 py-2">
|
||||
{getLocalizedValue(row.label, "default")}
|
||||
</Column>
|
||||
{firstQuestion.columns.map((column) => {
|
||||
|
||||
@@ -241,7 +241,7 @@ describe("utils.ts", () => {
|
||||
config: {},
|
||||
placement: "bottomRight" as const,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [
|
||||
@@ -389,7 +389,7 @@ describe("utils.ts", () => {
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [
|
||||
@@ -502,7 +502,7 @@ describe("utils.ts", () => {
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [],
|
||||
@@ -588,7 +588,7 @@ describe("utils.ts", () => {
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [],
|
||||
@@ -627,7 +627,7 @@ describe("utils.ts", () => {
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
styling: {},
|
||||
logo: null,
|
||||
environments: [],
|
||||
|
||||
@@ -150,7 +150,7 @@ export const getEnvironmentWithRelations = reactCache(async (environmentId: stri
|
||||
config: true,
|
||||
placement: true,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: true,
|
||||
overlay: 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,
|
||||
darkOverlay: data.project.darkOverlay,
|
||||
overlay: data.project.overlay,
|
||||
styling: data.project.styling,
|
||||
logo: data.project.logo,
|
||||
customHeadScripts: data.project.customHeadScripts,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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";
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
getMembershipsByUserId,
|
||||
getOrganizationOwnerCount,
|
||||
} from "@/modules/organization/settings/teams/lib/membership";
|
||||
import { deleteInvite, getInvite, inviteUser, resendInvite } from "./lib/invite";
|
||||
import { deleteInvite, getInvite, inviteUser, refreshInviteExpiration, resendInvite } from "./lib/invite";
|
||||
|
||||
const ZDeleteInviteAction = z.object({
|
||||
inviteId: ZUuid,
|
||||
@@ -57,30 +58,57 @@ const ZCreateInviteTokenAction = z.object({
|
||||
inviteId: ZUuid,
|
||||
});
|
||||
|
||||
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"],
|
||||
},
|
||||
],
|
||||
});
|
||||
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);
|
||||
|
||||
const invite = await getInvite(parsedInput.inviteId);
|
||||
if (!invite) {
|
||||
throw new ValidationError("Invite not found");
|
||||
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 inviteToken = createInviteToken(parsedInput.inviteId, invite.email, {
|
||||
expiresIn: "7d",
|
||||
});
|
||||
|
||||
return { inviteToken: encodeURIComponent(inviteToken) };
|
||||
});
|
||||
)
|
||||
);
|
||||
|
||||
const ZDeleteMembershipAction = z.object({
|
||||
userId: ZId,
|
||||
@@ -191,6 +219,7 @@ export const resendInviteAction = authenticatedActionClient.schema(ZResendInvite
|
||||
invite?.creator?.name ?? "",
|
||||
updatedInvite.name ?? ""
|
||||
);
|
||||
|
||||
return updatedInvite;
|
||||
}
|
||||
)
|
||||
|
||||
+2
@@ -80,6 +80,7 @@ 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);
|
||||
@@ -99,6 +100,7 @@ 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.invited_on", {
|
||||
date: getFormattedDateTimeString(member.createdAt),
|
||||
tooltipContent={`${t("environments.settings.general.invite_expires_on", {
|
||||
date: getFormattedDateTimeString(member.expiresAt),
|
||||
})}`}>
|
||||
<Badge type="warning" text="Pending" size="tiny" />
|
||||
</TooltipRenderer>
|
||||
|
||||
@@ -9,7 +9,14 @@ import {
|
||||
} from "@formbricks/types/errors";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { TInvitee } from "../types/invites";
|
||||
import { deleteInvite, getInvite, getInvitesByOrganizationId, inviteUser, resendInvite } from "./invite";
|
||||
import {
|
||||
deleteInvite,
|
||||
getInvite,
|
||||
getInvitesByOrganizationId,
|
||||
inviteUser,
|
||||
refreshInviteExpiration,
|
||||
resendInvite,
|
||||
} from "./invite";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -46,32 +53,129 @@ const mockInvite: Invite = {
|
||||
teamIds: [],
|
||||
};
|
||||
|
||||
describe("resendInvite", () => {
|
||||
describe("refreshInviteExpiration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
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 });
|
||||
|
||||
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", async () => {
|
||||
vi.mocked(prisma.invite.findUnique).mockResolvedValue(null);
|
||||
await expect(resendInvite("invite-1")).rejects.toThrow(ResourceNotFoundError);
|
||||
|
||||
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 prisma error", async () => {
|
||||
|
||||
test("throws DatabaseError on other prisma errors", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("db", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.invite.findUnique).mockRejectedValue(prismaError);
|
||||
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);
|
||||
|
||||
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);
|
||||
await expect(resendInvite("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(resendInvite("invite-1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws error if prisma error", async () => {
|
||||
test("throws error if non-prisma error", async () => {
|
||||
const error = new Error("db");
|
||||
vi.mocked(prisma.invite.findUnique).mockRejectedValue(error);
|
||||
vi.mocked(prisma.invite.update).mockRejectedValue(error);
|
||||
await expect(resendInvite("invite-1")).rejects.toThrow("db");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,44 +13,21 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { type InviteWithCreator, type TInvite, type TInvitee } from "../types/invites";
|
||||
|
||||
export const resendInvite = async (inviteId: string): Promise<Pick<Invite, "email" | "name">> => {
|
||||
export const refreshInviteExpiration = async (inviteId: string): Promise<Invite> => {
|
||||
try {
|
||||
const invite = await prisma.invite.findUnique({
|
||||
where: {
|
||||
id: inviteId,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
creator: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invite) {
|
||||
throw new ResourceNotFoundError("Invite", inviteId);
|
||||
}
|
||||
|
||||
const updatedInvite = await prisma.invite.update({
|
||||
where: {
|
||||
id: inviteId,
|
||||
},
|
||||
where: { id: inviteId },
|
||||
data: {
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
organizationId: true,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 7 days
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
email: updatedInvite.email,
|
||||
name: updatedInvite.name,
|
||||
};
|
||||
return updatedInvite;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2025") {
|
||||
throw new ResourceNotFoundError("Invite", inviteId);
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
@@ -58,6 +35,16 @@ export const resendInvite = async (inviteId: string): Promise<Pick<Invite, "emai
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
environments: [
|
||||
{
|
||||
id: "cmi2sra0j000004l73fvh7lhe",
|
||||
|
||||
@@ -24,7 +24,7 @@ const selectProject = {
|
||||
config: true,
|
||||
placement: true,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: true,
|
||||
overlay: true,
|
||||
environments: true,
|
||||
styling: true,
|
||||
logo: true,
|
||||
|
||||
@@ -15,6 +15,7 @@ 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;
|
||||
@@ -24,7 +25,7 @@ interface EditPlacementProps {
|
||||
|
||||
const ZProjectPlacementInput = z.object({
|
||||
placement: z.enum(["bottomRight", "topRight", "topLeft", "bottomLeft", "center"]),
|
||||
darkOverlay: z.boolean(),
|
||||
overlay: z.enum(["none", "light", "dark"]),
|
||||
clickOutsideClose: z.boolean(),
|
||||
});
|
||||
|
||||
@@ -40,28 +41,35 @@ 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,
|
||||
darkOverlay: project.darkOverlay ?? false,
|
||||
overlay: project.overlay ?? "none",
|
||||
clickOutsideClose: project.clickOutsideClose ?? false,
|
||||
},
|
||||
resolver: zodResolver(ZProjectPlacementInput),
|
||||
});
|
||||
|
||||
const currentPlacement = form.watch("placement");
|
||||
const darkOverlay = form.watch("darkOverlay");
|
||||
const overlay = form.watch("overlay");
|
||||
const clickOutsideClose = form.watch("clickOutsideClose");
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const overlayStyle = currentPlacement === "center" && darkOverlay ? "bg-slate-700/80" : "bg-slate-200";
|
||||
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 onSubmit: SubmitHandler<EditPlacementFormValues> = async (data) => {
|
||||
const updatedProjectResponse = await updateProjectAction({
|
||||
projectId: project.id,
|
||||
data: {
|
||||
placement: data.placement,
|
||||
darkOverlay: data.darkOverlay,
|
||||
overlay: data.overlay,
|
||||
clickOutsideClose: data.clickOutsideClose,
|
||||
},
|
||||
});
|
||||
@@ -113,9 +121,9 @@ export const EditPlacementForm = ({ project, isReadOnly }: EditPlacementProps) =
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
clickOutsideClose ? "" : "cursor-not-allowed",
|
||||
hasOverlay && !clickOutsideClose ? "cursor-not-allowed" : "",
|
||||
"relative ml-8 h-40 w-full rounded",
|
||||
overlayStyle
|
||||
getOverlayStyle()
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -125,85 +133,69 @@ export const EditPlacementForm = ({ project, isReadOnly }: EditPlacementProps) =
|
||||
</div>
|
||||
</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>
|
||||
</>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
logo: null,
|
||||
brandColor: null,
|
||||
highlightBorderColor: null,
|
||||
|
||||
@@ -391,7 +391,7 @@ export const ElementFormInput = ({
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<div className="mt-3 mb-2 flex items-center justify-between">
|
||||
<div className="mb-2 mt-3 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="mt-3 mb-2 flex items-center justify-between">
|
||||
<div className="mb-2 mt-3 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 px-3 py-2 text-center text-sm whitespace-nowrap text-transparent ${
|
||||
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 ${
|
||||
localSurvey.languages?.length > 1 ? "pr-24" : ""
|
||||
}`}
|
||||
dir="auto"
|
||||
|
||||
@@ -265,7 +265,7 @@ export const BlockCard = ({
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 hover:cursor-move"
|
||||
className="opacity-0 hover:cursor-move group-hover:opacity-100"
|
||||
aria-label="Drag to reorder block">
|
||||
<GripIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -69,6 +69,7 @@ export const EndScreenForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!endingCard.headline?.default || endingCard.headline.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
<div>
|
||||
{endingCard.subheader !== undefined && (
|
||||
@@ -87,6 +88,7 @@ export const EndScreenForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!endingCard.subheader?.default || endingCard.subheader.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,7 +141,7 @@ export const EndScreenForm = ({
|
||||
</Label>
|
||||
</div>
|
||||
{showEndingCardCTA && (
|
||||
<div className="mt-4 space-y-4 rounded-md border border-1 bg-slate-100 p-4 pt-2">
|
||||
<div className="mt-4 space-y-4 rounded-md border bg-slate-100 p-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<ElementFormInput
|
||||
id="buttonLabel"
|
||||
@@ -183,7 +185,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 px-3 py-2 text-center text-sm whitespace-nowrap text-transparent`}
|
||||
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`}
|
||||
dir="auto"
|
||||
key={highlightedJSX.toString()}>
|
||||
{highlightedJSX}
|
||||
|
||||
@@ -172,7 +172,7 @@ export const FileUploadElementForm = ({
|
||||
|
||||
updateElement(elementIdx, { maxSizeInMB: Number.parseInt(e.target.value, 10) });
|
||||
}}
|
||||
className="mr-2 ml-2 inline w-20 bg-white text-center text-sm"
|
||||
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
|
||||
/>
|
||||
MB
|
||||
</p>
|
||||
|
||||
@@ -27,6 +27,7 @@ interface PictureSelectionFormProps {
|
||||
isInvalid: boolean;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const PictureSelectionForm = ({
|
||||
@@ -39,6 +40,7 @@ export const PictureSelectionForm = ({
|
||||
isInvalid,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: PictureSelectionFormProps): JSX.Element => {
|
||||
const environmentId = localSurvey.environmentId;
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
@@ -88,6 +90,7 @@ export const PictureSelectionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
autoFocus={!element.headline?.default || element.headline.default.trim() === ""}
|
||||
/>
|
||||
<div ref={parent}>
|
||||
@@ -106,6 +109,7 @@ export const PictureSelectionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
autoFocus={!element.subheader?.default || element.subheader.default.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TPlacement } from "@formbricks/types/common";
|
||||
import { TOverlay, 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: string) => void;
|
||||
overlay: string;
|
||||
setOverlay: (overlay: TOverlay) => void;
|
||||
overlay: TOverlay;
|
||||
setClickOutsideClose: (clickOutside: boolean) => void;
|
||||
clickOutsideClose: boolean;
|
||||
}
|
||||
@@ -32,8 +33,15 @@ export const Placement = ({
|
||||
{ name: t("common.bottom_left"), value: "bottomLeft", disabled: false },
|
||||
{ name: t("common.centered_modal"), value: "center", disabled: false },
|
||||
];
|
||||
const overlayStyle =
|
||||
currentPlacement === "center" && overlay === "dark" ? "bg-slate-700/80" : "bg-slate-200";
|
||||
|
||||
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";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex">
|
||||
@@ -50,9 +58,9 @@ export const Placement = ({
|
||||
<div
|
||||
data-testid="placement-preview"
|
||||
className={cn(
|
||||
clickOutsideClose ? "" : "cursor-not-allowed",
|
||||
hasOverlay && !clickOutsideClose ? "cursor-not-allowed" : "",
|
||||
"relative ml-8 h-40 w-full rounded",
|
||||
overlayStyle
|
||||
getOverlayStyle()
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -61,53 +69,46 @@ export const Placement = ({
|
||||
)}></div>
|
||||
</div>
|
||||
</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>
|
||||
</>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 px-3 py-2 text-center text-sm whitespace-nowrap text-transparent`}
|
||||
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`}
|
||||
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 { TPlacement } from "@formbricks/types/common";
|
||||
import { TOverlay, 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, darkOverlay } = projectOverwrites ?? {};
|
||||
const { placement, clickOutsideClose, overlay } = projectOverwrites ?? {};
|
||||
|
||||
const setProjectOverwrites = (projectOverwrites: TSurveyProjectOverwrites | null) => {
|
||||
setLocalSurvey({ ...localSurvey, projectOverwrites: projectOverwrites });
|
||||
@@ -41,7 +41,7 @@ export const SurveyPlacementCard = ({
|
||||
setProjectOverwrites({
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: false,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -56,13 +56,11 @@ export const SurveyPlacementCard = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOverlay = (overlayType: string) => {
|
||||
const darkOverlay = overlayType === "dark";
|
||||
|
||||
const handleOverlay = (overlayValue: TOverlay) => {
|
||||
if (setProjectOverwrites) {
|
||||
setProjectOverwrites({
|
||||
...projectOverwrites,
|
||||
darkOverlay,
|
||||
overlay: overlayValue,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -132,7 +130,7 @@ export const SurveyPlacementCard = ({
|
||||
currentPlacement={placement}
|
||||
setCurrentPlacement={handlePlacementChange}
|
||||
setOverlay={handleOverlay}
|
||||
overlay={darkOverlay ? "dark" : "light"}
|
||||
overlay={overlay ?? "none"}
|
||||
setClickOutsideClose={handleClickOutsideClose}
|
||||
clickOutsideClose={!!clickOutsideClose}
|
||||
/>
|
||||
|
||||
@@ -188,6 +188,8 @@ 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,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
segment: null,
|
||||
surveyClosedMessage: null,
|
||||
singleUseId: null,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { TPlacement } from "@formbricks/types/common";
|
||||
import { TOverlay, 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;
|
||||
darkOverlay: boolean;
|
||||
overlay: TOverlay;
|
||||
borderRadius?: number;
|
||||
background?: string;
|
||||
}
|
||||
@@ -22,14 +22,13 @@ export const Modal = ({
|
||||
placement,
|
||||
previewMode,
|
||||
clickOutsideClose,
|
||||
darkOverlay,
|
||||
overlay,
|
||||
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") {
|
||||
@@ -42,10 +41,6 @@ export const Modal = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setOverlayVisible(placement === "center");
|
||||
}, [placement]);
|
||||
|
||||
const calculateScaling = () => {
|
||||
if (windowWidth === null) return {};
|
||||
|
||||
@@ -84,12 +79,11 @@ export const Modal = ({
|
||||
const scalingClasses = calculateScaling();
|
||||
|
||||
useEffect(() => {
|
||||
if (!clickOutsideClose || placement !== "center") return;
|
||||
if (!clickOutsideClose) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const previewBase = document.getElementById("preview-survey-base");
|
||||
|
||||
if (
|
||||
clickOutsideClose &&
|
||||
modalRef.current &&
|
||||
previewBase &&
|
||||
previewBase.contains(e.target as Node) &&
|
||||
@@ -106,7 +100,7 @@ export const Modal = ({
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [clickOutsideClose, placement]);
|
||||
}, [clickOutsideClose]);
|
||||
|
||||
useEffect(() => {
|
||||
setShow(isOpen);
|
||||
@@ -135,7 +129,8 @@ export const Modal = ({
|
||||
aria-live="assertive"
|
||||
className={cn(
|
||||
"relative h-full w-full overflow-hidden rounded-b-md",
|
||||
overlayVisible ? (darkOverlay ? "bg-slate-700/80" : "bg-white/50") : "",
|
||||
overlay === "dark" ? "bg-slate-700/80" : "",
|
||||
overlay === "light" ? "bg-slate-400/50" : "",
|
||||
"transition-all duration-500 ease-in-out"
|
||||
)}>
|
||||
<div
|
||||
|
||||
@@ -51,11 +51,11 @@ export const PreviewSurvey = ({
|
||||
const { projectOverwrites } = survey || {};
|
||||
|
||||
const { placement: surveyPlacement } = projectOverwrites || {};
|
||||
const { darkOverlay: surveyDarkOverlay } = projectOverwrites || {};
|
||||
const { overlay: surveyOverlay } = projectOverwrites || {};
|
||||
const { clickOutsideClose: surveyClickOutsideClose } = projectOverwrites || {};
|
||||
|
||||
const placement = surveyPlacement || project.placement;
|
||||
const darkOverlay = surveyDarkOverlay ?? project.darkOverlay;
|
||||
const overlay = surveyOverlay ?? project.overlay;
|
||||
const clickOutsideClose = surveyClickOutsideClose ?? project.clickOutsideClose;
|
||||
|
||||
const styling: TSurveyStyling | TProjectStyling = useMemo(() => {
|
||||
@@ -241,7 +241,7 @@ export const PreviewSurvey = ({
|
||||
isOpen={isModalOpen}
|
||||
placement={placement}
|
||||
previewMode="mobile"
|
||||
darkOverlay={darkOverlay}
|
||||
overlay={overlay}
|
||||
clickOutsideClose={clickOutsideClose}
|
||||
borderRadius={styling?.roundness ?? 8}
|
||||
background={styling?.cardBackgroundColor?.light}>
|
||||
@@ -345,7 +345,7 @@ export const PreviewSurvey = ({
|
||||
isOpen={isModalOpen}
|
||||
placement={placement}
|
||||
clickOutsideClose={clickOutsideClose}
|
||||
darkOverlay={darkOverlay}
|
||||
overlay={overlay}
|
||||
previewMode="desktop"
|
||||
borderRadius={styling.roundness ?? 8}
|
||||
background={styling.cardBackgroundColor?.light}>
|
||||
|
||||
@@ -18,6 +18,8 @@ interface StylingTabsProps<T> {
|
||||
|
||||
label?: string;
|
||||
subLabel?: string;
|
||||
activeTabClassName?: string;
|
||||
inactiveTabClassName?: string;
|
||||
}
|
||||
|
||||
export const StylingTabs = <T extends string | number>({
|
||||
@@ -29,6 +31,8 @@ export const StylingTabs = <T extends string | number>({
|
||||
tabsContainerClassName,
|
||||
label,
|
||||
subLabel,
|
||||
activeTabClassName,
|
||||
inactiveTabClassName,
|
||||
}: StylingTabsProps<T>) => {
|
||||
const [selectedOption, setSelectedOption] = useState<T | undefined>(defaultSelected);
|
||||
|
||||
@@ -57,7 +61,8 @@ 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"
|
||||
"focus:ring-brand-dark focus:outline-none focus:ring-2 focus:ring-opacity-50",
|
||||
selectedOption === option.value ? activeTabClassName : inactiveTabClassName
|
||||
)}>
|
||||
<input
|
||||
type="radio"
|
||||
|
||||
@@ -96,11 +96,11 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
};
|
||||
|
||||
const { placement: surveyPlacement } = projectOverwrites || {};
|
||||
const { darkOverlay: surveyDarkOverlay } = projectOverwrites || {};
|
||||
const { overlay: surveyOverlay } = projectOverwrites || {};
|
||||
const { clickOutsideClose: surveyClickOutsideClose } = projectOverwrites || {};
|
||||
|
||||
const placement = surveyPlacement || project.placement;
|
||||
const darkOverlay = surveyDarkOverlay ?? project.darkOverlay;
|
||||
const overlay = surveyOverlay ?? project.overlay;
|
||||
const clickOutsideClose = surveyClickOutsideClose ?? project.clickOutsideClose;
|
||||
|
||||
const highlightBorderColor = project.styling.highlightBorderColor?.light;
|
||||
@@ -162,7 +162,7 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
isOpen
|
||||
placement={placement}
|
||||
clickOutsideClose={clickOutsideClose}
|
||||
darkOverlay={darkOverlay}
|
||||
overlay={overlay}
|
||||
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/dev/types/routes.d.ts";
|
||||
import "./.next/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@9.15.9",
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"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.879.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.879.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.879.0",
|
||||
"@aws-sdk/client-s3": "3.971.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.971.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.971.0",
|
||||
"@boxyhq/saml-jackson": "1.52.2",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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,10 +704,6 @@
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"darkOverlay": {
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"example": "cm6orqtcl000319wj9wb7dltl",
|
||||
"type": "string"
|
||||
@@ -716,6 +712,11 @@
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"overlay": {
|
||||
"enum": ["none", "light", "dark"],
|
||||
"example": "none",
|
||||
"type": "string"
|
||||
},
|
||||
"placement": {
|
||||
"example": "bottomRight",
|
||||
"type": "string"
|
||||
|
||||
@@ -5411,10 +5411,15 @@ components:
|
||||
type:
|
||||
- boolean
|
||||
- "null"
|
||||
darkOverlay:
|
||||
overlay:
|
||||
type:
|
||||
- boolean
|
||||
- string
|
||||
- "null"
|
||||
enum:
|
||||
- none
|
||||
- light
|
||||
- dark
|
||||
- null
|
||||
description: Project specific overwrites
|
||||
styling:
|
||||
type:
|
||||
|
||||
+3
-1
@@ -80,7 +80,8 @@
|
||||
"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/tags",
|
||||
"xm-and-surveys/surveys/general-features/validation-rules"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -161,6 +162,7 @@
|
||||
"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.
|
After Width: | Height: | Size: 57 KiB |
@@ -0,0 +1,183 @@
|
||||
---
|
||||
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,6 +15,8 @@ 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
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
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
|
||||
+7
-4
@@ -73,9 +73,9 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"nextBundleAnalysis": {
|
||||
"budget": 358400,
|
||||
"budgetPercentIncreaseRed": 20,
|
||||
@@ -90,10 +90,13 @@
|
||||
"tar-fs": "2.1.4",
|
||||
"typeorm": ">=0.3.26",
|
||||
"systeminformation": "5.27.14",
|
||||
"qs": ">=6.14.1"
|
||||
"qs": ">=6.14.1",
|
||||
"preact": ">=10.26.10",
|
||||
"fast-xml-parser": ">=5.3.4",
|
||||
"diff": ">=8.0.3"
|
||||
},
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"next-auth@4.24.12": "patches/next-auth@4.24.12.patch"
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"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
@@ -0,0 +1,45 @@
|
||||
-- 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,6 +593,12 @@ 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.
|
||||
///
|
||||
@@ -621,7 +627,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)
|
||||
darkOverlay Boolean @default(false)
|
||||
overlay SurveyOverlay @default(none)
|
||||
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(`pnpm prisma migrate deploy --schema="${PRISMA_SCHEMA_PATH}"`);
|
||||
await execAsync(`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,6 +2,7 @@
|
||||
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";
|
||||
@@ -153,7 +154,7 @@ const ZSurveyBase = z.object({
|
||||
highlightBorderColor: ZColor.nullish(),
|
||||
placement: ZPlacement.nullish(),
|
||||
clickOutsideClose: z.boolean().nullish(),
|
||||
darkOverlay: z.boolean().nullish(),
|
||||
overlay: ZOverlay.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,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
styling: {
|
||||
|
||||
@@ -262,7 +262,7 @@ describe("api.ts", () => {
|
||||
id: "project123",
|
||||
recontactDays: 30,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
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, isNowExpired } from "@/lib/common/utils";
|
||||
import { filterSurveys, getIsDebug, 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,6 +56,7 @@ vi.mock("@/lib/common/utils", async (importOriginal) => {
|
||||
...originalModule,
|
||||
filterSurveys: vi.fn(),
|
||||
isNowExpired: vi.fn(),
|
||||
getIsDebug: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -86,6 +87,7 @@ 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(() => {
|
||||
@@ -117,7 +119,8 @@ describe("setup.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("skips setup if existing config is in error state and not expired", async () => {
|
||||
test("skips setup if existing config is in error state and not expired (debug mode)", async () => {
|
||||
(getIsDebug as unknown as Mock).mockReturnValue(true);
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environmentId: "env_123",
|
||||
@@ -131,7 +134,7 @@ describe("setup.ts", () => {
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
|
||||
|
||||
(isNowExpired as unknown as Mock).mockReturnValue(true);
|
||||
(isNowExpired as unknown as Mock).mockReturnValue(false); // Not expired
|
||||
|
||||
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
|
||||
expect(result.ok).toBe(true);
|
||||
@@ -140,6 +143,59 @@ 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,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
styling: { allowStyleOverwrite: false },
|
||||
|
||||
@@ -97,7 +97,7 @@ describe("widget-file", () => {
|
||||
data: {
|
||||
project: {
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
},
|
||||
@@ -163,7 +163,7 @@ describe("widget-file", () => {
|
||||
data: {
|
||||
project: {
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
},
|
||||
@@ -209,7 +209,7 @@ describe("widget-file", () => {
|
||||
data: {
|
||||
project: {
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
},
|
||||
|
||||
@@ -84,7 +84,7 @@ export const renderWidget = async (
|
||||
|
||||
const projectOverwrites = survey.projectOverwrites ?? {};
|
||||
const clickOutside = projectOverwrites.clickOutsideClose ?? project.clickOutsideClose;
|
||||
const darkOverlay = projectOverwrites.darkOverlay ?? project.darkOverlay;
|
||||
const overlay = projectOverwrites.overlay ?? project.overlay;
|
||||
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,
|
||||
darkOverlay,
|
||||
overlay,
|
||||
languageCode,
|
||||
placement,
|
||||
styling: getStyling(project, survey),
|
||||
|
||||
@@ -32,7 +32,7 @@ export type TEnvironmentStateSurvey = Pick<
|
||||
|
||||
export type TEnvironmentStateProject = Pick<
|
||||
Project,
|
||||
"id" | "recontactDays" | "clickOutsideClose" | "darkOverlay" | "placement" | "inAppSurveyBranding"
|
||||
"id" | "recontactDays" | "clickOutsideClose" | "overlay" | "placement" | "inAppSurveyBranding"
|
||||
> & {
|
||||
styling: TProjectStyling;
|
||||
};
|
||||
|
||||
@@ -37,9 +37,9 @@
|
||||
"author": "Formbricks <hola@formbricks.com>",
|
||||
"dependencies": {
|
||||
"@formbricks/logger": "workspace:*",
|
||||
"@aws-sdk/client-s3": "3.879.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.879.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.879.0"
|
||||
"@aws-sdk/client-s3": "3.971.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.971.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.971.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
"respondents_will_not_see_this_card": "A válaszadók nem fogják látni ezt a kártyát",
|
||||
"retry": "Újrapróbálkozás",
|
||||
"retrying": "Újrapróbálkozás…",
|
||||
"select_option": "Válassz egy lehetőséget",
|
||||
"select_options": "Válassz lehetőségeket",
|
||||
"select_option": "Lehetőség kiválasztása",
|
||||
"select_options": "Lehetőségek kiválasztása",
|
||||
"sending_responses": "Válaszok küldése…",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {Kevesebb mint 1 percet vesz igénybe} other {Kevesebb mint {count} percet vesz igénybe}}",
|
||||
"takes_x_minutes": "{count, plural, one {1 percet vesz igénybe} other {{count} percet vesz igénybe}}",
|
||||
@@ -48,7 +48,7 @@
|
||||
},
|
||||
"invalid_device_error": {
|
||||
"message": "Tiltsa le a szemét elleni védekezést a kérdőív beállításaiban, hogy tovább használhassa ezt az eszközt.",
|
||||
"title": "Ez az eszköz nem támogatja a spam elleni védelmet."
|
||||
"title": "Ez az eszköz nem támogatja a szemét elleni védekezést."
|
||||
},
|
||||
"invalid_format": "Adjon meg egy érvényes formátumot",
|
||||
"is_between": "Válasszon egy dátumot {startDate} és {endDate} között",
|
||||
@@ -71,7 +71,7 @@
|
||||
"please_fill_out_this_field": "Töltse ki ezt a mezőt",
|
||||
"recaptcha_error": {
|
||||
"message": "A válaszát nem sikerült elküldeni, mert automatizált tevékenységként lett megjelölve. Ha lélegzik, akkor próbálja meg újra.",
|
||||
"title": "Nem tudtuk ellenőrizni, hogy ember vagy."
|
||||
"title": "Nem tudtuk ellenőrizni, hogy Ön ember-e."
|
||||
},
|
||||
"value_must_contain": "Az értéknek tartalmaznia kell ezt: {value}",
|
||||
"value_must_equal": "Az értéknek egyenlőnek kell lennie ezzel: {value}",
|
||||
|
||||
@@ -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 leading-6 font-normal opacity-60"
|
||||
className="mb-[3px] text-xs font-normal leading-6 opacity-60"
|
||||
tabIndex={-1}
|
||||
data-testid="fb__surveys__headline-optional-text-test">
|
||||
{t("common.required")}
|
||||
|
||||
@@ -52,11 +52,13 @@ export function RenderSurvey(props: SurveyContainerProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasOverlay = props.overlay && props.overlay !== "none";
|
||||
|
||||
return (
|
||||
<SurveyContainer
|
||||
mode={props.mode ?? "modal"}
|
||||
placement={props.placement}
|
||||
darkOverlay={props.darkOverlay}
|
||||
overlay={props.overlay}
|
||||
clickOutside={props.clickOutside}
|
||||
onClose={close}
|
||||
isOpen={isOpen}
|
||||
@@ -64,7 +66,7 @@ export function RenderSurvey(props: SurveyContainerProps) {
|
||||
{/* @ts-expect-error -- TODO: fix this */}
|
||||
<Survey
|
||||
{...props}
|
||||
clickOutside={props.placement === "center" ? props.clickOutside : true}
|
||||
clickOutside={hasOverlay ? props.clickOutside : true}
|
||||
onClose={close}
|
||||
onFinished={() => {
|
||||
props.onFinished?.();
|
||||
|
||||
@@ -425,7 +425,10 @@ 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(
|
||||
@@ -676,7 +679,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;
|
||||
const firstEndingId = localSurvey.endings[0]?.id as string | undefined;
|
||||
if (firstEndingId) {
|
||||
setBlockId(firstEndingId);
|
||||
} else {
|
||||
@@ -690,7 +693,7 @@ export function Survey({
|
||||
};
|
||||
|
||||
const onBack = (): void => {
|
||||
let prevBlockId;
|
||||
let prevBlockId: string | undefined;
|
||||
// use history if available
|
||||
if (history.length > 0) {
|
||||
const newHistory = [...history];
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import { type TPlacement } from "@formbricks/types/common";
|
||||
import { type TOverlay, type TPlacement } from "@formbricks/types/common";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SurveyContainerProps {
|
||||
mode: "modal" | "inline";
|
||||
placement?: TPlacement;
|
||||
darkOverlay?: boolean;
|
||||
overlay?: TOverlay;
|
||||
children: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
clickOutside?: boolean;
|
||||
@@ -16,7 +16,7 @@ interface SurveyContainerProps {
|
||||
export function SurveyContainer({
|
||||
mode,
|
||||
placement = "bottomRight",
|
||||
darkOverlay = false,
|
||||
overlay = "none",
|
||||
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 (!isCenter) return;
|
||||
if (!clickOutside) return;
|
||||
if (!hasOverlay) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
clickOutside &&
|
||||
isOpen &&
|
||||
modalRef.current &&
|
||||
!(modalRef.current as HTMLElement).contains(e.target as Node) &&
|
||||
@@ -42,11 +42,12 @@ export function SurveyContainer({
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [clickOutside, onClose, isCenter, isModal, isOpen]);
|
||||
}, [clickOutside, onClose, isModal, isOpen]);
|
||||
|
||||
const getPlacementStyle = (placement: TPlacement): string => {
|
||||
switch (placement) {
|
||||
@@ -80,15 +81,14 @@ export function SurveyContainer({
|
||||
<div
|
||||
aria-live="assertive"
|
||||
className={cn(
|
||||
isCenter ? "pointer-events-auto" : "pointer-events-none",
|
||||
isModal && "fixed inset-0 z-999999 flex items-end"
|
||||
hasOverlay ? "pointer-events-auto" : "pointer-events-none",
|
||||
isModal && "z-999999 fixed inset-0 flex items-end"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"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" : ""
|
||||
"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" : ""
|
||||
)}>
|
||||
<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, darkOverlay, clickOutside, onClose
|
||||
// if survey type is link, we don't pass the placement, overlay, 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, darkOverlay, clickOutside, onClose
|
||||
// if survey type is link, we don't pass the placement, overlay, clickOutside, onClose
|
||||
if (props.survey.type === "link") {
|
||||
const { placement, darkOverlay, onClose, clickOutside, ...surveyInlineProps } = props;
|
||||
const { placement, overlay, 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 { darkOverlay, onClose, clickOutside, ...surveyInlineProps } = props;
|
||||
const { overlay, onClose, clickOutside, ...surveyInlineProps } = props;
|
||||
|
||||
render(
|
||||
h(
|
||||
|
||||
@@ -20,6 +20,10 @@ 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;
|
||||
darkOverlay: boolean;
|
||||
overlay: "none" | "light" | "dark";
|
||||
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;
|
||||
darkOverlay?: boolean;
|
||||
overlay?: "none" | "light" | "dark";
|
||||
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,
|
||||
darkOverlay: true,
|
||||
overlay: true,
|
||||
placement: true,
|
||||
inAppSurveyBranding: true,
|
||||
styling: true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { ZColor, ZPlacement } from "./common";
|
||||
import { ZColor, ZOverlay, 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(),
|
||||
darkOverlay: z.boolean(),
|
||||
overlay: ZOverlay,
|
||||
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(),
|
||||
darkOverlay: z.boolean().optional(),
|
||||
overlay: ZOverlay.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, ZPlacement, ZUrl, getZSafeUrl } from "../common";
|
||||
import { ZColor, ZEndingCardUrl, ZId, ZOverlay, 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(),
|
||||
darkOverlay: z.boolean().nullish(),
|
||||
overlay: ZOverlay.nullish(),
|
||||
});
|
||||
|
||||
export type TSurveyProjectOverwrites = z.infer<typeof ZSurveyProjectOverwrites>;
|
||||
@@ -2866,7 +2866,7 @@ const validateLogicFallback = (survey: TSurvey, questionIdx: number): z.ZodIssue
|
||||
}
|
||||
});
|
||||
|
||||
survey.endings.forEach((e) => {
|
||||
survey.endings.forEach((e: TSurveyEnding) => {
|
||||
possibleFallbackIds.push(e.id);
|
||||
});
|
||||
|
||||
@@ -3697,7 +3697,7 @@ const validateBlockLogicFallback = (
|
||||
}
|
||||
});
|
||||
|
||||
survey.endings.forEach((e) => {
|
||||
survey.endings.forEach((e: TSurveyEnding) => {
|
||||
possibleFallbackIds.push(e.id);
|
||||
});
|
||||
|
||||
|
||||
Generated
+1048
-757
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user