mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-29 09:50:10 -06:00
Compare commits
6 Commits
cursor/fix
...
devin/1735
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
272846a7ad | ||
|
|
64ed3e231c | ||
|
|
baa58b766d | ||
|
|
1bb09dbd94 | ||
|
|
6d441874c6 | ||
|
|
9dd94467a9 |
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,7 +1,6 @@
|
||||
name: Feature request
|
||||
description: "Suggest an idea for this project \U0001F680"
|
||||
type: feature
|
||||
projects: "formbricks/21"
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/task.yml
vendored
Normal file
11
.github/ISSUE_TEMPLATE/task.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
name: Task (internal)
|
||||
description: "Template for creating a task. Used by the Formbricks Team only \U0001f4e5"
|
||||
type: task
|
||||
body:
|
||||
- type: textarea
|
||||
id: task-summary
|
||||
attributes:
|
||||
label: Task description
|
||||
description: A clear detailed-rich description of the task.
|
||||
validations:
|
||||
required: true
|
||||
@@ -1,10 +1,9 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
# Load environment variables from .env files
|
||||
if [ -f .env ]; then
|
||||
if [ -f "$(git rev-parse --show-toplevel)/.env" ]; then
|
||||
set -a
|
||||
. .env
|
||||
. "$(git rev-parse --show-toplevel)/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
@@ -18,4 +17,4 @@ if [ -f branch.json ]; then
|
||||
pnpm run tolgee-pull
|
||||
git add apps/web/locales
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -274,7 +274,7 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
expect(withCache).toHaveBeenCalledWith(expect.any(Function), {
|
||||
key: `fb:env:${environmentId}:state`,
|
||||
ttl: 5 * 60 * 1000, // 5 minutes in milliseconds
|
||||
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,8 +83,9 @@ export const getEnvironmentState = async (
|
||||
{
|
||||
// Use enterprise-grade cache key pattern
|
||||
key: createCacheKey.environment.state(environmentId),
|
||||
// This is a temporary fix for the invalidation issues, will be changed later with a proper solution
|
||||
ttl: 5 * 60 * 1000, // 5 minutes in milliseconds
|
||||
// 30 minutes TTL ensures fresh data for hourly SDK checks
|
||||
// Balances performance with freshness requirements
|
||||
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -52,6 +52,14 @@ export const POST = withApiLogging(
|
||||
}
|
||||
|
||||
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
|
||||
const environmentId = actionClassInput.environmentId;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
@@ -62,14 +70,6 @@ export const POST = withApiLogging(
|
||||
};
|
||||
}
|
||||
|
||||
const environmentId = inputValidation.data.environmentId;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
|
||||
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
|
||||
auditLog.targetId = actionClass.id;
|
||||
auditLog.newObject = actionClass;
|
||||
|
||||
@@ -356,7 +356,7 @@
|
||||
"start_free_trial": "Iniciar Teste Grátis",
|
||||
"status": "status",
|
||||
"step_by_step_manual": "Manual passo a passo",
|
||||
"styling": "Estilização",
|
||||
"styling": "estilização",
|
||||
"submit": "Enviar",
|
||||
"summary": "Resumo",
|
||||
"survey": "Pesquisa",
|
||||
@@ -368,7 +368,7 @@
|
||||
"survey_paused": "Pesquisa pausada.",
|
||||
"survey_scheduled": "Pesquisa agendada.",
|
||||
"survey_type": "Tipo de Pesquisa",
|
||||
"surveys": "Pesquisas",
|
||||
"surveys": "pesquisas",
|
||||
"switch_organization": "Mudar organização",
|
||||
"switch_to": "Mudar para {environment}",
|
||||
"table_items_deleted_successfully": "{type}s deletados com sucesso",
|
||||
|
||||
@@ -25,9 +25,7 @@ export const getEnvironmentId = async (
|
||||
*/
|
||||
export const getEnvironmentIdFromSurveyIds = async (
|
||||
surveyIds: string[]
|
||||
): Promise<Result<string | null, ApiErrorResponseV2>> => {
|
||||
if (surveyIds.length === 0) return ok(null);
|
||||
|
||||
): Promise<Result<string, ApiErrorResponseV2>> => {
|
||||
const result = await fetchEnvironmentIdFromSurveyIds(surveyIds);
|
||||
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -75,14 +75,13 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho
|
||||
);
|
||||
}
|
||||
|
||||
const surveysEnvironmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
|
||||
// get surveys environment
|
||||
const surveysEnvironmentId = await getEnvironmentIdFromSurveyIds(body.surveyIds);
|
||||
|
||||
if (!surveysEnvironmentIdResult.ok) {
|
||||
return handleApiError(request, surveysEnvironmentIdResult.error, auditLog);
|
||||
if (!surveysEnvironmentId.ok) {
|
||||
return handleApiError(request, surveysEnvironmentId.error, auditLog);
|
||||
}
|
||||
|
||||
const surveysEnvironmentId = surveysEnvironmentIdResult.data;
|
||||
|
||||
// get webhook environment
|
||||
const webhook = await getWebhook(params.webhookId);
|
||||
|
||||
@@ -102,7 +101,7 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho
|
||||
}
|
||||
|
||||
// check if webhook environment matches the surveys environment
|
||||
if (surveysEnvironmentId && webhook.data.environmentId !== surveysEnvironmentId) {
|
||||
if (webhook.data.environmentId !== surveysEnvironmentId.data) {
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
|
||||
@@ -57,12 +57,10 @@ export const POST = async (request: NextRequest) =>
|
||||
);
|
||||
}
|
||||
|
||||
if (body.surveyIds && body.surveyIds.length > 0) {
|
||||
const environmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
|
||||
const environmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
|
||||
|
||||
if (!environmentIdResult.ok) {
|
||||
return handleApiError(request, environmentIdResult.error, auditLog);
|
||||
}
|
||||
if (!environmentIdResult.ok) {
|
||||
return handleApiError(request, environmentIdResult.error, auditLog);
|
||||
}
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, body.environmentId, "POST")) {
|
||||
|
||||
@@ -9,7 +9,7 @@ vi.mock("../../../ee/license-check/lib/utils", () => ({
|
||||
getIsAuditLogsEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: { audit: vi.fn(), error: vi.fn() },
|
||||
logger: { info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const validEvent = {
|
||||
@@ -37,7 +37,7 @@ describe("logAuditEvent", () => {
|
||||
test("logs event if access is granted and event is valid", async () => {
|
||||
getIsAuditLogsEnabled.mockResolvedValue(true);
|
||||
await logAuditEvent(validEvent);
|
||||
expect(logger.audit).toHaveBeenCalledWith(validEvent);
|
||||
expect(logger.info).toHaveBeenCalledWith(validEvent, "Audit event logged");
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("logAuditEvent", () => {
|
||||
getIsAuditLogsEnabled.mockResolvedValue(true);
|
||||
const invalidEvent = { ...validEvent, action: "invalid.action" };
|
||||
await logAuditEvent(invalidEvent as any);
|
||||
expect(logger.audit).not.toHaveBeenCalled();
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -53,12 +53,12 @@ describe("logAuditEvent", () => {
|
||||
getIsAuditLogsEnabled.mockResolvedValue(true);
|
||||
const event = { ...validEvent, organizationId: UNKNOWN_DATA };
|
||||
await logAuditEvent(event);
|
||||
expect(logger.audit).toHaveBeenCalledWith(event);
|
||||
expect(logger.info).toHaveBeenCalledWith(event, "Audit event logged");
|
||||
});
|
||||
|
||||
test("does not throw if logger.audit throws", async () => {
|
||||
test("does not throw if logger.info throws", async () => {
|
||||
getIsAuditLogsEnabled.mockResolvedValue(true);
|
||||
logger.audit.mockImplementation(() => {
|
||||
logger.info.mockImplementation(() => {
|
||||
throw new Error("fail");
|
||||
});
|
||||
await logAuditEvent(validEvent);
|
||||
|
||||
@@ -11,7 +11,7 @@ const validateEvent = (event: TAuditLogEvent): void => {
|
||||
export const logAuditEvent = async (event: TAuditLogEvent): Promise<void> => {
|
||||
try {
|
||||
validateEvent(event);
|
||||
logger.audit(event);
|
||||
logger.info(event, "Audit event logged");
|
||||
} catch (error) {
|
||||
// Log error to application logger but don't throw
|
||||
// This ensures audit logging failures don't break the application
|
||||
|
||||
@@ -34,8 +34,8 @@ export const getSegments = reactCache((environmentId: string) =>
|
||||
},
|
||||
{
|
||||
key: createCacheKey.environment.segments(environmentId),
|
||||
// This is a temporary fix for the invalidation issues, will be changed later with a proper solution
|
||||
ttl: 5 * 60 * 1000, // 5 minutes in milliseconds
|
||||
// 30 minutes TTL - segment definitions change infrequently
|
||||
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
import { DndContext } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyLanguage,
|
||||
TSurveyMatrixQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { MatrixLabelChoice } from "./matrix-label-choice";
|
||||
|
||||
// Mock constants
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
ENCRYPTION_KEY: "test",
|
||||
ENTERPRISE_LICENSE_KEY: "test",
|
||||
GITHUB_ID: "test",
|
||||
GITHUB_SECRET: "test",
|
||||
GOOGLE_CLIENT_ID: "test",
|
||||
GOOGLE_CLIENT_SECRET: "test",
|
||||
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_ISSUER: "mock-oidc-issuer",
|
||||
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
|
||||
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "mock-webapp-url",
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
|
||||
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
|
||||
IS_PRODUCTION: true,
|
||||
FB_LOGO_URL: "https://example.com/mock-logo.png",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
}));
|
||||
|
||||
// Mock tolgee
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string, params?: any) => {
|
||||
if (key === "environments.surveys.edit.row_idx") {
|
||||
return `Row ${params?.rowIndex}`;
|
||||
}
|
||||
if (key === "environments.surveys.edit.column_idx") {
|
||||
return `Column ${params?.columnIndex}`;
|
||||
}
|
||||
if (key === "environments.surveys.edit.delete_row") {
|
||||
return "Delete row";
|
||||
}
|
||||
if (key === "environments.surveys.edit.delete_column") {
|
||||
return "Delete column";
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock QuestionFormInput component
|
||||
vi.mock("@/modules/survey/components/question-form-input", () => ({
|
||||
QuestionFormInput: vi.fn(({ id, updateMatrixLabel, value, onKeyDown }) => (
|
||||
<div data-testid={`question-input-${id}`}>
|
||||
<input
|
||||
data-testid={`input-${id}`}
|
||||
onChange={(e) => {
|
||||
if (updateMatrixLabel) {
|
||||
const type = id.startsWith("row") ? "row" : "column";
|
||||
const index = parseInt(id.split("-")[1]);
|
||||
updateMatrixLabel(index, type, { default: e.target.value });
|
||||
}
|
||||
}}
|
||||
value={value?.default || ""}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock TooltipRenderer component
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipRenderer: vi.fn(({ children }) => <div data-testid="tooltip-renderer">{children}</div>),
|
||||
}));
|
||||
|
||||
// Mock validation
|
||||
vi.mock("../lib/validation", () => ({
|
||||
isLabelValidForAllLanguages: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
// Mock survey languages
|
||||
const mockSurveyLanguages: TSurveyLanguage[] = [
|
||||
{
|
||||
default: true,
|
||||
enabled: true,
|
||||
language: {
|
||||
id: "en",
|
||||
code: "en",
|
||||
alias: "English",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "project-1",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Mock matrix question
|
||||
const mockQuestion: TSurveyMatrixQuestion = {
|
||||
id: "matrix-1",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: createI18nString("Matrix Question", ["en"]),
|
||||
required: false,
|
||||
logic: [],
|
||||
rows: [
|
||||
createI18nString("Row 1", ["en"]),
|
||||
createI18nString("Row 2", ["en"]),
|
||||
createI18nString("Row 3", ["en"]),
|
||||
],
|
||||
columns: [
|
||||
createI18nString("Column 1", ["en"]),
|
||||
createI18nString("Column 2", ["en"]),
|
||||
createI18nString("Column 3", ["en"]),
|
||||
],
|
||||
shuffleOption: "none",
|
||||
};
|
||||
|
||||
// Mock survey
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "survey-1",
|
||||
name: "Test Survey",
|
||||
questions: [mockQuestion],
|
||||
languages: mockSurveyLanguages,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const defaultProps = {
|
||||
labelIdx: 0,
|
||||
type: "row" as const,
|
||||
questionIdx: 0,
|
||||
updateMatrixLabel: vi.fn(),
|
||||
handleDeleteLabel: vi.fn(),
|
||||
handleKeyDown: vi.fn(),
|
||||
isInvalid: false,
|
||||
localSurvey: mockSurvey,
|
||||
selectedLanguageCode: "en",
|
||||
setSelectedLanguageCode: vi.fn(),
|
||||
question: mockQuestion,
|
||||
locale: "en-US" as TUserLocale,
|
||||
};
|
||||
|
||||
const renderWithDndContext = (props = {}) => {
|
||||
const finalProps = { ...defaultProps, ...props };
|
||||
return render(
|
||||
<DndContext>
|
||||
<SortableContext
|
||||
items={[`${finalProps.type}-${finalProps.labelIdx}`]}
|
||||
strategy={verticalListSortingStrategy}>
|
||||
<MatrixLabelChoice {...finalProps} />
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
describe("MatrixLabelChoice", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Row type", () => {
|
||||
test("renders the row choice with drag handle and input", () => {
|
||||
renderWithDndContext({ type: "row" });
|
||||
|
||||
expect(screen.getByDisplayValue("Row 1")).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows delete button when there are more than 2 rows", () => {
|
||||
renderWithDndContext({ type: "row" });
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const deleteButton = buttons.find((button) => button.querySelector('svg[class*="lucide-trash"]'));
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides delete button when there are only 2 rows", () => {
|
||||
const questionWith2Rows = {
|
||||
...mockQuestion,
|
||||
rows: [createI18nString("Row 1", ["en"]), createI18nString("Row 2", ["en"])],
|
||||
};
|
||||
|
||||
renderWithDndContext({ type: "row", question: questionWith2Rows });
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const deleteButton = buttons.find((button) => button.querySelector('svg[class*="lucide-trash"]'));
|
||||
expect(deleteButton).toBeUndefined();
|
||||
});
|
||||
|
||||
test("calls handleDeleteLabel when delete button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleDeleteLabel = vi.fn();
|
||||
|
||||
renderWithDndContext({ type: "row", handleDeleteLabel });
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const deleteButton = buttons.find((button) => button.querySelector('svg[class*="lucide-trash"]'));
|
||||
expect(deleteButton).toBeDefined();
|
||||
|
||||
await user.click(deleteButton!);
|
||||
|
||||
expect(handleDeleteLabel).toHaveBeenCalledWith("row", 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Column type", () => {
|
||||
test("renders the column choice with drag handle and input", () => {
|
||||
renderWithDndContext({ type: "column" });
|
||||
|
||||
expect(screen.getByDisplayValue("Column 1")).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows delete button when there are more than 2 columns", () => {
|
||||
renderWithDndContext({ type: "column" });
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const deleteButton = buttons.find((button) => button.querySelector('svg[class*="lucide-trash"]'));
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides delete button when there are only 2 columns", () => {
|
||||
const questionWith2Columns = {
|
||||
...mockQuestion,
|
||||
columns: [createI18nString("Column 1", ["en"]), createI18nString("Column 2", ["en"])],
|
||||
};
|
||||
|
||||
renderWithDndContext({ type: "column", question: questionWith2Columns });
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const deleteButton = buttons.find((button) => button.querySelector('svg[class*="lucide-trash"]'));
|
||||
expect(deleteButton).toBeUndefined();
|
||||
});
|
||||
|
||||
test("calls handleDeleteLabel when delete button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleDeleteLabel = vi.fn();
|
||||
|
||||
renderWithDndContext({ type: "column", handleDeleteLabel });
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const deleteButton = buttons.find((button) => button.querySelector('svg[class*="lucide-trash"]'));
|
||||
expect(deleteButton).toBeDefined();
|
||||
|
||||
await user.click(deleteButton!);
|
||||
|
||||
expect(handleDeleteLabel).toHaveBeenCalledWith("column", 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Common functionality", () => {
|
||||
test("calls updateMatrixLabel when input value changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateMatrixLabel = vi.fn();
|
||||
|
||||
renderWithDndContext({ updateMatrixLabel });
|
||||
|
||||
const input = screen.getByDisplayValue("Row 1");
|
||||
await user.clear(input);
|
||||
await user.type(input, "Updated Row");
|
||||
|
||||
expect(updateMatrixLabel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls handleKeyDown when Enter key is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleKeyDown = vi.fn();
|
||||
|
||||
renderWithDndContext({ handleKeyDown });
|
||||
|
||||
const input = screen.getByDisplayValue("Row 1");
|
||||
await user.type(input, "{Enter}");
|
||||
|
||||
expect(handleKeyDown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("applies invalid styling when isInvalid is true", () => {
|
||||
renderWithDndContext({ isInvalid: true });
|
||||
|
||||
const input = screen.getByDisplayValue("Row 1");
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { GripVerticalIcon, TrashIcon } from "lucide-react";
|
||||
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { isLabelValidForAllLanguages } from "../lib/validation";
|
||||
|
||||
interface MatrixLabelChoiceProps {
|
||||
labelIdx: number;
|
||||
type: "row" | "column";
|
||||
questionIdx: number;
|
||||
updateMatrixLabel: (index: number, type: "row" | "column", data: TI18nString) => void;
|
||||
handleDeleteLabel: (type: "row" | "column", index: number) => void;
|
||||
handleKeyDown: (e: React.KeyboardEvent, type: "row" | "column") => void;
|
||||
isInvalid: boolean;
|
||||
localSurvey: TSurvey;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
question: TSurveyMatrixQuestion;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const MatrixLabelChoice = ({
|
||||
labelIdx,
|
||||
type,
|
||||
questionIdx,
|
||||
updateMatrixLabel,
|
||||
handleDeleteLabel,
|
||||
handleKeyDown,
|
||||
isInvalid,
|
||||
localSurvey,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
question,
|
||||
locale,
|
||||
}: MatrixLabelChoiceProps) => {
|
||||
const { t } = useTranslate();
|
||||
const labels = type === "row" ? question.rows : question.columns;
|
||||
const surveyLanguages = localSurvey.languages ?? [];
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: `${type}-${labelIdx}`,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transition: transition ?? "transform 100ms ease",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2" ref={setNodeRef} style={style}>
|
||||
{/* drag handle */}
|
||||
<div {...listeners} {...attributes}>
|
||||
<GripVerticalIcon className="h-4 w-4 cursor-move text-slate-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex w-full space-x-2">
|
||||
<QuestionFormInput
|
||||
key={`${type}-${labelIdx}`}
|
||||
id={`${type}-${labelIdx}`}
|
||||
placeholder={t(`environments.surveys.edit.${type}_idx`, {
|
||||
[`${type}Index`]: labelIdx + 1,
|
||||
})}
|
||||
label=""
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
value={labels[labelIdx]}
|
||||
updateMatrixLabel={updateMatrixLabel}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid && !isLabelValidForAllLanguages(labels[labelIdx], surveyLanguages)}
|
||||
onKeyDown={(e) => handleKeyDown(e, type)}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{labels.length > 2 && (
|
||||
<TooltipRenderer tooltipContent={t(`environments.surveys.edit.delete_${type}`)}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label={`Delete ${type}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDeleteLabel(type, labelIdx);
|
||||
}}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { MatrixLabelChoice } from "@/modules/survey/editor/components/matrix-label-choice";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { DndContext } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
interface MatrixLabelSectionProps {
|
||||
type: "row" | "column";
|
||||
labels: TI18nString[];
|
||||
question: TSurveyMatrixQuestion;
|
||||
questionIdx: number;
|
||||
updateMatrixLabel: (index: number, type: "row" | "column", data: TI18nString) => void;
|
||||
handleDeleteLabel: (type: "row" | "column", index: number) => void;
|
||||
handleKeyDown: (e: React.KeyboardEvent, type: "row" | "column") => void;
|
||||
handleAddLabel: (type: "row" | "column") => void;
|
||||
onDragEnd: (event: any) => void;
|
||||
isInvalid: boolean;
|
||||
localSurvey: TSurvey;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
locale: TUserLocale;
|
||||
parent: any;
|
||||
}
|
||||
|
||||
export const MatrixLabelSection = ({
|
||||
type,
|
||||
labels,
|
||||
question,
|
||||
questionIdx,
|
||||
updateMatrixLabel,
|
||||
handleDeleteLabel,
|
||||
handleKeyDown,
|
||||
handleAddLabel,
|
||||
onDragEnd,
|
||||
isInvalid,
|
||||
localSurvey,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
parent,
|
||||
}: MatrixLabelSectionProps) => {
|
||||
const { t } = useTranslate();
|
||||
const labelKey = type === "row" ? "rows" : "columns";
|
||||
const addKey = type === "row" ? "add_row" : "add_column";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={labelKey}>{t(`environments.surveys.edit.${labelKey}`)}</Label>
|
||||
<div className="mt-2" id={labelKey}>
|
||||
<DndContext id={`matrix-${labelKey}`} onDragEnd={onDragEnd}>
|
||||
<SortableContext
|
||||
items={labels.map((_, idx) => `${type}-${idx}`)}
|
||||
strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col gap-2" ref={parent}>
|
||||
{labels.map((_, index) => (
|
||||
<MatrixLabelChoice
|
||||
key={`${type}-${index}`}
|
||||
labelIdx={index}
|
||||
type={type}
|
||||
questionIdx={questionIdx}
|
||||
updateMatrixLabel={updateMatrixLabel}
|
||||
handleDeleteLabel={handleDeleteLabel}
|
||||
handleKeyDown={handleKeyDown}
|
||||
isInvalid={isInvalid}
|
||||
localSurvey={localSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
question={question}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-2 w-fit"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddLabel(type);
|
||||
}}>
|
||||
<PlusIcon />
|
||||
{t(`environments.surveys.edit.${addKey}`)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,19 +2,17 @@
|
||||
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { MatrixLabelSection } from "@/modules/survey/editor/components/matrix-label-section";
|
||||
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { isLabelValidForAllLanguages } from "../lib/validation";
|
||||
|
||||
interface MatrixQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -109,6 +107,42 @@ export const MatrixQuestionForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowDragEnd = (event: any) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!active || !over) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeIndex = question.rows.findIndex((_, idx) => `row-${idx}` === active.id);
|
||||
const overIndex = question.rows.findIndex((_, idx) => `row-${idx}` === over.id);
|
||||
|
||||
if (activeIndex !== overIndex) {
|
||||
const newRows = [...question.rows];
|
||||
const [reorderedItem] = newRows.splice(activeIndex, 1);
|
||||
newRows.splice(overIndex, 0, reorderedItem);
|
||||
updateQuestion(questionIdx, { rows: newRows });
|
||||
}
|
||||
};
|
||||
|
||||
const handleColumnDragEnd = (event: any) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!active || !over) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeIndex = question.columns.findIndex((_, idx) => `column-${idx}` === active.id);
|
||||
const overIndex = question.columns.findIndex((_, idx) => `column-${idx}` === over.id);
|
||||
|
||||
if (activeIndex !== overIndex) {
|
||||
const newColumns = [...question.columns];
|
||||
const [reorderedItem] = newColumns.splice(activeIndex, 1);
|
||||
newColumns.splice(overIndex, 0, reorderedItem);
|
||||
updateQuestion(questionIdx, { columns: newColumns });
|
||||
}
|
||||
};
|
||||
|
||||
const shuffleOptionsTypes = {
|
||||
none: {
|
||||
id: "none",
|
||||
@@ -178,105 +212,41 @@ export const MatrixQuestionForm = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-4">
|
||||
<MatrixLabelSection
|
||||
type="row"
|
||||
labels={question.rows}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateMatrixLabel={updateMatrixLabel}
|
||||
handleDeleteLabel={handleDeleteLabel}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleAddLabel={handleAddLabel}
|
||||
onDragEnd={handleRowDragEnd}
|
||||
isInvalid={isInvalid}
|
||||
localSurvey={localSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
parent={parent}
|
||||
/>
|
||||
<div>
|
||||
{/* Rows section */}
|
||||
<Label htmlFor="rows">{t("environments.surveys.edit.rows")}</Label>
|
||||
<div className="mt-2 flex flex-col gap-2" ref={parent}>
|
||||
{question.rows.map((row, index) => (
|
||||
<div className="flex items-center" key={`${row}-${index}`}>
|
||||
<QuestionFormInput
|
||||
id={`row-${index}`}
|
||||
label={""}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
value={question.rows[index]}
|
||||
updateMatrixLabel={updateMatrixLabel}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={
|
||||
isInvalid && !isLabelValidForAllLanguages(question.rows[index], localSurvey.languages)
|
||||
}
|
||||
locale={locale}
|
||||
onKeyDown={(e) => handleKeyDown(e, "row")}
|
||||
/>
|
||||
{question.rows.length > 2 && (
|
||||
<TooltipRenderer data-testid="tooltip-renderer" tooltipContent={t("common.delete")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-2"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDeleteLabel("row", index);
|
||||
}}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddLabel("row");
|
||||
}}>
|
||||
<PlusIcon />
|
||||
{t("environments.surveys.edit.add_row")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{/* Columns section */}
|
||||
<Label htmlFor="columns">{t("environments.surveys.edit.columns")}</Label>
|
||||
<div className="mt-2 flex flex-col gap-2" ref={parent}>
|
||||
{question.columns.map((column, index) => (
|
||||
<div className="flex items-center" key={`${column}-${index}`}>
|
||||
<QuestionFormInput
|
||||
id={`column-${index}`}
|
||||
label={""}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
value={question.columns[index]}
|
||||
updateMatrixLabel={updateMatrixLabel}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={
|
||||
isInvalid && !isLabelValidForAllLanguages(question.columns[index], localSurvey.languages)
|
||||
}
|
||||
locale={locale}
|
||||
onKeyDown={(e) => handleKeyDown(e, "column")}
|
||||
/>
|
||||
{question.columns.length > 2 && (
|
||||
<TooltipRenderer data-testid="tooltip-renderer" tooltipContent={t("common.delete")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-2"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDeleteLabel("column", index);
|
||||
}}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddLabel("column");
|
||||
}}>
|
||||
<PlusIcon />
|
||||
{t("environments.surveys.edit.add_column")}
|
||||
</Button>
|
||||
</div>
|
||||
<MatrixLabelSection
|
||||
type="column"
|
||||
labels={question.columns}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateMatrixLabel={updateMatrixLabel}
|
||||
handleDeleteLabel={handleDeleteLabel}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleAddLabel={handleAddLabel}
|
||||
onDragEnd={handleColumnDragEnd}
|
||||
isInvalid={isInvalid}
|
||||
localSurvey={localSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
parent={parent}
|
||||
/>
|
||||
<div className="mt-3 flex flex-1 items-center justify-end gap-2">
|
||||
<ShuffleOptionSelect
|
||||
shuffleOptionsTypes={shuffleOptionsTypes}
|
||||
|
||||
@@ -5,11 +5,7 @@ import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface BackButtonProps {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export const BackButton = ({ path }: BackButtonProps) => {
|
||||
export const BackButton = () => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
@@ -17,11 +13,7 @@ export const BackButton = ({ path }: BackButtonProps) => {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (path) {
|
||||
router.push(path);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
router.back();
|
||||
}}>
|
||||
<ArrowLeftIcon />
|
||||
{t("common.back")}
|
||||
|
||||
@@ -7,7 +7,7 @@ export const MenuBar = () => {
|
||||
<>
|
||||
<div className="border-b border-slate-200 bg-white px-5 py-2.5 sm:flex sm:items-center sm:justify-between">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<BackButton path="/" />
|
||||
<BackButton />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -12,7 +12,6 @@ vi.mock("react-confetti", () => ({
|
||||
data-colors={JSON.stringify(props.colors)}
|
||||
data-number-of-pieces={props.numberOfPieces}
|
||||
data-recycle={props.recycle}
|
||||
style={props.style}
|
||||
/>
|
||||
)),
|
||||
}));
|
||||
@@ -37,10 +36,6 @@ describe("Confetti", () => {
|
||||
expect(confettiElement).toHaveAttribute("data-colors", JSON.stringify(["#00C4B8", "#eee"]));
|
||||
expect(confettiElement).toHaveAttribute("data-number-of-pieces", "400");
|
||||
expect(confettiElement).toHaveAttribute("data-recycle", "false");
|
||||
expect(confettiElement).toHaveAttribute(
|
||||
"style",
|
||||
"position: fixed; top: 0px; left: 0px; z-index: 9999; pointer-events: none;"
|
||||
);
|
||||
});
|
||||
|
||||
test("renders with custom colors", () => {
|
||||
|
||||
@@ -13,20 +13,5 @@ export const Confetti: React.FC<ConfettiProps> = ({
|
||||
colors?: string[];
|
||||
}) => {
|
||||
const { width, height } = useWindowSize();
|
||||
return (
|
||||
<ReactConfetti
|
||||
width={width}
|
||||
height={height}
|
||||
colors={colors}
|
||||
numberOfPieces={400}
|
||||
recycle={false}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 9999,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <ReactConfetti width={width} height={height} colors={colors} numberOfPieces={400} recycle={false} />;
|
||||
};
|
||||
|
||||
@@ -220,8 +220,7 @@ const v1ClientEndpoints = {
|
||||
"/client/{environmentId}/environment": {
|
||||
get: {
|
||||
security: [],
|
||||
description:
|
||||
"Retrieves the environment state to be used in Formbricks SDKs. **Cache Behavior**: This endpoint uses server-side caching with a **5-minute TTL (Time To Live)**. Any changes to surveys, action classes, project settings, or other environment data will take up to 5 minutes to reflect in the API response. This caching is implemented to improve performance for high-frequency SDK requests.",
|
||||
description: "Retrieves the environment state to be used in Formbricks SDKs",
|
||||
parameters: [
|
||||
{
|
||||
in: "path",
|
||||
|
||||
@@ -632,7 +632,7 @@
|
||||
},
|
||||
"/api/v1/client/{environmentId}/environment": {
|
||||
"get": {
|
||||
"description": "Retrieves the environment state to be used in Formbricks SDKs **Cache Behavior**: This endpoint uses server-side caching with a **5-minute TTL (Time To Live)**. Any changes to surveys, action classes, project settings, or other environment data will take up to 5 minutes to reflect in the API response. This caching is implemented to improve performance for high-frequency SDK requests.",
|
||||
"description": "Retrieves the environment state to be used in Formbricks SDKs",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "The ID of the environment",
|
||||
@@ -2336,8 +2336,13 @@
|
||||
"example": {
|
||||
"description": "From API Docs (optional)",
|
||||
"environmentId": "{{environmentId}}",
|
||||
"key": "my-action",
|
||||
"name": "My Action from Postman",
|
||||
"noCodeConfig": {
|
||||
"innerHtml": {
|
||||
"value": "sign-up"
|
||||
},
|
||||
"type": "innerHtml"
|
||||
},
|
||||
"type": "code"
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
@@ -227,12 +227,7 @@ paths:
|
||||
/client/{environmentId}/environment:
|
||||
get:
|
||||
security: []
|
||||
description: "Retrieves the environment state to be used in Formbricks SDKs.
|
||||
**Cache Behavior**: This endpoint uses server-side caching with a
|
||||
**5-minute TTL (Time To Live)**. Any changes to surveys, action classes,
|
||||
project settings, or other environment data will take up to 5 minutes to
|
||||
reflect in the API response. This caching is implemented to improve
|
||||
performance for high-frequency SDK requests."
|
||||
description: Retrieves the environment state to be used in Formbricks SDKs
|
||||
parameters:
|
||||
- in: path
|
||||
name: environmentId
|
||||
@@ -1530,15 +1525,13 @@ paths:
|
||||
summary: Upload Bulk Contacts
|
||||
description: Uploads contacts in bulk. Each contact in the payload must have an
|
||||
'email' attribute present in their attributes array. The email attribute
|
||||
is mandatory and must be a valid email format. Without a valid email,
|
||||
the contact will be skipped during processing.
|
||||
is mandatory and must be a valid email format.
|
||||
tags:
|
||||
- Management API > Contacts
|
||||
requestBody:
|
||||
required: true
|
||||
description: The contacts to upload. Each contact must include an 'email'
|
||||
attribute in their attributes array. The email is used as the unique
|
||||
identifier for the contact.
|
||||
description: The contacts to upload. Each contact **must include an 'email'
|
||||
attribute** in their attributes array.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
||||
@@ -80,24 +80,11 @@ When PUBLIC_URL is configured, the following routes are automatically served fro
|
||||
#### Survey Routes
|
||||
|
||||
- `/s/{surveyId}` - Individual survey access
|
||||
- `/c/{jwt}` - Personalized link survey access (JWT-based access)
|
||||
- Embedded survey endpoints
|
||||
|
||||
#### API Routes
|
||||
|
||||
- `/api/v1/client/{environmentId}/*` - Client API endpoints (v1)
|
||||
- `/api/v2/client/{environmentId}/*` - Client API endpoints (v2)
|
||||
|
||||
#### Static Assets & Next.js Routes
|
||||
|
||||
- `/favicon.ico` - Favicon
|
||||
- `/_next/*` - Next.js static assets and build files
|
||||
- `/js/*` - JavaScript files
|
||||
- `/css/*` - CSS stylesheets
|
||||
- `/images/*` - Image assets
|
||||
- `/fonts/*` - Font files
|
||||
- `/icons/*` - Icon assets
|
||||
- `/public/*` - Public static files
|
||||
- `/api/v1/client/{environmentId}/*` - Client API endpoints
|
||||
|
||||
#### Storage Routes
|
||||
|
||||
|
||||
@@ -11,21 +11,18 @@ icon: "code"
|
||||
The user performs an action in your application.
|
||||
</Step>
|
||||
|
||||
<Step title="Formbricks widget detects action">
|
||||
The embedded widget (SDK) detects the action, if you set up a [No Code
|
||||
Action](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions) or a [Code
|
||||
Action](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions) to do so.
|
||||
</Step>
|
||||
<Step title="Formbricks widget detects action">
|
||||
The embedded widget (SDK) detects the action, if you set up a [No Code Action](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions) or a [Code Action](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions) to do so.
|
||||
</Step>
|
||||
|
||||
<Step title="Check for survey trigger">
|
||||
If a survey is set to trigger on that action, Formbricks checks if the user or visitor qualifies.
|
||||
</Step>
|
||||
<Step title="Check for survey trigger">
|
||||
If a survey is set to trigger on that action, Formbricks checks if the user or visitor qualifies.
|
||||
</Step>
|
||||
|
||||
<Step title="Check for user or visitor qualification">
|
||||
If the user or visitor already filled out a survey in the past couple of days (see [Global Waiting Time](/xm-and-surveys/surveys/website-app-surveys/recontact#project-wide-global-waiting-time)), the survey will not be shown to prevent survey fatigue.
|
||||
|
||||
Also, if this user or visitor has seen this specific survey before, Formbricks might not show it as this is dependent on the [Recontact Options](/xm-and-surveys/surveys/website-app-surveys/recontact).
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Show survey">
|
||||
@@ -34,29 +31,15 @@ icon: "code"
|
||||
</Steps>
|
||||
|
||||
<Tip>
|
||||
Tying surveys to specific user actions enables **context-aware surveying**: You make sure that your user
|
||||
research is relevant to the current user context which leads to sign**ificantly higher response and
|
||||
completions rates** as well as lower survey fatigue.
|
||||
Tying surveys to specific user actions enables **context-aware surveying**: You make sure that your user research is relevant to the current user context which leads to sign**ificantly higher response and completions rates** as well as lower survey fatigue.
|
||||
</Tip>
|
||||
|
||||
<Note>
|
||||
**Important**: Any changes to actions, surveys, or environment configuration will take up to 5 minutes to
|
||||
reflect in your app/website running the formbricks sdk with debug mode enabled due to server-side caching.
|
||||
This includes new actions, modified action configurations, and survey trigger updates. For quick updates
|
||||
during development and testing, you can enable [Debug
|
||||
Mode](/xm-and-surveys/surveys/website-app-surveys/framework-guides#activate-debug-mode) in your SDK
|
||||
configuration.
|
||||
</Note>
|
||||
|
||||
## **Setting Up No-Code Actions**
|
||||
|
||||
Formbricks offers an intuitive No-Code interface that allows you to configure actions without needing to write any code.
|
||||
|
||||
<Note>
|
||||
No Code Actions are **not available for surveys in mobile apps**. No Code Action tracking are heavily
|
||||
dependent on JavaScript and most mobile apps are compiled into a different programming language. To track
|
||||
user actions in mobile apps use [Code
|
||||
Actions.](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions)
|
||||
No Code Actions are **not available for surveys in mobile apps**. No Code Action tracking are heavily dependent on JavaScript and most mobile apps are compiled into a different programming language. To track user actions in mobile apps use [Code Actions.](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions)
|
||||
</Note>
|
||||
|
||||
<Steps>
|
||||
@@ -72,31 +55,27 @@ Formbricks offers an intuitive No-Code interface that allows you to configure ac
|
||||
There are four types of No-Code actions:
|
||||
|
||||
### **1. Click Action**
|
||||
|
||||

|
||||
|
||||
A Click Action is triggered when a user clicks on a specific element within your application. You can define the element's inner text, CSS selector or both to trigger the survey.
|
||||
|
||||
- **Inner Text**: Checks if the innerText of a clicked HTML element, like a button label, matches a specific text. This action allows you to display a survey based on text interactions within your application.
|
||||
* **Inner Text**: Checks if the innerText of a clicked HTML element, like a button label, matches a specific text. This action allows you to display a survey based on text interactions within your application.
|
||||
|
||||
- **CSS Selector**: Verifies if a clicked HTML element matches a provided CSS selector, such as a class, ID, or any other CSS selector used in your website. It enables survey triggers based on element interactions.
|
||||
* **CSS Selector**: Verifies if a clicked HTML element matches a provided CSS selector, such as a class, ID, or any other CSS selector used in your website. It enables survey triggers based on element interactions.
|
||||
|
||||
- **Both**: Only if both is true, the action is triggered
|
||||
* **Both**: Only if both is true, the action is triggered
|
||||
|
||||
### **2. Page View Action**
|
||||
|
||||

|
||||
|
||||
This action is triggered when a user visits a page within your application.
|
||||
|
||||
### **3. Exit Intent Action**
|
||||
|
||||

|
||||
|
||||
This action is triggered when a user is about to leave your application. It helps capture user feedback before they exit, providing valuable insights into user experiences and potential improvements.
|
||||
|
||||
### **4. 50% Scroll Action**
|
||||
|
||||

|
||||
|
||||
This action is triggered when a user scrolls through 50% of a page within your application. It helps capture user feedback at a specific point in their journey, enabling you to gather insights based on user interactions.
|
||||
@@ -109,17 +88,17 @@ You can combine the url filters with any of the no-code actions to trigger the s
|
||||
|
||||
You can limit action tracking to specific subpages of your website or web app by using the Page Filter. Here you can use a variety of URL filter settings:
|
||||
|
||||
- **exactMatch**: Triggers the action when the URL exactly matches the specified string.
|
||||
* **exactMatch**: Triggers the action when the URL exactly matches the specified string.
|
||||
|
||||
- **contains**: Activates when the URL contains the specified substring.
|
||||
* **contains**: Activates when the URL contains the specified substring.
|
||||
|
||||
- **startsWith**: Fires when the URL starts with the specified string.
|
||||
* **startsWith**: Fires when the URL starts with the specified string.
|
||||
|
||||
- **endsWith**: Executes when the URL ends with the specified string.
|
||||
* **endsWith**: Executes when the URL ends with the specified string.
|
||||
|
||||
- **notMatch**: Triggers when the URL does not match the specified condition.
|
||||
* **notMatch**: Triggers when the URL does not match the specified condition.
|
||||
|
||||
- **notContains**: Activates when the URL does not contain the specified substring.
|
||||
* **notContains**: Activates when the URL does not contain the specified substring.
|
||||
|
||||
## **Setting Up Code Actions**
|
||||
|
||||
@@ -129,7 +108,7 @@ For more granular control, you can implement actions directly in your code:
|
||||
<Step title="Configure action in Formbricks">
|
||||
First, add the action via the Formbricks web interface to make it available for survey configuration:
|
||||
|
||||

|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
@@ -149,6 +128,5 @@ For more granular control, you can implement actions directly in your code:
|
||||
|
||||
return <button onClick={handleClick}>Click Me</button>;
|
||||
```
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
</Steps>
|
||||
@@ -16,37 +16,33 @@ sidebarTitle: "Quickstart"
|
||||
You can create In-product and Link Surveys with both options, but the onboarding will prompt you to connect your app/website to Formbricks.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Connect your App/Website">
|
||||
Follow the instructions to connect your app or website: 
|
||||
</Step>
|
||||
<Step title="Connect your App/Website">
|
||||
Follow the instructions to connect your app or website:
|
||||

|
||||
</Step>
|
||||
|
||||
<Step title="Confirm setup">
|
||||
As soon as Formbricks receives the first data point, you will see a message in the onboarding:
|
||||
|
||||

|
||||
|
||||
|
||||
Onboarding is complete! Now let's create our first survey as you should see templates to choose from after clicking on **Next**:
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Create your first survey">
|
||||
To be able to see a survey in your app, you need to create one. We'll choose one of the templates and head over to the survey settings:
|
||||
|
||||
Pick the Survey Type as **Website & App Survey**.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Create trigger to show survey">
|
||||
Scroll to **Survey Trigger**, click **+ Add Action**, and select **Page View**. This ensures the survey appears when the Formbricks Widget detects any page load.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Set Recontact Options right">
|
||||
@@ -56,22 +52,17 @@ sidebarTitle: "Quickstart"
|
||||
<Note>
|
||||
Please change this setting after testing your survey to avoid user fatigue.
|
||||
</Note>
|
||||
|
||||
</Step>
|
||||
|
||||
|
||||
<Step title="Publish the survey">
|
||||
Publish the survey to make it available for the SDK to pull into the website or app where you want to show it.
|
||||
</Step>
|
||||
|
||||
<Step title="Enable debug mode in website / app">
|
||||
For better scalability, we cache the request the SDK makes to the server. This allows you to use Formbricks on websites with millions of visitors without high hosting cost. On the downside, there can be **up to a 5 minute delay** until the SDK pulls the newest surveys from the server.
|
||||
<Step title="Enable debug mode in website / app">
|
||||
For better scalability, we cache the request the SDK makes to the server. This allows you to use Formbricks on websites with millions of visitors without high hosting cost. On the downside, there can be **up to a 10 minute delay** until the SDK pulls the newest surveys from the server.
|
||||
|
||||
<Note>
|
||||
**Important**: Any changes to surveys, action classes, project settings, or environment configuration will take up to 5 minutes to reflect in debug mode in your app/website due to server-side caching. This includes survey modifications, new triggers, styling changes, and other updates.
|
||||
</Note>
|
||||
|
||||
To avoid the delay during development and testing, please switch on the [Debug Mode.](/xm-and-surveys/surveys/website-app-surveys/framework-guides#activate-debug-mode)
|
||||
To avoid the delay, please switch on the [Debug Mode.](/xm-and-surveys/surveys/website-app-surveys/framework-guides#activate-debug-mode)
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
Need help? Join us in [**GitHub Discussions**](https://github.com/formbricks/formbricks/discussions), and we'll be happy to assist!
|
||||
Need help? Join us in [**GitHub Discussions**](https://github.com/formbricks/formbricks/discussions), and we’ll be happy to assist!
|
||||
@@ -28,14 +28,9 @@ export const ZWebhook = z.object({
|
||||
environmentId: z.string().cuid2().openapi({
|
||||
description: "The ID of the environment",
|
||||
}),
|
||||
triggers: z
|
||||
.array(z.enum(["responseFinished", "responseCreated", "responseUpdated"]))
|
||||
.openapi({
|
||||
description: "The triggers of the webhook",
|
||||
})
|
||||
.min(1, {
|
||||
message: "At least one trigger is required",
|
||||
}),
|
||||
triggers: z.array(z.enum(["responseFinished", "responseCreated", "responseUpdated"])).openapi({
|
||||
description: "The triggers of the webhook",
|
||||
}),
|
||||
surveyIds: z.array(z.string().cuid2()).openapi({
|
||||
description: "The IDs of the surveys ",
|
||||
}),
|
||||
|
||||
@@ -207,7 +207,7 @@ export function MultipleChoiceMultiQuestion({
|
||||
name={question.id}
|
||||
tabIndex={-1}
|
||||
value={getLocalizedValue(choice.label, languageCode)}
|
||||
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
|
||||
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
|
||||
aria-labelledby={`${choice.id}-label`}
|
||||
onChange={(e) => {
|
||||
if ((e.target as HTMLInputElement).checked) {
|
||||
@@ -251,7 +251,7 @@ export function MultipleChoiceMultiQuestion({
|
||||
id={otherOption.id}
|
||||
name={question.id}
|
||||
value={getLocalizedValue(otherOption.label, languageCode)}
|
||||
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
|
||||
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
|
||||
aria-labelledby={`${otherOption.id}-label`}
|
||||
onChange={() => {
|
||||
if (otherSelected) {
|
||||
|
||||
@@ -168,7 +168,7 @@ export function MultipleChoiceSingleQuestion({
|
||||
name={question.id}
|
||||
value={getLocalizedValue(choice.label, languageCode)}
|
||||
dir="auto"
|
||||
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
|
||||
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
|
||||
aria-labelledby={`${choice.id}-label`}
|
||||
onChange={() => {
|
||||
setOtherSelected(false);
|
||||
@@ -210,7 +210,7 @@ export function MultipleChoiceSingleQuestion({
|
||||
id={otherOption.id}
|
||||
name={question.id}
|
||||
value={getLocalizedValue(otherOption.label, languageCode)}
|
||||
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
|
||||
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
|
||||
aria-labelledby={`${otherOption.id}-label`}
|
||||
onChange={() => {
|
||||
setOtherSelected(!otherSelected);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "./common";
|
||||
|
||||
export const ZActionClassMatchType = z.union([
|
||||
z.literal("exactMatch"),
|
||||
@@ -92,8 +91,8 @@ const ZActionClassInputBase = z.object({
|
||||
.string({ message: "Name is required" })
|
||||
.trim()
|
||||
.min(1, { message: "Name must be at least 1 character long" }),
|
||||
description: z.string().nullish(),
|
||||
environmentId: ZId.min(1, { message: "Environment ID cannot be empty" }),
|
||||
description: z.string().nullable(),
|
||||
environmentId: z.string(),
|
||||
type: ZActionClassType,
|
||||
});
|
||||
|
||||
@@ -109,9 +108,6 @@ const ZActionClassInputNoCode = ZActionClassInputBase.extend({
|
||||
noCodeConfig: ZActionClassNoCodeConfig.nullable(),
|
||||
});
|
||||
|
||||
export const ZActionClassInput = z.discriminatedUnion("type", [
|
||||
ZActionClassInputCode,
|
||||
ZActionClassInputNoCode,
|
||||
]);
|
||||
export const ZActionClassInput = z.union([ZActionClassInputCode, ZActionClassInputNoCode]);
|
||||
|
||||
export type TActionClassInput = z.infer<typeof ZActionClassInput>;
|
||||
|
||||
Reference in New Issue
Block a user