mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-23 05:17:49 -05:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 272846a7ad | |||
| 64ed3e231c | |||
| baa58b766d | |||
| 1bb09dbd94 | |||
| 6d441874c6 | |||
| 9dd94467a9 |
+3
-4
@@ -1,10 +1,9 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
# Load environment variables from .env files
|
# Load environment variables from .env files
|
||||||
if [ -f .env ]; then
|
if [ -f "$(git rev-parse --show-toplevel)/.env" ]; then
|
||||||
set -a
|
set -a
|
||||||
. .env
|
. "$(git rev-parse --show-toplevel)/.env"
|
||||||
set +a
|
set +a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -18,4 +17,4 @@ if [ -f branch.json ]; then
|
|||||||
pnpm run tolgee-pull
|
pnpm run tolgee-pull
|
||||||
git add apps/web/locales
|
git add apps/web/locales
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ vi.mock("../../../ee/license-check/lib/utils", () => ({
|
|||||||
getIsAuditLogsEnabled: vi.fn(),
|
getIsAuditLogsEnabled: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@formbricks/logger", () => ({
|
vi.mock("@formbricks/logger", () => ({
|
||||||
logger: { audit: vi.fn(), error: vi.fn() },
|
logger: { info: vi.fn(), error: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const validEvent = {
|
const validEvent = {
|
||||||
@@ -37,7 +37,7 @@ describe("logAuditEvent", () => {
|
|||||||
test("logs event if access is granted and event is valid", async () => {
|
test("logs event if access is granted and event is valid", async () => {
|
||||||
getIsAuditLogsEnabled.mockResolvedValue(true);
|
getIsAuditLogsEnabled.mockResolvedValue(true);
|
||||||
await logAuditEvent(validEvent);
|
await logAuditEvent(validEvent);
|
||||||
expect(logger.audit).toHaveBeenCalledWith(validEvent);
|
expect(logger.info).toHaveBeenCalledWith(validEvent, "Audit event logged");
|
||||||
expect(logger.error).not.toHaveBeenCalled();
|
expect(logger.error).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ describe("logAuditEvent", () => {
|
|||||||
getIsAuditLogsEnabled.mockResolvedValue(true);
|
getIsAuditLogsEnabled.mockResolvedValue(true);
|
||||||
const invalidEvent = { ...validEvent, action: "invalid.action" };
|
const invalidEvent = { ...validEvent, action: "invalid.action" };
|
||||||
await logAuditEvent(invalidEvent as any);
|
await logAuditEvent(invalidEvent as any);
|
||||||
expect(logger.audit).not.toHaveBeenCalled();
|
expect(logger.info).not.toHaveBeenCalled();
|
||||||
expect(logger.error).toHaveBeenCalled();
|
expect(logger.error).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,12 +53,12 @@ describe("logAuditEvent", () => {
|
|||||||
getIsAuditLogsEnabled.mockResolvedValue(true);
|
getIsAuditLogsEnabled.mockResolvedValue(true);
|
||||||
const event = { ...validEvent, organizationId: UNKNOWN_DATA };
|
const event = { ...validEvent, organizationId: UNKNOWN_DATA };
|
||||||
await logAuditEvent(event);
|
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);
|
getIsAuditLogsEnabled.mockResolvedValue(true);
|
||||||
logger.audit.mockImplementation(() => {
|
logger.info.mockImplementation(() => {
|
||||||
throw new Error("fail");
|
throw new Error("fail");
|
||||||
});
|
});
|
||||||
await logAuditEvent(validEvent);
|
await logAuditEvent(validEvent);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const validateEvent = (event: TAuditLogEvent): void => {
|
|||||||
export const logAuditEvent = async (event: TAuditLogEvent): Promise<void> => {
|
export const logAuditEvent = async (event: TAuditLogEvent): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
validateEvent(event);
|
validateEvent(event);
|
||||||
logger.audit(event);
|
logger.info(event, "Audit event logged");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log error to application logger but don't throw
|
// Log error to application logger but don't throw
|
||||||
// This ensures audit logging failures don't break the application
|
// This ensures audit logging failures don't break the application
|
||||||
|
|||||||
@@ -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 { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
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 { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Label } from "@/modules/ui/components/label";
|
|
||||||
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
|
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
|
||||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
|
||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
|
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { isLabelValidForAllLanguages } from "../lib/validation";
|
|
||||||
|
|
||||||
interface MatrixQuestionFormProps {
|
interface MatrixQuestionFormProps {
|
||||||
localSurvey: TSurvey;
|
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 = {
|
const shuffleOptionsTypes = {
|
||||||
none: {
|
none: {
|
||||||
id: "none",
|
id: "none",
|
||||||
@@ -178,105 +212,41 @@ export const MatrixQuestionForm = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 grid grid-cols-2 gap-4">
|
<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>
|
<div>
|
||||||
{/* Rows section */}
|
<MatrixLabelSection
|
||||||
<Label htmlFor="rows">{t("environments.surveys.edit.rows")}</Label>
|
type="column"
|
||||||
<div className="mt-2 flex flex-col gap-2" ref={parent}>
|
labels={question.columns}
|
||||||
{question.rows.map((row, index) => (
|
question={question}
|
||||||
<div className="flex items-center" key={`${row}-${index}`}>
|
questionIdx={questionIdx}
|
||||||
<QuestionFormInput
|
updateMatrixLabel={updateMatrixLabel}
|
||||||
id={`row-${index}`}
|
handleDeleteLabel={handleDeleteLabel}
|
||||||
label={""}
|
handleKeyDown={handleKeyDown}
|
||||||
localSurvey={localSurvey}
|
handleAddLabel={handleAddLabel}
|
||||||
questionIdx={questionIdx}
|
onDragEnd={handleColumnDragEnd}
|
||||||
value={question.rows[index]}
|
isInvalid={isInvalid}
|
||||||
updateMatrixLabel={updateMatrixLabel}
|
localSurvey={localSurvey}
|
||||||
selectedLanguageCode={selectedLanguageCode}
|
selectedLanguageCode={selectedLanguageCode}
|
||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
isInvalid={
|
locale={locale}
|
||||||
isInvalid && !isLabelValidForAllLanguages(question.rows[index], localSurvey.languages)
|
parent={parent}
|
||||||
}
|
/>
|
||||||
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>
|
|
||||||
<div className="mt-3 flex flex-1 items-center justify-end gap-2">
|
<div className="mt-3 flex flex-1 items-center justify-end gap-2">
|
||||||
<ShuffleOptionSelect
|
<ShuffleOptionSelect
|
||||||
shuffleOptionsTypes={shuffleOptionsTypes}
|
shuffleOptionsTypes={shuffleOptionsTypes}
|
||||||
|
|||||||
Reference in New Issue
Block a user