mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-27 17:00:25 -06:00
Compare commits
5 Commits
6095-pr-ch
...
chore/upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0a7e212dd | ||
|
|
0c1f6f3c3a | ||
|
|
9399b526b8 | ||
|
|
cd60032bc9 | ||
|
|
a941f994ea |
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
@@ -10,8 +10,6 @@ permissions:
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ export const resetPasswordAction = authenticatedActionClient.action(
|
||||
"user",
|
||||
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("auth.reset-password.not-allowed");
|
||||
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
|
||||
}
|
||||
|
||||
await sendForgotPasswordEmail(ctx.user);
|
||||
|
||||
@@ -145,7 +145,7 @@ export const EditProfileDetailsForm = ({
|
||||
});
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(t(errorMessage));
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
|
||||
setIsResettingPassword(false);
|
||||
|
||||
@@ -143,7 +143,6 @@ export const mockPrismaPerson: Prisma.ContactGetPayload<{
|
||||
include: typeof selectContact;
|
||||
}> = {
|
||||
id: mockId,
|
||||
userId: mockId,
|
||||
attributes: [
|
||||
{
|
||||
value: "de",
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
"formbricks_version": "Formbricks Version",
|
||||
"full_name": "Name",
|
||||
"gathering_responses": "Antworten sammeln",
|
||||
"general": "Allgemein",
|
||||
"go_back": "Geh zurück",
|
||||
"go_to_dashboard": "Zum Dashboard gehen",
|
||||
"hidden": "Versteckt",
|
||||
@@ -377,6 +378,7 @@
|
||||
"switch_to": "Wechseln zu {environment}",
|
||||
"table_items_deleted_successfully": "{type}s erfolgreich gelöscht",
|
||||
"table_settings": "Tabelleinstellungen",
|
||||
"tags": "Tags",
|
||||
"targeting": "Targeting",
|
||||
"team": "Team",
|
||||
"team_access": "Teamzugriff",
|
||||
@@ -1246,8 +1248,6 @@
|
||||
"add_description": "Beschreibung hinzufügen",
|
||||
"add_ending": "Abschluss hinzufügen",
|
||||
"add_ending_below": "Abschluss unten hinzufügen",
|
||||
"add_fallback": "Hinzufügen",
|
||||
"add_fallback_placeholder": "Hinzufügen eines Platzhalters, der angezeigt wird, wenn die Frage übersprungen wird:",
|
||||
"add_hidden_field_id": "Verstecktes Feld ID hinzufügen",
|
||||
"add_highlight_border": "Rahmen hinzufügen",
|
||||
"add_highlight_border_description": "Füge deiner Umfragekarte einen äußeren Rahmen hinzu.",
|
||||
@@ -1386,7 +1386,6 @@
|
||||
"error_saving_changes": "Fehler beim Speichern der Änderungen",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Sogar nachdem sie eine Antwort eingereicht haben (z.B. Feedback-Box)",
|
||||
"everyone": "Jeder",
|
||||
"fallback_for": "Ersatz für",
|
||||
"fallback_missing": "Fehlender Fallback",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
|
||||
"field_name_eg_score_price": "Feldname z.B. Punktzahl, Preis",
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
"formbricks_version": "Formbricks Version",
|
||||
"full_name": "Full name",
|
||||
"gathering_responses": "Gathering responses",
|
||||
"general": "General",
|
||||
"go_back": "Go Back",
|
||||
"go_to_dashboard": "Go to Dashboard",
|
||||
"hidden": "Hidden",
|
||||
@@ -377,6 +378,7 @@
|
||||
"switch_to": "Switch to {environment}",
|
||||
"table_items_deleted_successfully": "{type}s deleted successfully",
|
||||
"table_settings": "Table settings",
|
||||
"tags": "Tags",
|
||||
"targeting": "Targeting",
|
||||
"team": "Team",
|
||||
"team_access": "Team Access",
|
||||
@@ -1246,8 +1248,6 @@
|
||||
"add_description": "Add description",
|
||||
"add_ending": "Add ending",
|
||||
"add_ending_below": "Add ending below",
|
||||
"add_fallback": "Add",
|
||||
"add_fallback_placeholder": "Add a placeholder to show if the question gets skipped:",
|
||||
"add_hidden_field_id": "Add hidden field ID",
|
||||
"add_highlight_border": "Add highlight border",
|
||||
"add_highlight_border_description": "Add an outer border to your survey card.",
|
||||
@@ -1386,7 +1386,6 @@
|
||||
"error_saving_changes": "Error saving changes",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Even after they submitted a response (e.g. Feedback Box)",
|
||||
"everyone": "Everyone",
|
||||
"fallback_for": "Fallback for ",
|
||||
"fallback_missing": "Fallback missing",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
|
||||
"field_name_eg_score_price": "Field name e.g, score, price",
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
"formbricks_version": "Version de Formbricks",
|
||||
"full_name": "Nom complet",
|
||||
"gathering_responses": "Collecte des réponses",
|
||||
"general": "Général",
|
||||
"go_back": "Retourner",
|
||||
"go_to_dashboard": "Aller au tableau de bord",
|
||||
"hidden": "Caché",
|
||||
@@ -377,6 +378,7 @@
|
||||
"switch_to": "Passer à {environment}",
|
||||
"table_items_deleted_successfully": "{type}s supprimés avec succès",
|
||||
"table_settings": "Réglages de table",
|
||||
"tags": "Étiquettes",
|
||||
"targeting": "Ciblage",
|
||||
"team": "Équipe",
|
||||
"team_access": "Accès Équipe",
|
||||
@@ -1246,8 +1248,6 @@
|
||||
"add_description": "Ajouter une description",
|
||||
"add_ending": "Ajouter une fin",
|
||||
"add_ending_below": "Ajouter une fin ci-dessous",
|
||||
"add_fallback": "Ajouter",
|
||||
"add_fallback_placeholder": "Ajouter un espace réservé pour montrer si la question est ignorée :",
|
||||
"add_hidden_field_id": "Ajouter un champ caché ID",
|
||||
"add_highlight_border": "Ajouter une bordure de surlignage",
|
||||
"add_highlight_border_description": "Ajoutez une bordure extérieure à votre carte d'enquête.",
|
||||
@@ -1386,7 +1386,6 @@
|
||||
"error_saving_changes": "Erreur lors de l'enregistrement des modifications",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Même après avoir soumis une réponse (par exemple, la boîte de feedback)",
|
||||
"everyone": "Tout le monde",
|
||||
"fallback_for": "Solution de repli pour ",
|
||||
"fallback_missing": "Fallback manquant",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
|
||||
"field_name_eg_score_price": "Nom du champ par exemple, score, prix",
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
"formbricks_version": "Versão do Formbricks",
|
||||
"full_name": "Nome completo",
|
||||
"gathering_responses": "Recolhendo respostas",
|
||||
"general": "Geral",
|
||||
"go_back": "Voltar",
|
||||
"go_to_dashboard": "Ir para o Painel",
|
||||
"hidden": "Escondido",
|
||||
@@ -377,6 +378,7 @@
|
||||
"switch_to": "Mudar para {environment}",
|
||||
"table_items_deleted_successfully": "{type}s deletados com sucesso",
|
||||
"table_settings": "Arrumação da mesa",
|
||||
"tags": "Etiquetas",
|
||||
"targeting": "mirando",
|
||||
"team": "Time",
|
||||
"team_access": "Acesso da equipe",
|
||||
@@ -1246,8 +1248,6 @@
|
||||
"add_description": "Adicionar Descrição",
|
||||
"add_ending": "Adicionar final",
|
||||
"add_ending_below": "Adicione o final abaixo",
|
||||
"add_fallback": "Adicionar",
|
||||
"add_fallback_placeholder": "Adicionar um texto padrão para mostrar se a pergunta for ignorada:",
|
||||
"add_hidden_field_id": "Adicionar campo oculto ID",
|
||||
"add_highlight_border": "Adicionar borda de destaque",
|
||||
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de pesquisa.",
|
||||
@@ -1386,7 +1386,6 @@
|
||||
"error_saving_changes": "Erro ao salvar alterações",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de eles enviarem uma resposta (por exemplo, Caixa de Feedback)",
|
||||
"everyone": "Todo mundo",
|
||||
"fallback_for": "Alternativa para",
|
||||
"fallback_missing": "Faltando alternativa",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
||||
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
"formbricks_version": "Versão do Formbricks",
|
||||
"full_name": "Nome completo",
|
||||
"gathering_responses": "A recolher respostas",
|
||||
"general": "Geral",
|
||||
"go_back": "Voltar",
|
||||
"go_to_dashboard": "Ir para o Painel",
|
||||
"hidden": "Oculto",
|
||||
@@ -377,6 +378,7 @@
|
||||
"switch_to": "Mudar para {environment}",
|
||||
"table_items_deleted_successfully": "{type}s eliminados com sucesso",
|
||||
"table_settings": "Configurações da tabela",
|
||||
"tags": "Etiquetas",
|
||||
"targeting": "Segmentação",
|
||||
"team": "Equipa",
|
||||
"team_access": "Acesso da Equipa",
|
||||
@@ -1246,8 +1248,6 @@
|
||||
"add_description": "Adicionar descrição",
|
||||
"add_ending": "Adicionar encerramento",
|
||||
"add_ending_below": "Adicionar encerramento abaixo",
|
||||
"add_fallback": "Adicionar",
|
||||
"add_fallback_placeholder": "Adicionar um espaço reservado para mostrar se a pergunta for ignorada:",
|
||||
"add_hidden_field_id": "Adicionar ID do campo oculto",
|
||||
"add_highlight_border": "Adicionar borda de destaque",
|
||||
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de inquérito.",
|
||||
@@ -1386,7 +1386,6 @@
|
||||
"error_saving_changes": "Erro ao guardar alterações",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de terem enviado uma resposta (por exemplo, Caixa de Feedback)",
|
||||
"everyone": "Todos",
|
||||
"fallback_for": "Alternativa para ",
|
||||
"fallback_missing": "Substituição em falta",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
||||
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
"formbricks_version": "Formbricks 版本",
|
||||
"full_name": "全名",
|
||||
"gathering_responses": "收集回應中",
|
||||
"general": "一般",
|
||||
"go_back": "返回",
|
||||
"go_to_dashboard": "前往儀表板",
|
||||
"hidden": "隱藏",
|
||||
@@ -377,6 +378,7 @@
|
||||
"switch_to": "切換至 '{'environment'}'",
|
||||
"table_items_deleted_successfully": "'{'type'}' 已成功刪除",
|
||||
"table_settings": "表格設定",
|
||||
"tags": "標籤",
|
||||
"targeting": "目標設定",
|
||||
"team": "團隊",
|
||||
"team_access": "團隊存取權限",
|
||||
@@ -1246,8 +1248,6 @@
|
||||
"add_description": "新增描述",
|
||||
"add_ending": "新增結尾",
|
||||
"add_ending_below": "在下方新增結尾",
|
||||
"add_fallback": "新增",
|
||||
"add_fallback_placeholder": "新增用于顯示問題被跳過時的佔位符",
|
||||
"add_hidden_field_id": "新增隱藏欄位 ID",
|
||||
"add_highlight_border": "新增醒目提示邊框",
|
||||
"add_highlight_border_description": "在您的問卷卡片新增外邊框。",
|
||||
@@ -1386,7 +1386,6 @@
|
||||
"error_saving_changes": "儲存變更時發生錯誤",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "即使他們提交回應之後(例如,意見反應方塊)",
|
||||
"everyone": "所有人",
|
||||
"fallback_for": "備用 用於 ",
|
||||
"fallback_missing": "遺失的回退",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
|
||||
"field_name_eg_score_price": "欄位名稱,例如:分數、價格",
|
||||
|
||||
@@ -20,7 +20,6 @@ const mockContact = {
|
||||
environmentId: mockEnvironmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
attributes: [],
|
||||
};
|
||||
|
||||
describe("contact lib", () => {
|
||||
@@ -38,7 +37,9 @@ describe("contact lib", () => {
|
||||
const result = await getContact(mockContactId);
|
||||
|
||||
expect(result).toEqual(mockContact);
|
||||
expect(prisma.contact.findUnique).toHaveBeenCalledWith({ where: { id: mockContactId } });
|
||||
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockContactId },
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null if contact not found", async () => {
|
||||
@@ -46,7 +47,9 @@ describe("contact lib", () => {
|
||||
const result = await getContact(mockContactId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(prisma.contact.findUnique).toHaveBeenCalledWith({ where: { id: mockContactId } });
|
||||
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockContactId },
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw DatabaseError if prisma throws PrismaClientKnownRequestError", async () => {
|
||||
|
||||
@@ -20,18 +20,12 @@ const mockContacts = [
|
||||
{
|
||||
id: "contactId1",
|
||||
environmentId: mockEnvironmentId1,
|
||||
name: "Contact 1",
|
||||
email: "contact1@example.com",
|
||||
attributes: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "contactId2",
|
||||
environmentId: mockEnvironmentId2,
|
||||
name: "Contact 2",
|
||||
email: "contact2@example.com",
|
||||
attributes: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
|
||||
@@ -41,8 +41,6 @@ export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: We
|
||||
},
|
||||
];
|
||||
|
||||
const webhookName = webhook.name || t("common.webhook"); // NOSONAR // We want to check for empty strings
|
||||
|
||||
const handleTabClick = (index: number) => {
|
||||
setActiveTab(index);
|
||||
};
|
||||
@@ -58,7 +56,7 @@ export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: We
|
||||
<DialogContent disableCloseOnOutsideClick>
|
||||
<DialogHeader>
|
||||
<WebhookIcon />
|
||||
<DialogTitle>{webhookName}</DialogTitle> {/* NOSONAR // We want to check for empty strings */}
|
||||
<DialogTitle>{webhook.name || t("common.webhook")}</DialogTitle>{" "} {/* NOSONAR // We want to check for empty strings */}
|
||||
<DialogDescription>{webhook.url}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
|
||||
@@ -12,21 +12,6 @@ vi.mock("react-hot-toast", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: { [key: string]: string } = {
|
||||
"environments.surveys.edit.add_fallback_placeholder":
|
||||
"Add a placeholder to show if the question gets skipped:",
|
||||
"environments.surveys.edit.fallback_for": "Fallback for",
|
||||
"environments.surveys.edit.fallback_missing": "Fallback missing",
|
||||
"environments.surveys.edit.add_fallback": "Add",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("FallbackInput", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -40,21 +25,18 @@ describe("FallbackInput", () => {
|
||||
|
||||
const mockSetFallbacks = vi.fn();
|
||||
const mockAddFallback = vi.fn();
|
||||
const mockSetOpen = vi.fn();
|
||||
const mockInputRef = { current: null } as any;
|
||||
|
||||
const defaultProps = {
|
||||
filteredRecallItems: mockFilteredRecallItems,
|
||||
fallbacks: {},
|
||||
setFallbacks: mockSetFallbacks,
|
||||
fallbackInputRef: mockInputRef,
|
||||
addFallback: mockAddFallback,
|
||||
open: true,
|
||||
setOpen: mockSetOpen,
|
||||
};
|
||||
|
||||
test("renders fallback input component correctly", () => {
|
||||
render(<FallbackInput {...defaultProps} />);
|
||||
render(
|
||||
<FallbackInput
|
||||
filteredRecallItems={mockFilteredRecallItems}
|
||||
fallbacks={{}}
|
||||
setFallbacks={mockSetFallbacks}
|
||||
fallbackInputRef={mockInputRef}
|
||||
addFallback={mockAddFallback}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Add a placeholder to show if the question gets skipped:")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Fallback for Item 1")).toBeInTheDocument();
|
||||
@@ -63,7 +45,15 @@ describe("FallbackInput", () => {
|
||||
});
|
||||
|
||||
test("enables Add button when fallbacks are provided for all items", () => {
|
||||
render(<FallbackInput {...defaultProps} fallbacks={{ item1: "fallback1", item2: "fallback2" }} />);
|
||||
render(
|
||||
<FallbackInput
|
||||
filteredRecallItems={mockFilteredRecallItems}
|
||||
fallbacks={{ item1: "fallback1", item2: "fallback2" }}
|
||||
setFallbacks={mockSetFallbacks}
|
||||
fallbackInputRef={mockInputRef}
|
||||
addFallback={mockAddFallback}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Add" })).toBeEnabled();
|
||||
});
|
||||
@@ -71,7 +61,15 @@ describe("FallbackInput", () => {
|
||||
test("updates fallbacks when input changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<FallbackInput {...defaultProps} />);
|
||||
render(
|
||||
<FallbackInput
|
||||
filteredRecallItems={mockFilteredRecallItems}
|
||||
fallbacks={{}}
|
||||
setFallbacks={mockSetFallbacks}
|
||||
fallbackInputRef={mockInputRef}
|
||||
addFallback={mockAddFallback}
|
||||
/>
|
||||
);
|
||||
|
||||
const input1 = screen.getByPlaceholderText("Fallback for Item 1");
|
||||
await user.type(input1, "new fallback");
|
||||
@@ -82,38 +80,59 @@ describe("FallbackInput", () => {
|
||||
test("handles Enter key press correctly when input is valid", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<FallbackInput {...defaultProps} fallbacks={{ item1: "fallback1", item2: "fallback2" }} />);
|
||||
render(
|
||||
<FallbackInput
|
||||
filteredRecallItems={mockFilteredRecallItems}
|
||||
fallbacks={{ item1: "fallback1", item2: "fallback2" }}
|
||||
setFallbacks={mockSetFallbacks}
|
||||
fallbackInputRef={mockInputRef}
|
||||
addFallback={mockAddFallback}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("Fallback for Item 1");
|
||||
await user.type(input, "{Enter}");
|
||||
|
||||
expect(mockAddFallback).toHaveBeenCalled();
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("shows error toast and doesn't call addFallback when Enter is pressed with empty fallbacks", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<FallbackInput {...defaultProps} fallbacks={{ item1: "" }} />);
|
||||
render(
|
||||
<FallbackInput
|
||||
filteredRecallItems={mockFilteredRecallItems}
|
||||
fallbacks={{ item1: "" }}
|
||||
setFallbacks={mockSetFallbacks}
|
||||
fallbackInputRef={mockInputRef}
|
||||
addFallback={mockAddFallback}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("Fallback for Item 1");
|
||||
await user.type(input, "{Enter}");
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith("Fallback missing");
|
||||
expect(mockAddFallback).not.toHaveBeenCalled();
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls addFallback when Add button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<FallbackInput {...defaultProps} fallbacks={{ item1: "fallback1", item2: "fallback2" }} />);
|
||||
render(
|
||||
<FallbackInput
|
||||
filteredRecallItems={mockFilteredRecallItems}
|
||||
fallbacks={{ item1: "fallback1", item2: "fallback2" }}
|
||||
setFallbacks={mockSetFallbacks}
|
||||
fallbackInputRef={mockInputRef}
|
||||
addFallback={mockAddFallback}
|
||||
/>
|
||||
);
|
||||
|
||||
const addButton = screen.getByRole("button", { name: "Add" });
|
||||
await user.click(addButton);
|
||||
|
||||
expect(mockAddFallback).toHaveBeenCalled();
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("handles undefined recall items gracefully", () => {
|
||||
@@ -122,24 +141,32 @@ describe("FallbackInput", () => {
|
||||
undefined,
|
||||
];
|
||||
|
||||
render(<FallbackInput {...defaultProps} filteredRecallItems={mixedRecallItems} />);
|
||||
render(
|
||||
<FallbackInput
|
||||
filteredRecallItems={mixedRecallItems}
|
||||
fallbacks={{}}
|
||||
setFallbacks={mockSetFallbacks}
|
||||
fallbackInputRef={mockInputRef}
|
||||
addFallback={mockAddFallback}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText("Fallback for Item 1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("undefined")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("replaces 'nbsp' with space in fallback value", () => {
|
||||
render(<FallbackInput {...defaultProps} fallbacks={{ item1: "fallbacknbsptext" }} />);
|
||||
render(
|
||||
<FallbackInput
|
||||
filteredRecallItems={mockFilteredRecallItems}
|
||||
fallbacks={{ item1: "fallbacknbsptext" }}
|
||||
setFallbacks={mockSetFallbacks}
|
||||
fallbackInputRef={mockInputRef}
|
||||
addFallback={mockAddFallback}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("Fallback for Item 1");
|
||||
expect(input).toHaveValue("fallback text");
|
||||
});
|
||||
|
||||
test("does not render when open is false", () => {
|
||||
render(<FallbackInput {...defaultProps} open={false} />);
|
||||
|
||||
expect(
|
||||
screen.queryByText("Add a placeholder to show if the question gets skipped:")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { RefObject } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
@@ -12,8 +10,6 @@ interface FallbackInputProps {
|
||||
setFallbacks: (fallbacks: { [type: string]: string }) => void;
|
||||
fallbackInputRef: RefObject<HTMLInputElement>;
|
||||
addFallback: () => void;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const FallbackInput = ({
|
||||
@@ -22,74 +18,59 @@ export const FallbackInput = ({
|
||||
setFallbacks,
|
||||
fallbackInputRef,
|
||||
addFallback,
|
||||
open,
|
||||
setOpen,
|
||||
}: FallbackInputProps) => {
|
||||
const { t } = useTranslate();
|
||||
const containsEmptyFallback = () => {
|
||||
const fallBacksList = Object.values(fallbacks);
|
||||
return fallBacksList.length === 0 || fallBacksList.map((value) => value.trim()).includes("");
|
||||
return (
|
||||
Object.values(fallbacks)
|
||||
.map((value) => value.trim())
|
||||
.includes("") || Object.entries(fallbacks).length === 0
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="z-10 h-0 w-full cursor-pointer" />
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className="w-auto border border-slate-300 bg-slate-50 p-3 text-xs shadow-lg"
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={4}>
|
||||
<p className="font-medium">{t("environments.surveys.edit.add_fallback_placeholder")}</p>
|
||||
|
||||
<div className="mt-2 space-y-2">
|
||||
{filteredRecallItems.map((recallItem, idx) => {
|
||||
if (!recallItem) return null;
|
||||
return (
|
||||
<div key={recallItem.id} className="flex flex-col">
|
||||
<Input
|
||||
className="placeholder:text-md h-full bg-white"
|
||||
ref={idx === 0 ? fallbackInputRef : undefined}
|
||||
id="fallback"
|
||||
value={fallbacks[recallItem.id]?.replaceAll("nbsp", " ")}
|
||||
placeholder={`${t("environments.surveys.edit.fallback_for")} ${recallItem.label}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (containsEmptyFallback()) {
|
||||
toast.error(t("environments.surveys.edit.fallback_missing"));
|
||||
return;
|
||||
}
|
||||
addFallback();
|
||||
setOpen(false);
|
||||
<div className="absolute top-10 z-30 mt-1 rounded-md border border-slate-300 bg-slate-50 p-3 text-xs">
|
||||
<p className="font-medium">Add a placeholder to show if the question gets skipped:</p>
|
||||
{filteredRecallItems.map((recallItem) => {
|
||||
if (!recallItem) return;
|
||||
return (
|
||||
<div className="mt-2 flex flex-col" key={recallItem.id}>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
className="placeholder:text-md h-full bg-white"
|
||||
ref={fallbackInputRef}
|
||||
id="fallback"
|
||||
value={fallbacks[recallItem.id]?.replaceAll("nbsp", " ")}
|
||||
placeholder={"Fallback for " + recallItem.label}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") {
|
||||
e.preventDefault();
|
||||
if (containsEmptyFallback()) {
|
||||
toast.error("Fallback missing");
|
||||
return;
|
||||
}
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const newFallbacks = { ...fallbacks };
|
||||
newFallbacks[recallItem.id] = e.target.value;
|
||||
setFallbacks(newFallbacks);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
className="mt-2 h-full py-2"
|
||||
disabled={containsEmptyFallback()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
addFallback();
|
||||
setOpen(false);
|
||||
}}>
|
||||
{t("environments.surveys.edit.add_fallback")}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
addFallback();
|
||||
}
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const newFallbacks = { ...fallbacks };
|
||||
newFallbacks[recallItem.id] = e.target.value;
|
||||
setFallbacks(newFallbacks);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
className="mt-2 h-full py-2"
|
||||
disabled={containsEmptyFallback()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
addFallback();
|
||||
}}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,18 +14,6 @@ vi.mock("react-hot-toast", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: { [key: string]: string } = {
|
||||
"environments.surveys.edit.edit_recall": "Edit Recall",
|
||||
"environments.surveys.edit.add_fallback_placeholder": "Add fallback value...",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/recall", async () => {
|
||||
const actual = await vi.importActual("@/lib/utils/recall");
|
||||
return {
|
||||
@@ -41,48 +29,53 @@ vi.mock("@/lib/utils/recall", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock structuredClone if it's not available
|
||||
global.structuredClone = global.structuredClone || ((obj: any) => JSON.parse(JSON.stringify(obj)));
|
||||
|
||||
vi.mock("@/modules/survey/components/question-form-input/components/fallback-input", () => ({
|
||||
FallbackInput: vi
|
||||
.fn()
|
||||
.mockImplementation(({ addFallback, open, filteredRecallItems, fallbacks, setFallbacks }) =>
|
||||
open ? (
|
||||
<div data-testid="fallback-input">
|
||||
{filteredRecallItems.map((item: any) => (
|
||||
<input
|
||||
key={item.id}
|
||||
data-testid={`fallback-input-${item.id}`}
|
||||
placeholder={`Fallback for ${item.label}`}
|
||||
value={fallbacks[item.id] || ""}
|
||||
onChange={(e) => setFallbacks({ ...fallbacks, [item.id]: e.target.value })}
|
||||
/>
|
||||
))}
|
||||
<button type="button" data-testid="add-fallback-btn" onClick={addFallback}>
|
||||
Add Fallback
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
FallbackInput: vi.fn().mockImplementation(({ addFallback }) => (
|
||||
<div data-testid="fallback-input">
|
||||
<button data-testid="add-fallback-btn" onClick={addFallback}>
|
||||
Add Fallback
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/components/question-form-input/components/recall-item-select", () => ({
|
||||
RecallItemSelect: vi
|
||||
.fn()
|
||||
.mockImplementation(() => <div data-testid="recall-item-select">Recall Item Select</div>),
|
||||
RecallItemSelect: vi.fn().mockImplementation(({ addRecallItem }) => (
|
||||
<div data-testid="recall-item-select">
|
||||
<button
|
||||
data-testid="add-recall-item-btn"
|
||||
onClick={() => addRecallItem({ id: "testRecallId", label: "testLabel" })}>
|
||||
Add Recall Item
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
describe("RecallWrapper", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Ensure headlineToRecall always returns a string, even with null input
|
||||
beforeEach(() => {
|
||||
vi.mocked(recallUtils.headlineToRecall).mockImplementation((val) => val || "");
|
||||
vi.mocked(recallUtils.recallToHeadline).mockImplementation((val) => val || { en: "" });
|
||||
});
|
||||
|
||||
const mockSurvey = {
|
||||
id: "surveyId",
|
||||
name: "Test Survey",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
questions: [{ id: "q1", type: "text", headline: "Question 1" }],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const defaultProps = {
|
||||
value: "Test value",
|
||||
onChange: vi.fn(),
|
||||
localSurvey: {
|
||||
id: "testSurveyId",
|
||||
questions: [],
|
||||
hiddenFields: { enabled: false },
|
||||
} as unknown as TSurvey,
|
||||
questionId: "testQuestionId",
|
||||
localSurvey: mockSurvey,
|
||||
questionId: "q1",
|
||||
render: ({ value, onChange, highlightedJSX, children, isRecallSelectVisible }: any) => (
|
||||
<div>
|
||||
<div data-testid="rendered-text">{highlightedJSX}</div>
|
||||
@@ -96,143 +89,116 @@ describe("RecallWrapper", () => {
|
||||
onAddFallback: vi.fn(),
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Ensure headlineToRecall always returns a string, even with null input
|
||||
beforeEach(() => {
|
||||
vi.mocked(recallUtils.headlineToRecall).mockImplementation((val) => val || "");
|
||||
vi.mocked(recallUtils.recallToHeadline).mockImplementation((val) => val || { en: "" });
|
||||
// Reset all mocks to default state
|
||||
vi.mocked(recallUtils.getRecallItems).mockReturnValue([]);
|
||||
vi.mocked(recallUtils.findRecallInfoById).mockReturnValue(null);
|
||||
});
|
||||
|
||||
test("renders correctly with no recall items", () => {
|
||||
vi.mocked(recallUtils.getRecallItems).mockReturnValueOnce([]);
|
||||
|
||||
render(<RecallWrapper {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("test-input")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("rendered-text")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("fallback-input")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("recall-item-select")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly with recall items", () => {
|
||||
const recallItems = [{ id: "testRecallId", label: "testLabel", type: "question" }] as TSurveyRecallItem[];
|
||||
vi.mocked(recallUtils.getRecallItems).mockReturnValue(recallItems);
|
||||
const recallItems = [{ id: "item1", label: "Item 1" }] as TSurveyRecallItem[];
|
||||
|
||||
render(<RecallWrapper {...defaultProps} value="Test with #recall:testRecallId/fallback:# inside" />);
|
||||
vi.mocked(recallUtils.getRecallItems).mockReturnValueOnce(recallItems);
|
||||
|
||||
render(<RecallWrapper {...defaultProps} value="Test value with #recall:item1/fallback:# inside" />);
|
||||
|
||||
expect(screen.getByTestId("test-input")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("rendered-text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows recall item select when @ is typed", async () => {
|
||||
// Mock implementation to properly render the RecallItemSelect component
|
||||
vi.mocked(recallUtils.recallToHeadline).mockImplementation(() => ({ en: "Test value@" }));
|
||||
|
||||
render(<RecallWrapper {...defaultProps} />);
|
||||
|
||||
const input = screen.getByTestId("test-input");
|
||||
await userEvent.type(input, "@");
|
||||
|
||||
// Check if recall-select-visible is true
|
||||
expect(screen.getByTestId("recall-select-visible").textContent).toBe("true");
|
||||
|
||||
// Verify RecallItemSelect was called
|
||||
const mockedRecallItemSelect = vi.mocked(RecallItemSelect);
|
||||
expect(mockedRecallItemSelect).toHaveBeenCalled();
|
||||
|
||||
// Check that specific required props were passed
|
||||
const callArgs = mockedRecallItemSelect.mock.calls[0][0];
|
||||
expect(callArgs.localSurvey).toBe(mockSurvey);
|
||||
expect(callArgs.questionId).toBe("q1");
|
||||
expect(callArgs.selectedLanguageCode).toBe("en");
|
||||
expect(typeof callArgs.addRecallItem).toBe("function");
|
||||
});
|
||||
|
||||
test("adds recall item when selected", async () => {
|
||||
vi.mocked(recallUtils.getRecallItems).mockReturnValue([]);
|
||||
|
||||
render(<RecallWrapper {...defaultProps} />);
|
||||
|
||||
const input = screen.getByTestId("test-input");
|
||||
await userEvent.type(input, "@");
|
||||
|
||||
expect(RecallItemSelect).toHaveBeenCalled();
|
||||
// Instead of trying to find and click the button, call the addRecallItem function directly
|
||||
const mockedRecallItemSelect = vi.mocked(RecallItemSelect);
|
||||
expect(mockedRecallItemSelect).toHaveBeenCalled();
|
||||
|
||||
// Get the addRecallItem function that was passed to RecallItemSelect
|
||||
const addRecallItemFunction = mockedRecallItemSelect.mock.calls[0][0].addRecallItem;
|
||||
expect(typeof addRecallItemFunction).toBe("function");
|
||||
|
||||
// Call it directly with test data
|
||||
addRecallItemFunction({ id: "testRecallId", label: "testLabel" } as any);
|
||||
|
||||
// Just check that onChange was called with the expected parameters
|
||||
expect(defaultProps.onChange).toHaveBeenCalled();
|
||||
|
||||
// Instead of looking for fallback-input, check that onChange was called with the correct format
|
||||
const onChangeCall = defaultProps.onChange.mock.calls[1][0]; // Get the most recent call
|
||||
expect(onChangeCall).toContain("recall:testRecallId/fallback:");
|
||||
});
|
||||
|
||||
test("handles fallback addition through user interaction and verifies state changes", async () => {
|
||||
// Start with a value that already contains a recall item
|
||||
const valueWithRecall = "Test with #recall:testId/fallback:# inside";
|
||||
const recallItems = [{ id: "testId", label: "testLabel", type: "question" }] as TSurveyRecallItem[];
|
||||
test("handles fallback addition", async () => {
|
||||
const recallItems = [{ id: "testRecallId", label: "testLabel" }] as TSurveyRecallItem[];
|
||||
|
||||
// Set up mocks to simulate the component's recall detection and fallback functionality
|
||||
vi.mocked(recallUtils.getRecallItems).mockReturnValue(recallItems);
|
||||
vi.mocked(recallUtils.findRecallInfoById).mockReturnValue("#recall:testId/fallback:#");
|
||||
vi.mocked(recallUtils.getFallbackValues).mockReturnValue({ testId: "" });
|
||||
vi.mocked(recallUtils.findRecallInfoById).mockReturnValue("#recall:testRecallId/fallback:#");
|
||||
|
||||
// Track onChange and onAddFallback calls to verify component state changes
|
||||
const onChangeMock = vi.fn();
|
||||
const onAddFallbackMock = vi.fn();
|
||||
render(<RecallWrapper {...defaultProps} value="Test with #recall:testRecallId/fallback:# inside" />);
|
||||
|
||||
render(
|
||||
<RecallWrapper
|
||||
{...defaultProps}
|
||||
value={valueWithRecall}
|
||||
onChange={onChangeMock}
|
||||
onAddFallback={onAddFallbackMock}
|
||||
/>
|
||||
);
|
||||
// Find the edit button by its text content
|
||||
const editButton = screen.getByText("environments.surveys.edit.edit_recall");
|
||||
await userEvent.click(editButton);
|
||||
|
||||
// Verify that the edit recall button appears (indicating recall item is detected)
|
||||
expect(screen.getByText("Edit Recall")).toBeInTheDocument();
|
||||
// Directly call the addFallback method on the component
|
||||
// by simulating it manually since we can't access the component instance
|
||||
vi.mocked(recallUtils.findRecallInfoById).mockImplementation((val, id) => {
|
||||
return val.includes(`#recall:${id}`) ? `#recall:${id}/fallback:#` : null;
|
||||
});
|
||||
|
||||
// Click the "Edit Recall" button to trigger the fallback addition flow
|
||||
await userEvent.click(screen.getByText("Edit Recall"));
|
||||
// Directly call the onAddFallback prop
|
||||
defaultProps.onAddFallback("Test with #recall:testRecallId/fallback:value#");
|
||||
|
||||
// Since the mocked FallbackInput renders a simplified version,
|
||||
// check if the fallback input interface is shown
|
||||
const { FallbackInput } = await import(
|
||||
"@/modules/survey/components/question-form-input/components/fallback-input"
|
||||
);
|
||||
const FallbackInputMock = vi.mocked(FallbackInput);
|
||||
|
||||
// If the FallbackInput is rendered, verify its state and simulate the fallback addition
|
||||
if (FallbackInputMock.mock.calls.length > 0) {
|
||||
// Get the functions from the mock call
|
||||
const lastCall = FallbackInputMock.mock.calls[FallbackInputMock.mock.calls.length - 1][0];
|
||||
const { addFallback, setFallbacks } = lastCall;
|
||||
|
||||
// Simulate user adding a fallback value
|
||||
setFallbacks({ testId: "test fallback value" });
|
||||
|
||||
// Simulate clicking the "Add Fallback" button
|
||||
addFallback();
|
||||
|
||||
// Verify that the component's state was updated through the callbacks
|
||||
expect(onChangeMock).toHaveBeenCalled();
|
||||
expect(onAddFallbackMock).toHaveBeenCalled();
|
||||
|
||||
// Verify that the final value reflects the fallback addition
|
||||
const finalValue = onAddFallbackMock.mock.calls[0][0];
|
||||
expect(finalValue).toContain("#recall:testId/fallback:");
|
||||
expect(finalValue).toContain("test fallback value");
|
||||
expect(finalValue).toContain("# inside");
|
||||
} else {
|
||||
// Verify that the component is in a state that would allow fallback addition
|
||||
expect(screen.getByText("Edit Recall")).toBeInTheDocument();
|
||||
|
||||
// Verify that the callbacks are configured and would handle fallback addition
|
||||
expect(onChangeMock).toBeDefined();
|
||||
expect(onAddFallbackMock).toBeDefined();
|
||||
|
||||
// Simulate the expected behavior of fallback addition
|
||||
// This tests that the component would handle fallback addition correctly
|
||||
const simulatedFallbackValue = "Test with #recall:testId/fallback:test fallback value# inside";
|
||||
onAddFallbackMock(simulatedFallbackValue);
|
||||
|
||||
// Verify that the simulated fallback value has the correct structure
|
||||
expect(onAddFallbackMock).toHaveBeenCalledWith(simulatedFallbackValue);
|
||||
expect(simulatedFallbackValue).toContain("#recall:testId/fallback:");
|
||||
expect(simulatedFallbackValue).toContain("test fallback value");
|
||||
expect(simulatedFallbackValue).toContain("# inside");
|
||||
}
|
||||
expect(defaultProps.onAddFallback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("displays error when trying to add empty recall item", async () => {
|
||||
vi.mocked(recallUtils.getRecallItems).mockReturnValue([]);
|
||||
|
||||
render(<RecallWrapper {...defaultProps} />);
|
||||
|
||||
const input = screen.getByTestId("test-input");
|
||||
await userEvent.type(input, "@");
|
||||
|
||||
const mockedRecallItemSelect = vi.mocked(RecallItemSelect);
|
||||
const addRecallItemFunction = mockedRecallItemSelect.mock.calls[0][0].addRecallItem;
|
||||
const mockRecallItemSelect = vi.mocked(RecallItemSelect);
|
||||
|
||||
// Add an item with empty label
|
||||
addRecallItemFunction({ id: "testRecallId", label: "", type: "question" });
|
||||
// Simulate adding an empty recall item
|
||||
const addRecallItemCallback = mockRecallItemSelect.mock.calls[0][0].addRecallItem;
|
||||
addRecallItemCallback({ id: "emptyId", label: "" } as any);
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith("Recall item label cannot be empty");
|
||||
});
|
||||
@@ -241,17 +207,17 @@ describe("RecallWrapper", () => {
|
||||
render(<RecallWrapper {...defaultProps} />);
|
||||
|
||||
const input = screen.getByTestId("test-input");
|
||||
await userEvent.type(input, "New text");
|
||||
await userEvent.type(input, " additional");
|
||||
|
||||
expect(defaultProps.onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("updates internal value when props value changes", () => {
|
||||
const { rerender } = render(<RecallWrapper {...defaultProps} value="Initial value" />);
|
||||
const { rerender } = render(<RecallWrapper {...defaultProps} />);
|
||||
|
||||
rerender(<RecallWrapper {...defaultProps} value="Updated value" />);
|
||||
rerender(<RecallWrapper {...defaultProps} value="New value" />);
|
||||
|
||||
expect(screen.getByTestId("test-input")).toHaveValue("Updated value");
|
||||
expect(screen.getByTestId("test-input")).toHaveValue("New value");
|
||||
});
|
||||
|
||||
test("handles recall disable", () => {
|
||||
@@ -262,38 +228,4 @@ describe("RecallWrapper", () => {
|
||||
|
||||
expect(screen.getByTestId("recall-select-visible").textContent).toBe("false");
|
||||
});
|
||||
|
||||
test("shows edit recall button when value contains recall syntax", () => {
|
||||
const valueWithRecall = "Test with #recall:testId/fallback:# inside";
|
||||
|
||||
render(<RecallWrapper {...defaultProps} value={valueWithRecall} />);
|
||||
|
||||
expect(screen.getByText("Edit Recall")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("edit recall button toggles visibility state", async () => {
|
||||
const valueWithRecall = "Test with #recall:testId/fallback:# inside";
|
||||
|
||||
render(<RecallWrapper {...defaultProps} value={valueWithRecall} />);
|
||||
|
||||
const editButton = screen.getByText("Edit Recall");
|
||||
|
||||
// Verify the edit button is functional and clickable
|
||||
expect(editButton).toBeInTheDocument();
|
||||
expect(editButton).toBeEnabled();
|
||||
|
||||
// Click the "Edit Recall" button - this should work without errors
|
||||
await userEvent.click(editButton);
|
||||
|
||||
// The button should still be present and functional after clicking
|
||||
expect(editButton).toBeInTheDocument();
|
||||
expect(editButton).toBeEnabled();
|
||||
|
||||
// Click again to verify the button can be clicked multiple times
|
||||
await userEvent.click(editButton);
|
||||
|
||||
// Button should still be functional
|
||||
expect(editButton).toBeInTheDocument();
|
||||
expect(editButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -258,7 +258,7 @@ export const RecallWrapper = ({
|
||||
className="absolute right-2 top-full z-[1] flex h-6 cursor-pointer items-center rounded-b-lg rounded-t-none bg-slate-100 px-2.5 py-0 text-xs hover:bg-slate-200"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowFallbackInput(!showFallbackInput);
|
||||
setShowFallbackInput(true);
|
||||
}}>
|
||||
{t("environments.surveys.edit.edit_recall")}
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
@@ -284,8 +284,6 @@ export const RecallWrapper = ({
|
||||
setFallbacks={setFallbacks}
|
||||
fallbackInputRef={fallbackInputRef as React.RefObject<HTMLInputElement>}
|
||||
addFallback={addFallback}
|
||||
open={showFallbackInput}
|
||||
setOpen={setShowFallbackInput}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -245,17 +245,13 @@ describe("EndScreenForm", () => {
|
||||
const buttonLinkInput = container.querySelector("#buttonLink") as HTMLInputElement;
|
||||
expect(buttonLinkInput).toBeTruthy();
|
||||
|
||||
// Mock focus method
|
||||
const mockFocus = vi.fn();
|
||||
if (buttonLinkInput) {
|
||||
// Use vi.spyOn to properly mock the focus method
|
||||
const focusSpy = vi.spyOn(buttonLinkInput, "focus");
|
||||
|
||||
// Call focus to simulate the behavior
|
||||
vi.spyOn(HTMLElement.prototype, "focus").mockImplementation(mockFocus);
|
||||
buttonLinkInput.focus();
|
||||
|
||||
expect(focusSpy).toHaveBeenCalled();
|
||||
|
||||
// Clean up the spy
|
||||
focusSpy.mockRestore();
|
||||
expect(mockFocus).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ 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 React from "react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyLanguage,
|
||||
@@ -12,6 +13,16 @@ import {
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { MatrixQuestionForm } from "./matrix-question-form";
|
||||
|
||||
// Mock cuid2 to track CUID generation
|
||||
const mockCuids = ["cuid1", "cuid2", "cuid3", "cuid4", "cuid5", "cuid6"];
|
||||
let cuidIndex = 0;
|
||||
|
||||
vi.mock("@paralleldrive/cuid2", () => ({
|
||||
default: {
|
||||
createId: vi.fn(() => mockCuids[cuidIndex++]),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock window.matchMedia - required for useAutoAnimate
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
@@ -386,4 +397,223 @@ describe("MatrixQuestionForm", () => {
|
||||
|
||||
expect(mockUpdateQuestion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// CUID functionality tests
|
||||
describe("CUID Management", () => {
|
||||
beforeEach(() => {
|
||||
// Reset CUID index before each test
|
||||
cuidIndex = 0;
|
||||
});
|
||||
|
||||
test("generates stable CUIDs for rows and columns on initial render", () => {
|
||||
const { rerender } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
|
||||
// Check that CUIDs are generated for initial items
|
||||
expect(cuidIndex).toBe(6); // 3 rows + 3 columns
|
||||
|
||||
// Rerender with the same props - no new CUIDs should be generated
|
||||
rerender(<MatrixQuestionForm {...defaultProps} />);
|
||||
expect(cuidIndex).toBe(6); // Should remain the same
|
||||
});
|
||||
|
||||
test("maintains stable CUIDs across rerenders", () => {
|
||||
const TestComponent = ({ question }: { question: TSurveyMatrixQuestion }) => {
|
||||
return <MatrixQuestionForm {...defaultProps} question={question} />;
|
||||
};
|
||||
|
||||
const { rerender } = render(<TestComponent question={mockMatrixQuestion} />);
|
||||
|
||||
// Check initial CUID count
|
||||
expect(cuidIndex).toBe(6); // 3 rows + 3 columns
|
||||
|
||||
// Rerender multiple times
|
||||
rerender(<TestComponent question={mockMatrixQuestion} />);
|
||||
rerender(<TestComponent question={mockMatrixQuestion} />);
|
||||
rerender(<TestComponent question={mockMatrixQuestion} />);
|
||||
|
||||
// CUIDs should remain stable
|
||||
expect(cuidIndex).toBe(6); // Should not increase
|
||||
});
|
||||
|
||||
test("generates new CUIDs only when rows are added", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Create a test component that can update its props
|
||||
const TestComponent = () => {
|
||||
const [question, setQuestion] = React.useState(mockMatrixQuestion);
|
||||
|
||||
const handleUpdateQuestion = (_: number, updates: Partial<TSurveyMatrixQuestion>) => {
|
||||
setQuestion((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
return (
|
||||
<MatrixQuestionForm {...defaultProps} question={question} updateQuestion={handleUpdateQuestion} />
|
||||
);
|
||||
};
|
||||
|
||||
const { getByText } = render(<TestComponent />);
|
||||
|
||||
// Initial render should generate 6 CUIDs (3 rows + 3 columns)
|
||||
expect(cuidIndex).toBe(6);
|
||||
|
||||
// Add a new row
|
||||
const addRowButton = getByText("environments.surveys.edit.add_row");
|
||||
await user.click(addRowButton);
|
||||
|
||||
// Should generate 1 new CUID for the new row
|
||||
expect(cuidIndex).toBe(7);
|
||||
});
|
||||
|
||||
test("generates new CUIDs only when columns are added", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Create a test component that can update its props
|
||||
const TestComponent = () => {
|
||||
const [question, setQuestion] = React.useState(mockMatrixQuestion);
|
||||
|
||||
const handleUpdateQuestion = (_: number, updates: Partial<TSurveyMatrixQuestion>) => {
|
||||
setQuestion((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
return (
|
||||
<MatrixQuestionForm {...defaultProps} question={question} updateQuestion={handleUpdateQuestion} />
|
||||
);
|
||||
};
|
||||
|
||||
const { getByText } = render(<TestComponent />);
|
||||
|
||||
// Initial render should generate 6 CUIDs (3 rows + 3 columns)
|
||||
expect(cuidIndex).toBe(6);
|
||||
|
||||
// Add a new column
|
||||
const addColumnButton = getByText("environments.surveys.edit.add_column");
|
||||
await user.click(addColumnButton);
|
||||
|
||||
// Should generate 1 new CUID for the new column
|
||||
expect(cuidIndex).toBe(7);
|
||||
});
|
||||
|
||||
test("maintains CUID stability when items are deleted", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { findAllByTestId, rerender } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
|
||||
// Mock that no items are used in logic
|
||||
vi.mocked(findOptionUsedInLogic).mockReturnValue(-1);
|
||||
|
||||
// Initial render: 6 CUIDs generated
|
||||
expect(cuidIndex).toBe(6);
|
||||
|
||||
// Delete a row
|
||||
const deleteButtons = await findAllByTestId("tooltip-renderer");
|
||||
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
|
||||
|
||||
// No new CUIDs should be generated for deletion
|
||||
expect(cuidIndex).toBe(6);
|
||||
|
||||
// Rerender should not generate new CUIDs
|
||||
rerender(<MatrixQuestionForm {...defaultProps} />);
|
||||
expect(cuidIndex).toBe(6);
|
||||
});
|
||||
|
||||
test("handles mixed operations maintaining CUID stability", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Create a test component that can update its props
|
||||
const TestComponent = () => {
|
||||
const [question, setQuestion] = React.useState(mockMatrixQuestion);
|
||||
|
||||
const handleUpdateQuestion = (_: number, updates: Partial<TSurveyMatrixQuestion>) => {
|
||||
setQuestion((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
return (
|
||||
<MatrixQuestionForm {...defaultProps} question={question} updateQuestion={handleUpdateQuestion} />
|
||||
);
|
||||
};
|
||||
|
||||
const { getByText, findAllByTestId } = render(<TestComponent />);
|
||||
|
||||
// Mock that no items are used in logic
|
||||
vi.mocked(findOptionUsedInLogic).mockReturnValue(-1);
|
||||
|
||||
// Initial: 6 CUIDs
|
||||
expect(cuidIndex).toBe(6);
|
||||
|
||||
// Add a row: +1 CUID
|
||||
const addRowButton = getByText("environments.surveys.edit.add_row");
|
||||
await user.click(addRowButton);
|
||||
expect(cuidIndex).toBe(7);
|
||||
|
||||
// Add a column: +1 CUID
|
||||
const addColumnButton = getByText("environments.surveys.edit.add_column");
|
||||
await user.click(addColumnButton);
|
||||
expect(cuidIndex).toBe(8);
|
||||
|
||||
// Delete a row: no new CUIDs
|
||||
const deleteButtons = await findAllByTestId("tooltip-renderer");
|
||||
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
|
||||
expect(cuidIndex).toBe(8);
|
||||
|
||||
// Delete a column: no new CUIDs
|
||||
const updatedDeleteButtons = await findAllByTestId("tooltip-renderer");
|
||||
await user.click(updatedDeleteButtons[2].querySelector("button") as HTMLButtonElement);
|
||||
expect(cuidIndex).toBe(8);
|
||||
});
|
||||
|
||||
test("CUID arrays are properly maintained when items are deleted in order", async () => {
|
||||
const user = userEvent.setup();
|
||||
const propsWithManyRows = {
|
||||
...defaultProps,
|
||||
question: {
|
||||
...mockMatrixQuestion,
|
||||
rows: [
|
||||
createI18nString("Row 1", ["en"]),
|
||||
createI18nString("Row 2", ["en"]),
|
||||
createI18nString("Row 3", ["en"]),
|
||||
createI18nString("Row 4", ["en"]),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const { findAllByTestId } = render(<MatrixQuestionForm {...propsWithManyRows} />);
|
||||
|
||||
// Mock that no items are used in logic
|
||||
vi.mocked(findOptionUsedInLogic).mockReturnValue(-1);
|
||||
|
||||
// Initial: 7 CUIDs (4 rows + 3 columns)
|
||||
expect(cuidIndex).toBe(7);
|
||||
|
||||
// Delete first row
|
||||
const deleteButtons = await findAllByTestId("tooltip-renderer");
|
||||
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
|
||||
|
||||
// Verify the correct row was deleted (should be Row 2, Row 3, Row 4 remaining)
|
||||
expect(mockUpdateQuestion).toHaveBeenLastCalledWith(0, {
|
||||
rows: [
|
||||
propsWithManyRows.question.rows[1],
|
||||
propsWithManyRows.question.rows[2],
|
||||
propsWithManyRows.question.rows[3],
|
||||
],
|
||||
});
|
||||
|
||||
// No new CUIDs should be generated
|
||||
expect(cuidIndex).toBe(7);
|
||||
});
|
||||
|
||||
test("CUID generation is consistent across component instances", () => {
|
||||
// Reset CUID index
|
||||
cuidIndex = 0;
|
||||
|
||||
// Render first instance
|
||||
const { unmount } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
expect(cuidIndex).toBe(6);
|
||||
|
||||
// Unmount and render second instance
|
||||
unmount();
|
||||
render(<MatrixQuestionForm {...defaultProps} />);
|
||||
|
||||
// Should generate 6 more CUIDs for the new instance
|
||||
expect(cuidIndex).toBe(12);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,9 +8,10 @@ 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 cuid2 from "@paralleldrive/cuid2";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
import { type JSX, useMemo, useRef } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
@@ -39,6 +40,45 @@ export const MatrixQuestionForm = ({
|
||||
}: MatrixQuestionFormProps): JSX.Element => {
|
||||
const languageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
const { t } = useTranslate();
|
||||
|
||||
// Refs to maintain stable CUIDs across renders
|
||||
const cuidRefs = useRef<{
|
||||
rows: string[];
|
||||
columns: string[];
|
||||
}>({
|
||||
rows: [],
|
||||
columns: [],
|
||||
});
|
||||
|
||||
// Generic function to ensure CUIDs are synchronized with the current state
|
||||
const ensureCuids = (type: "rows" | "columns", currentItems: TI18nString[]) => {
|
||||
const currentCuids = cuidRefs.current[type];
|
||||
if (currentCuids.length !== currentItems.length) {
|
||||
if (currentItems.length > currentCuids.length) {
|
||||
// Add new CUIDs for added items
|
||||
const newCuids = Array(currentItems.length - currentCuids.length)
|
||||
.fill(null)
|
||||
.map(() => cuid2.createId());
|
||||
cuidRefs.current[type] = [...currentCuids, ...newCuids];
|
||||
} else {
|
||||
// Remove CUIDs for deleted items (keep the remaining ones in order)
|
||||
cuidRefs.current[type] = currentCuids.slice(0, currentItems.length);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Generic function to get items with CUIDs
|
||||
const getItemsWithCuid = (type: "rows" | "columns", items: TI18nString[]) => {
|
||||
ensureCuids(type, items);
|
||||
return items.map((item, index) => ({
|
||||
...item,
|
||||
id: cuidRefs.current[type][index],
|
||||
}));
|
||||
};
|
||||
|
||||
const rowsWithCuid = useMemo(() => getItemsWithCuid("rows", question.rows), [question.rows]);
|
||||
const columnsWithCuid = useMemo(() => getItemsWithCuid("columns", question.columns), [question.columns]);
|
||||
|
||||
// Function to add a new Label input field
|
||||
const handleAddLabel = (type: "row" | "column") => {
|
||||
if (type === "row") {
|
||||
@@ -79,6 +119,11 @@ export const MatrixQuestionForm = ({
|
||||
}
|
||||
|
||||
const updatedLabels = labels.filter((_, idx) => idx !== index);
|
||||
|
||||
// Update the CUID arrays when deleting
|
||||
const cuidType = type === "row" ? "rows" : "columns";
|
||||
cuidRefs.current[cuidType] = cuidRefs.current[cuidType].filter((_, idx) => idx !== index);
|
||||
|
||||
if (type === "row") {
|
||||
updateQuestion(questionIdx, { rows: updatedLabels });
|
||||
} else {
|
||||
@@ -182,8 +227,8 @@ export const MatrixQuestionForm = ({
|
||||
{/* 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}`}>
|
||||
{rowsWithCuid.map((row, index) => (
|
||||
<div className="flex items-center" key={row.id}>
|
||||
<QuestionFormInput
|
||||
id={`row-${index}`}
|
||||
label={""}
|
||||
@@ -232,8 +277,8 @@ export const MatrixQuestionForm = ({
|
||||
{/* 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}`}>
|
||||
{columnsWithCuid.map((column, index) => (
|
||||
<div className="flex items-center" key={column.id}>
|
||||
<QuestionFormInput
|
||||
id={`column-${index}`}
|
||||
label={""}
|
||||
|
||||
@@ -70,6 +70,14 @@ vi.mock("react-hot-toast", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock clipboard API
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: {
|
||||
writeText: vi.fn(),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe("SurveyDropDownMenu", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -78,7 +86,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
test("calls copySurveyLink when copy link is clicked", async () => {
|
||||
const mockRefresh = vi.fn().mockResolvedValue("fakeSingleUseId");
|
||||
const mockDeleteSurvey = vi.fn();
|
||||
const mockDuplicateSurvey = vi.fn();
|
||||
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
@@ -149,6 +156,135 @@ describe("SurveyDropDownMenu", () => {
|
||||
responseCount: 5,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
describe("clipboard functionality", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("pre-fetches single-use ID when dropdown opens", async () => {
|
||||
const mockRefreshSingleUseId = vi.fn().mockResolvedValue("test-single-use-id");
|
||||
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
environmentId="env123"
|
||||
survey={{ ...fakeSurvey, status: "completed" }}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={mockRefreshSingleUseId}
|
||||
deleteSurvey={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
|
||||
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
|
||||
|
||||
// Initially, refreshSingleUseId should not have been called
|
||||
expect(mockRefreshSingleUseId).not.toHaveBeenCalled();
|
||||
|
||||
// Open dropdown
|
||||
await userEvent.click(triggerElement);
|
||||
|
||||
// Now it should have been called
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshSingleUseId).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
test("does not pre-fetch single-use ID when dropdown is closed", async () => {
|
||||
const mockRefreshSingleUseId = vi.fn().mockResolvedValue("test-single-use-id");
|
||||
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
environmentId="env123"
|
||||
survey={{ ...fakeSurvey, status: "completed" }}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={mockRefreshSingleUseId}
|
||||
deleteSurvey={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Don't open dropdown
|
||||
|
||||
// Wait a bit to ensure useEffect doesn't run
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshSingleUseId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test("copies link with pre-fetched single-use ID", async () => {
|
||||
const mockRefreshSingleUseId = vi.fn().mockResolvedValue("test-single-use-id");
|
||||
const mockWriteText = vi.fn().mockResolvedValue(undefined);
|
||||
navigator.clipboard.writeText = mockWriteText;
|
||||
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
environmentId="env123"
|
||||
survey={{ ...fakeSurvey, status: "completed" }}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={mockRefreshSingleUseId}
|
||||
deleteSurvey={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
|
||||
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
|
||||
|
||||
// Open dropdown to trigger pre-fetch
|
||||
await userEvent.click(triggerElement);
|
||||
|
||||
// Wait for pre-fetch to complete
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshSingleUseId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Click copy link
|
||||
const copyLinkButton = screen.getByTestId("copy-link");
|
||||
await userEvent.click(copyLinkButton);
|
||||
|
||||
// Verify clipboard was called with the correct URL including single-use ID
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith("http://survey.test/s/testSurvey?suId=test-single-use-id");
|
||||
expect(mockToast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||
});
|
||||
});
|
||||
|
||||
test("handles copy link with undefined single-use ID", async () => {
|
||||
const mockRefreshSingleUseId = vi.fn().mockResolvedValue(undefined);
|
||||
const mockWriteText = vi.fn().mockResolvedValue(undefined);
|
||||
navigator.clipboard.writeText = mockWriteText;
|
||||
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
environmentId="env123"
|
||||
survey={{ ...fakeSurvey, status: "completed" }}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={mockRefreshSingleUseId}
|
||||
deleteSurvey={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
|
||||
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
|
||||
|
||||
// Open dropdown to trigger pre-fetch
|
||||
await userEvent.click(triggerElement);
|
||||
|
||||
// Wait for pre-fetch to complete
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshSingleUseId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Click copy link
|
||||
const copyLinkButton = screen.getByTestId("copy-link");
|
||||
await userEvent.click(copyLinkButton);
|
||||
|
||||
// Verify clipboard was called with base URL (no single-use ID)
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith("http://survey.test/s/testSurvey");
|
||||
expect(mockToast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("handleEditforActiveSurvey opens EditPublicSurveyAlertDialog for active surveys", async () => {
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
@@ -285,7 +421,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
expect(mockDeleteSurveyAction).toHaveBeenCalledWith({ surveyId: "testSurvey" });
|
||||
expect(mockDeleteSurvey).toHaveBeenCalledWith("testSurvey");
|
||||
expect(mockToast.success).toHaveBeenCalledWith("environments.surveys.survey_deleted_successfully");
|
||||
expect(mockRouterRefresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -396,7 +531,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
|
||||
// Verify that deleteSurvey callback was not called due to error
|
||||
expect(mockDeleteSurvey).not.toHaveBeenCalled();
|
||||
expect(mockRouterRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not call router.refresh or success toast when deleteSurveyAction throws", async () => {
|
||||
@@ -480,7 +614,7 @@ describe("SurveyDropDownMenu", () => {
|
||||
await userEvent.click(confirmDeleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(callOrder).toEqual(["deleteSurveyAction", "deleteSurvey", "toast.success", "router.refresh"]);
|
||||
expect(callOrder).toEqual(["deleteSurveyAction", "deleteSurvey", "toast.success"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,8 +30,9 @@ import {
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { CopySurveyModal } from "./copy-survey-modal";
|
||||
|
||||
interface SurveyDropDownMenuProps {
|
||||
@@ -61,18 +62,33 @@ export const SurveyDropDownMenu = ({
|
||||
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||
const [isCopyFormOpen, setIsCopyFormOpen] = useState(false);
|
||||
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
|
||||
const [newSingleUseId, setNewSingleUseId] = useState<string | undefined>(undefined);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const surveyLink = useMemo(() => publicDomain + "/s/" + survey.id, [survey.id, publicDomain]);
|
||||
|
||||
// Pre-fetch single-use ID when dropdown opens to avoid async delay during clipboard operation
|
||||
// This ensures Safari's clipboard API works by maintaining the user gesture context
|
||||
useEffect(() => {
|
||||
if (!isDropDownOpen) return;
|
||||
const fetchNewId = async () => {
|
||||
try {
|
||||
const newId = await refreshSingleUseId();
|
||||
setNewSingleUseId(newId ?? undefined);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
fetchNewId();
|
||||
}, [refreshSingleUseId, isDropDownOpen]);
|
||||
|
||||
const handleDeleteSurvey = async (surveyId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteSurveyAction({ surveyId });
|
||||
deleteSurvey(surveyId);
|
||||
toast.success(t("environments.surveys.survey_deleted_successfully"));
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.error_deleting_survey"));
|
||||
} finally {
|
||||
@@ -84,12 +100,11 @@ export const SurveyDropDownMenu = ({
|
||||
try {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
const newId = await refreshSingleUseId();
|
||||
const copiedLink = copySurveyLink(surveyLink, newId);
|
||||
const copiedLink = copySurveyLink(surveyLink, newSingleUseId);
|
||||
navigator.clipboard.writeText(copiedLink);
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
toast.error(t("environments.surveys.summary.failed_to_copy_link"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -174,6 +174,7 @@ describe("CardStylingSettings", () => {
|
||||
// Check for color picker labels
|
||||
expect(screen.getByText("environments.surveys.edit.card_background_color")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.edit.card_border_color")).toBeInTheDocument();
|
||||
|
||||
});
|
||||
|
||||
test("renders slider for roundness adjustment", () => {
|
||||
|
||||
@@ -162,6 +162,8 @@ export const CardStylingSettings = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"cardArrangement"}
|
||||
|
||||
@@ -59,7 +59,11 @@
|
||||
"questions": [
|
||||
{
|
||||
"allowMultipleFiles": true,
|
||||
"allowedFileExtensions": ["jpeg", "jpg", "png"],
|
||||
"allowedFileExtensions": [
|
||||
"jpeg",
|
||||
"jpg",
|
||||
"png"
|
||||
],
|
||||
"backButtonLabel": {
|
||||
"default": "Back"
|
||||
},
|
||||
@@ -302,7 +306,9 @@
|
||||
"filters": [],
|
||||
"id": "cm6ovw6jl000hsf0knn547w0y",
|
||||
"isPrivate": true,
|
||||
"surveys": ["cm6ovw6j7000gsf0kduf4oo4i"],
|
||||
"surveys": [
|
||||
"cm6ovw6j7000gsf0kduf4oo4i"
|
||||
],
|
||||
"title": "cm6ovw6j7000gsf0kduf4oo4i",
|
||||
"updatedAt": "2025-02-03T10:04:21.922Z"
|
||||
},
|
||||
@@ -369,4 +375,4 @@
|
||||
},
|
||||
"expiresAt": "2035-03-06T10:33:38.647Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `userId` on the `Contact` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Contact" DROP COLUMN "userId";
|
||||
@@ -112,14 +112,12 @@ model ContactAttributeKey {
|
||||
/// Contacts are environment-specific and can have multiple attributes and responses.
|
||||
///
|
||||
/// @property id - Unique identifier for the contact
|
||||
/// @property userId - Optional external user identifier
|
||||
/// @property environment - The environment this contact belongs to
|
||||
/// @property responses - Survey responses from this contact
|
||||
/// @property attributes - Custom attributes associated with this contact
|
||||
/// @property displays - Record of surveys shown to this contact
|
||||
model Contact {
|
||||
id String @id @default(cuid())
|
||||
userId String?
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -59,7 +59,11 @@
|
||||
"questions": [
|
||||
{
|
||||
"allowMultipleFiles": true,
|
||||
"allowedFileExtensions": ["jpeg", "jpg", "png"],
|
||||
"allowedFileExtensions": [
|
||||
"jpeg",
|
||||
"jpg",
|
||||
"png"
|
||||
],
|
||||
"backButtonLabel": {
|
||||
"default": "Back"
|
||||
},
|
||||
@@ -302,7 +306,9 @@
|
||||
"filters": [],
|
||||
"id": "cm6ovw6jl000hsf0knn547w0y",
|
||||
"isPrivate": true,
|
||||
"surveys": ["cm6ovw6j7000gsf0kduf4oo4i"],
|
||||
"surveys": [
|
||||
"cm6ovw6j7000gsf0kduf4oo4i"
|
||||
],
|
||||
"title": "cm6ovw6j7000gsf0kduf4oo4i",
|
||||
"updatedAt": "2025-02-03T10:04:21.922Z"
|
||||
},
|
||||
@@ -369,4 +375,4 @@
|
||||
},
|
||||
"expiresAt": "2035-03-06T10:33:38.647Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,14 +109,14 @@ export function Survey({
|
||||
setErrorType(errorCode);
|
||||
|
||||
if (getSetIsError) {
|
||||
getSetIsError((_prev) => {});
|
||||
getSetIsError((_prev) => { });
|
||||
}
|
||||
},
|
||||
onResponseSendingFinished: () => {
|
||||
setIsResponseSendingFinished(true);
|
||||
|
||||
if (getSetIsResponseSendingFinished) {
|
||||
getSetIsResponseSendingFinished((_prev) => {});
|
||||
getSetIsResponseSendingFinished((_prev) => { });
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -53,6 +53,8 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
|
||||
appendCssVariable("brand-text-color", "#ffffff");
|
||||
}
|
||||
|
||||
|
||||
|
||||
appendCssVariable("heading-color", styling.questionColor?.light);
|
||||
appendCssVariable("subheading-color", styling.questionColor?.light);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user