feat: hit ENTER for new option (#6624)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
Johannes
2025-10-06 00:23:17 -07:00
committed by GitHub
parent 1ced76c44d
commit 3a09af674a
11 changed files with 464 additions and 397 deletions
@@ -1,5 +1,19 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useTranslate } from "@tolgee/react";
import { debounce } from "lodash";
import { ImagePlusIcon, TrashIcon } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import {
TI18nString,
TSurvey,
TSurveyEndScreenCard,
TSurveyQuestion,
TSurveyQuestionChoice,
TSurveyRedirectUrlCard,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { useSyncScroll } from "@/lib/utils/hooks/useSyncScroll";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -10,20 +24,6 @@ import { FileInput } from "@/modules/ui/components/file-input";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useTranslate } from "@tolgee/react";
import { debounce } from "lodash";
import { ImagePlusIcon, TrashIcon } from "lucide-react";
import { RefObject, useCallback, useMemo, useRef, useState } from "react";
import {
TI18nString,
TSurvey,
TSurveyEndScreenCard,
TSurveyQuestion,
TSurveyQuestionChoice,
TSurveyRedirectUrlCard,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import {
determineImageUploaderVisibility,
getChoiceLabel,
@@ -50,7 +50,6 @@ interface QuestionFormInputProps {
label: string;
maxLength?: number;
placeholder?: string;
ref?: RefObject<HTMLInputElement | null>;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
className?: string;
locale: TUserLocale;
@@ -1,335 +1,270 @@
import { createI18nString } from "@/lib/i18n/utils";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TLanguage } from "@formbricks/types/project";
import {
TSurvey,
TSurveyLanguage,
TSurveyMatrixQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { MatrixQuestionForm } from "./matrix-question-form";
// Mock cuid2 to track CUID generation
let cuidIndex = 0;
vi.mock("@paralleldrive/cuid2", () => ({
default: {
createId: vi.fn(() => `cuid${cuidIndex++}`),
},
}));
// Mock window.matchMedia - required for useAutoAnimate
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock @formkit/auto-animate - simplify implementation
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [null],
}));
// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
},
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
// Mock findOptionUsedInLogic
vi.mock("@/modules/survey/editor/lib/utils", () => ({
findOptionUsedInLogic: vi.fn(),
}));
// 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) => key,
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useSortable: () => ({
attributes: {},
listeners: {},
setNodeRef: () => {},
transform: null,
transition: null,
}),
verticalListSortingStrategy: () => {},
}));
// Mock QuestionFormInput component
// Keep QuestionFormInput simple and forward keydown
vi.mock("@/modules/survey/components/question-form-input", () => ({
QuestionFormInput: vi.fn(({ id, updateMatrixLabel, value, updateQuestion, 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 });
} else if (updateQuestion) {
updateQuestion(0, { [id]: { default: e.target.value } });
}
}}
value={value?.default || ""}
onKeyDown={onKeyDown}
/>
</div>
)),
QuestionFormInput: ({ id, value, onKeyDown }: { id: string; value: any; onKeyDown?: any }) => (
<input
data-testid={`qfi-${id}`}
value={value?.en || value?.de || value?.default || ""}
onChange={() => {}}
onKeyDown={onKeyDown}
/>
),
}));
// Mock ShuffleOptionSelect component
vi.mock("@/modules/ui/components/shuffle-option-select", () => ({
ShuffleOptionSelect: vi.fn(() => <div data-testid="shuffle-option-select" />),
}));
describe("MatrixQuestionForm - handleKeyDown", () => {
beforeEach(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
// Mock TooltipRenderer component
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: vi.fn(({ children }) => (
<div data-testid="tooltip-renderer">
{children}
<button>Delete</button>
</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 mockMatrixQuestion: TSurveyMatrixQuestion = {
id: "matrix-1",
type: TSurveyQuestionTypeEnum.Matrix,
headline: createI18nString("Matrix Question", ["en"]),
subheader: createI18nString("Please rate the following", ["en"]),
required: false,
logic: [],
rows: [
{ id: "row-1", label: createI18nString("Row 1", ["en"]) },
{ id: "row-2", label: createI18nString("Row 2", ["en"]) },
{ id: "row-3", label: createI18nString("Row 3", ["en"]) },
],
columns: [
{ id: "col-1", label: createI18nString("Column 1", ["en"]) },
{ id: "col-2", label: createI18nString("Column 2", ["en"]) },
{ id: "col-3", label: createI18nString("Column 3", ["en"]) },
],
shuffleOption: "none",
};
// Mock survey
const mockSurvey: TSurvey = {
id: "survey-1",
name: "Test Survey",
questions: [mockMatrixQuestion],
languages: mockSurveyLanguages,
} as unknown as TSurvey;
const mockUpdateQuestion = vi.fn();
const defaultProps = {
localSurvey: mockSurvey,
question: mockMatrixQuestion,
questionIdx: 0,
updateQuestion: mockUpdateQuestion,
selectedLanguageCode: "en",
setSelectedLanguageCode: vi.fn(),
isInvalid: false,
locale: "en-US" as TUserLocale,
isStorageConfigured: true,
};
describe("MatrixQuestionForm", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
cuidIndex = 0;
});
test("renders the matrix question form with rows and columns", () => {
render(<MatrixQuestionForm {...defaultProps} isStorageConfigured={true} />);
const makeSurvey = (languages: Array<Pick<TSurveyLanguage, "language" | "default">>): TSurvey =>
({
id: "s1",
name: "Survey",
type: "link",
languages: languages as unknown as TSurveyLanguage[],
questions: [] as any,
endings: [] as any,
createdAt: new Date("2024-01-01T00:00:00.000Z"),
environmentId: "env1",
}) as unknown as TSurvey;
expect(screen.getByTestId("question-input-headline")).toBeInTheDocument();
const langDefault: TSurveyLanguage = {
language: { code: "default" } as unknown as TLanguage,
default: true,
} as unknown as TSurveyLanguage;
// Check for rows and columns
expect(screen.getByTestId("question-input-row-0")).toBeInTheDocument();
expect(screen.getByTestId("question-input-row-1")).toBeInTheDocument();
expect(screen.getByTestId("question-input-column-0")).toBeInTheDocument();
expect(screen.getByTestId("question-input-column-1")).toBeInTheDocument();
// Check for shuffle options
expect(screen.getByTestId("shuffle-option-select")).toBeInTheDocument();
const baseQuestion = (): TSurveyMatrixQuestion => ({
id: "q1",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix" },
required: false,
rows: [
{ id: "r1", label: { default: "Row 1" } },
{ id: "r2", label: { default: "" } },
],
columns: [
{ id: "c1", label: { default: "Col 1" } },
{ id: "c2", label: { default: "" } },
],
shuffleOption: "none",
});
test("adds description when button is clicked", async () => {
const user = userEvent.setup();
const propsWithoutSubheader = {
...defaultProps,
question: {
...mockMatrixQuestion,
subheader: undefined,
},
};
test("Enter on last row adds a new row", async () => {
const question = baseQuestion();
const localSurvey = makeSurvey([langDefault]);
(localSurvey as any).questions = [question];
const { getByText } = render(
<MatrixQuestionForm {...propsWithoutSubheader} isStorageConfigured={true} />
const updateQuestion = vi.fn();
render(
<MatrixQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
isStorageConfigured={true}
/>
);
const addDescriptionButton = getByText("environments.surveys.edit.add_description");
await user.click(addDescriptionButton);
const lastRowInput = screen.getByTestId("qfi-row-1");
await userEvent.type(lastRowInput, "{enter}");
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
subheader: expect.any(Object),
});
expect(updateQuestion).toHaveBeenCalledTimes(1);
const [, payload] = updateQuestion.mock.calls[0];
expect(payload.rows.length).toBe(3);
expect(payload.rows[2]).toEqual(
expect.objectContaining({ id: expect.any(String), label: expect.objectContaining({ default: "" }) })
);
});
test("renders subheader input when subheader is defined", () => {
render(<MatrixQuestionForm {...defaultProps} />);
test("Enter on non-last row focuses next row", async () => {
const question = baseQuestion();
const localSurvey = makeSurvey([langDefault]);
(localSurvey as any).questions = [question];
expect(screen.getByTestId("question-input-subheader")).toBeInTheDocument();
const updateQuestion = vi.fn();
render(
<MatrixQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
isStorageConfigured={true}
/>
);
const firstRowInput = screen.getByTestId("qfi-row-0");
await userEvent.type(firstRowInput, "{enter}");
expect(updateQuestion).not.toHaveBeenCalled();
});
test("deletes a row when delete button is clicked", async () => {
const user = userEvent.setup();
const { findAllByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(-1);
test("Enter on last column adds a new column", async () => {
const question = baseQuestion();
const localSurvey = makeSurvey([langDefault]);
(localSurvey as any).questions = [question];
const deleteButtons = await findAllByTestId("tooltip-renderer");
// First delete button is for the first column
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
const updateQuestion = vi.fn();
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
rows: [mockMatrixQuestion.rows[1], mockMatrixQuestion.rows[2]],
});
render(
<MatrixQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
isStorageConfigured={true}
/>
);
const lastColInput = screen.getByTestId("qfi-column-1");
await userEvent.type(lastColInput, "{enter}");
expect(updateQuestion).toHaveBeenCalledTimes(1);
const [, payload] = updateQuestion.mock.calls[0];
expect(payload.columns.length).toBe(3);
expect(payload.columns[2]).toEqual(
expect.objectContaining({ id: expect.any(String), label: expect.objectContaining({ default: "" }) })
);
});
test("doesn't delete a row if it would result in less than 2 rows", async () => {
const user = userEvent.setup();
const propsWithMinRows = {
...defaultProps,
question: {
...mockMatrixQuestion,
rows: [
{ id: "row-1", label: createI18nString("Row 1", ["en"]) },
{ id: "row-2", label: createI18nString("Row 2", ["en"]) },
],
},
};
test("Enter on non-last column focuses next column", async () => {
const question = baseQuestion();
const localSurvey = makeSurvey([langDefault]);
(localSurvey as any).questions = [question];
const { findAllByTestId } = render(<MatrixQuestionForm {...propsWithMinRows} />);
const updateQuestion = vi.fn();
// Try to delete rows until there are only 2 left
const deleteButtons = await findAllByTestId("tooltip-renderer");
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
render(
<MatrixQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
isStorageConfigured={true}
/>
);
// Try to delete another row, which should fail
vi.mocked(mockUpdateQuestion).mockClear();
await user.click(deleteButtons[1].querySelector("button") as HTMLButtonElement);
const firstColInput = screen.getByTestId("qfi-column-0");
await userEvent.type(firstColInput, "{enter}");
// The mockUpdateQuestion should not be called again
expect(mockUpdateQuestion).not.toHaveBeenCalled();
expect(updateQuestion).not.toHaveBeenCalled();
});
test("handles row input changes", async () => {
const user = userEvent.setup();
const { getByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
test("Arrow Down on row focuses next row", async () => {
const question = baseQuestion();
const localSurvey = makeSurvey([langDefault]);
(localSurvey as any).questions = [question];
const rowInput = getByTestId("input-row-0");
await user.clear(rowInput);
await user.type(rowInput, "New Row Label");
const updateQuestion = vi.fn();
expect(mockUpdateQuestion).toHaveBeenCalled();
render(
<MatrixQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
isStorageConfigured={true}
/>
);
const firstRowInput = screen.getByTestId("qfi-row-0");
await userEvent.type(firstRowInput, "{arrowdown}");
expect(updateQuestion).not.toHaveBeenCalled();
});
test("handles column input changes", async () => {
const user = userEvent.setup();
const { getByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
test("Arrow Up on row focuses previous row", async () => {
const question = baseQuestion();
const localSurvey = makeSurvey([langDefault]);
(localSurvey as any).questions = [question];
const columnInput = getByTestId("input-column-0");
await user.clear(columnInput);
await user.type(columnInput, "New Column Label");
const updateQuestion = vi.fn();
expect(mockUpdateQuestion).toHaveBeenCalled();
});
render(
<MatrixQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
isStorageConfigured={true}
/>
);
test("prevents deletion of a row used in logic", async () => {
const { findOptionUsedInLogic } = await import("@/modules/survey/editor/lib/utils");
vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(1); // Mock that this row is used in logic
const secondRowInput = screen.getByTestId("qfi-row-1");
await userEvent.type(secondRowInput, "{arrowup}");
const user = userEvent.setup();
const { findAllByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
const deleteButtons = await findAllByTestId("tooltip-renderer");
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
expect(mockUpdateQuestion).not.toHaveBeenCalled();
});
test("prevents deletion of a column used in logic", async () => {
const { findOptionUsedInLogic } = await import("@/modules/survey/editor/lib/utils");
vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(1); // Mock that this column is used in logic
const user = userEvent.setup();
const { findAllByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
// Column delete buttons are after row delete buttons
const deleteButtons = await findAllByTestId("tooltip-renderer");
// Click the first column delete button (index 2)
await user.click(deleteButtons[2].querySelector("button") as HTMLButtonElement);
expect(mockUpdateQuestion).not.toHaveBeenCalled();
expect(updateQuestion).not.toHaveBeenCalled();
});
});
@@ -1,12 +1,5 @@
"use client";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { MatrixSortableItem } from "@/modules/survey/editor/components/matrix-sortable-item";
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 { DndContext, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useAutoAnimate } from "@formkit/auto-animate/react";
@@ -17,6 +10,13 @@ import { type JSX, useCallback } from "react";
import toast from "react-hot-toast";
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { MatrixSortableItem } from "@/modules/survey/editor/components/matrix-sortable-item";
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 { isLabelValidForAllLanguages } from "../lib/validation";
interface MatrixQuestionFormProps {
@@ -45,17 +45,24 @@ export const MatrixQuestionForm = ({
const languageCodes = extractLanguageCodes(localSurvey.languages);
const { t } = useTranslate();
const focusItem = (targetIdx: number, type: "row" | "column") => {
const input = document.querySelector(`input[id="${type}-${targetIdx}"]`) as HTMLInputElement;
if (input) input.focus();
};
// Function to add a new Label input field
const handleAddLabel = (type: "row" | "column") => {
if (type === "row") {
const updatedRows = [...question.rows, { id: createId(), label: createI18nString("", languageCodes) }];
updateQuestion(questionIdx, { rows: updatedRows });
setTimeout(() => focusItem(updatedRows.length - 1, type), 0);
} else {
const updatedColumns = [
...question.columns,
{ id: createId(), label: createI18nString("", languageCodes) },
];
updateQuestion(questionIdx, { columns: updatedColumns });
setTimeout(() => focusItem(updatedColumns.length - 1, type), 0);
}
};
@@ -112,10 +119,30 @@ export const MatrixQuestionForm = ({
}
};
const handleKeyDown = (e: React.KeyboardEvent, type: "row" | "column") => {
const handleKeyDown = (e: React.KeyboardEvent, type: "row" | "column", currentIndex: number) => {
const items = type === "row" ? question.rows : question.columns;
if (e.key === "Enter") {
e.preventDefault();
handleAddLabel(type);
if (currentIndex === items.length - 1) {
handleAddLabel(type);
} else {
focusItem(currentIndex + 1, type);
}
}
if (e.key === "ArrowDown") {
e.preventDefault();
if (currentIndex + 1 < items.length) {
focusItem(currentIndex + 1, type);
}
}
if (e.key === "ArrowUp") {
e.preventDefault();
if (currentIndex > 0) {
focusItem(currentIndex - 1, type);
}
}
};
@@ -230,7 +257,7 @@ export const MatrixQuestionForm = ({
questionIdx={questionIdx}
updateMatrixLabel={updateMatrixLabel}
onDelete={(index) => handleDeleteLabel("row", index)}
onKeyDown={(e) => handleKeyDown(e, "row")}
onKeyDown={(e) => handleKeyDown(e, "row", index)}
canDelete={question.rows.length > 2}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
@@ -276,7 +303,7 @@ export const MatrixQuestionForm = ({
questionIdx={questionIdx}
updateMatrixLabel={updateMatrixLabel}
onDelete={(index) => handleDeleteLabel("column", index)}
onKeyDown={(e) => handleKeyDown(e, "column")}
onKeyDown={(e) => handleKeyDown(e, "column", index)}
canDelete={question.columns.length > 2}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
@@ -1,8 +1,5 @@
"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";
@@ -15,6 +12,9 @@ import {
TSurveyMatrixQuestionChoice,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
interface MatrixSortableItemProps {
choice: TSurveyMatrixQuestionChoice;
@@ -72,7 +72,6 @@ describe("MultipleChoiceQuestionForm", () => {
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
lastQuestion={false}
isStorageConfigured={true}
/>
);
@@ -1,12 +1,5 @@
"use client";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { QuestionOptionChoice } from "@/modules/survey/editor/components/question-option-choice";
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 { DndContext } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useAutoAnimate } from "@formkit/auto-animate/react";
@@ -23,13 +16,19 @@ import {
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { QuestionOptionChoice } from "@/modules/survey/editor/components/question-option-choice";
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";
interface MultipleChoiceQuestionFormProps {
localSurvey: TSurvey;
question: TSurveyMultipleChoiceQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyMultipleChoiceQuestion>) => void;
lastQuestion: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
@@ -248,28 +247,27 @@ export const MultipleChoiceQuestionForm = ({
}}>
<SortableContext items={question.choices} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-2" ref={parent}>
{question.choices &&
question.choices.map((choice, choiceIdx) => (
<QuestionOptionChoice
key={choice.id}
choice={choice}
choiceIdx={choiceIdx}
questionIdx={questionIdx}
updateChoice={updateChoice}
deleteChoice={deleteChoice}
addChoice={addChoice}
isInvalid={isInvalid}
localSurvey={localSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
surveyLanguages={surveyLanguages}
question={question}
updateQuestion={updateQuestion}
surveyLanguageCodes={surveyLanguageCodes}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
))}
{question.choices?.map((choice, choiceIdx) => (
<QuestionOptionChoice
key={choice.id}
choice={choice}
choiceIdx={choiceIdx}
questionIdx={questionIdx}
updateChoice={updateChoice}
deleteChoice={deleteChoice}
addChoice={addChoice}
isInvalid={isInvalid}
localSurvey={localSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
surveyLanguages={surveyLanguages}
question={question}
updateQuestion={updateQuestion}
surveyLanguageCodes={surveyLanguageCodes}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
))}
</div>
</SortableContext>
</DndContext>
@@ -1,5 +1,21 @@
"use client";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Project } from "@prisma/client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
import { useState } from "react";
import {
TI18nString,
TSurvey,
TSurveyQuestion,
TSurveyQuestionId,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { recallToHeadline } from "@/lib/utils/recall";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
@@ -24,22 +40,6 @@ import { getQuestionIconMap, getTSurveyQuestionTypeEnumName } from "@/modules/su
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Project } from "@prisma/client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
import { useState } from "react";
import {
TI18nString,
TSurvey,
TSurveyQuestion,
TSurveyQuestionId,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
interface QuestionCardProps {
localSurvey: TSurvey;
@@ -301,7 +301,6 @@ export const QuestionCard = ({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
@@ -314,7 +313,6 @@ export const QuestionCard = ({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
@@ -453,7 +451,6 @@ export const QuestionCard = ({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
@@ -7,7 +7,13 @@ import { QuestionOptionChoice } from "./question-option-choice";
vi.mock("@/modules/survey/components/question-form-input", () => ({
QuestionFormInput: (props: any) => (
<div data-testid="question-form-input" className={props.className}></div>
<input
data-testid="question-form-input"
className={props.className}
onKeyDown={props.onKeyDown}
value={props.value?.default || props.value?.en || props.value?.de || ""}
onChange={() => {}}
/>
),
}));
@@ -70,6 +76,81 @@ describe("QuestionOptionChoice", () => {
expect(addButton).toBeDefined();
});
test("pressing Enter on last choice adds a new choice", async () => {
const addChoice = vi.fn();
const choice = { id: "choice2", label: { default: "Choice 2" } };
const question = {
id: "question1",
headline: { default: "Question 1" },
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [{ id: "choice1", label: { default: "Choice 1" } }, choice],
} as any;
render(
<QuestionOptionChoice
choice={choice}
choiceIdx={1}
questionIdx={0}
updateChoice={vi.fn()}
deleteChoice={vi.fn()}
addChoice={addChoice}
isInvalid={false}
localSurvey={{ languages: [{ language: { code: "default" }, default: true }] } as any}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
surveyLanguages={[{ language: { code: "default" } as any, enabled: true, default: true } as any]}
question={question}
updateQuestion={vi.fn()}
surveyLanguageCodes={["default"]}
locale="en-US"
isStorageConfigured={true}
/>
);
const input = screen.getByTestId("question-form-input");
await userEvent.type(input, "{enter}");
expect(addChoice).toHaveBeenCalledWith(1);
});
test("pressing Enter on non-last choice focuses next choice", async () => {
const addChoice = vi.fn();
const choice = { id: "choice1", label: { default: "Choice 1" } };
const question = {
id: "question1",
headline: { default: "Question 1" },
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [choice, { id: "choice2", label: { default: "Choice 2" } }],
} as any;
render(
<QuestionOptionChoice
choice={choice}
choiceIdx={0}
questionIdx={0}
updateChoice={vi.fn()}
deleteChoice={vi.fn()}
addChoice={addChoice}
isInvalid={false}
localSurvey={{ languages: [{ language: { code: "default" }, default: true }] } as any}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
surveyLanguages={[{ language: { code: "default" } as any, enabled: true, default: true } as any]}
question={question}
updateQuestion={vi.fn()}
surveyLanguageCodes={["default"]}
locale="en-US"
isStorageConfigured={true}
/>
);
const input = screen.getByTestId("question-form-input");
await userEvent.type(input, "{enter}");
// Should not add a new choice (not the last one)
expect(addChoice).not.toHaveBeenCalled();
});
test("should call deleteChoice when the 'Delete choice' button is clicked for a standard choice", async () => {
const choice = { id: "choice1", label: { default: "Choice 1" } };
const question = {
@@ -1,10 +1,5 @@
"use client";
import { cn } from "@/lib/cn";
import { createI18nString } from "@/lib/i18n/utils";
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";
@@ -18,6 +13,11 @@ import {
TSurveyRankingQuestion,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { createI18nString } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { isLabelValidForAllLanguages } from "../lib/validation";
interface ChoiceProps {
@@ -72,6 +72,17 @@ export const QuestionOptionChoice = ({
transform: CSS.Translate.toString(transform),
};
const focusChoiceInput = (targetIdx: number) => {
const input = document.querySelector(`input[id="choice-${targetIdx}"]`) as HTMLInputElement;
input?.focus();
};
const addChoiceAndFocus = (idx: number) => {
addChoice(idx);
// Wait for DOM update before focusing the new input
setTimeout(() => focusChoiceInput(idx + 1), 0);
};
return (
<div className="flex w-full items-center gap-2" ref={setNodeRef} style={style}>
{/* drag handle */}
@@ -101,6 +112,32 @@ export const QuestionOptionChoice = ({
className={`${choice.id === "other" ? "border border-dashed" : ""} mt-0`}
locale={locale}
isStorageConfigured={isStorageConfigured}
onKeyDown={(e) => {
if (e.key === "Enter" && choice.id !== "other") {
e.preventDefault();
const lastChoiceIdx = question.choices.findLastIndex((c) => c.id !== "other");
if (choiceIdx === lastChoiceIdx) {
addChoiceAndFocus(choiceIdx);
} else {
focusChoiceInput(choiceIdx + 1);
}
}
if (e.key === "ArrowDown") {
e.preventDefault();
if (choiceIdx + 1 < question.choices.length) {
focusChoiceInput(choiceIdx + 1);
}
}
if (e.key === "ArrowUp") {
e.preventDefault();
if (choiceIdx > 0) {
focusChoiceInput(choiceIdx - 1);
}
}
}}
/>
{choice.id === "other" && (
<QuestionFormInput
@@ -110,9 +147,8 @@ export const QuestionOptionChoice = ({
label={""}
questionIdx={questionIdx}
value={
question.otherOptionPlaceholder
? question.otherOptionPlaceholder
: createI18nString(t("environments.surveys.edit.please_specify"), surveyLanguageCodes)
question.otherOptionPlaceholder ??
createI18nString(t("environments.surveys.edit.please_specify"), surveyLanguageCodes)
}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
@@ -127,7 +163,7 @@ export const QuestionOptionChoice = ({
)}
</div>
<div className="flex gap-2">
{question.choices && question.choices.length > 2 && (
{question.choices?.length > 2 && (
<TooltipRenderer tooltipContent={t("environments.surveys.edit.delete_choice")}>
<Button
variant="secondary"
@@ -149,7 +185,7 @@ export const QuestionOptionChoice = ({
aria-label="Add choice below"
onClick={(e) => {
e.preventDefault();
addChoice(choiceIdx);
addChoiceAndFocus(choiceIdx);
}}>
<PlusIcon />
</Button>
@@ -88,7 +88,6 @@ describe("RankingQuestionForm", () => {
selectedLanguageCode="default"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
isStorageConfigured={true}
/>
);
@@ -135,7 +134,6 @@ describe("RankingQuestionForm", () => {
selectedLanguageCode="default"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
isStorageConfigured={true}
/>
);
@@ -190,7 +188,6 @@ describe("RankingQuestionForm", () => {
selectedLanguageCode="en"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
isStorageConfigured={true}
/>
);
@@ -1,11 +1,5 @@
"use client";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { QuestionOptionChoice } from "@/modules/survey/editor/components/question-option-choice";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
import { DndContext } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useAutoAnimate } from "@formkit/auto-animate/react";
@@ -15,13 +9,18 @@ import { PlusIcon } from "lucide-react";
import { type JSX, useEffect, useRef, useState } from "react";
import { TI18nString, TSurvey, TSurveyRankingQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { QuestionOptionChoice } from "@/modules/survey/editor/components/question-option-choice";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
interface RankingQuestionFormProps {
localSurvey: TSurvey;
question: TSurveyRankingQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyRankingQuestion>) => void;
lastQuestion: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
@@ -195,28 +194,27 @@ export const RankingQuestionForm = ({
}}>
<SortableContext items={question.choices} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-2" ref={parent}>
{question.choices &&
question.choices.map((choice, choiceIdx) => (
<QuestionOptionChoice
key={choice.id}
choice={choice}
choiceIdx={choiceIdx}
questionIdx={questionIdx}
updateChoice={updateChoice}
deleteChoice={deleteChoice}
addChoice={addChoice}
isInvalid={isInvalid}
localSurvey={localSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
surveyLanguages={surveyLanguages}
question={question}
updateQuestion={updateQuestion}
surveyLanguageCodes={surveyLanguageCodes}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
))}
{question.choices?.map((choice, choiceIdx) => (
<QuestionOptionChoice
key={choice.id}
choice={choice}
choiceIdx={choiceIdx}
questionIdx={questionIdx}
updateChoice={updateChoice}
deleteChoice={deleteChoice}
addChoice={addChoice}
isInvalid={isInvalid}
localSurvey={localSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
surveyLanguages={surveyLanguages}
question={question}
updateQuestion={updateQuestion}
surveyLanguageCodes={surveyLanguageCodes}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
))}
</div>
</SortableContext>
</DndContext>