mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-26 16:30:21 -06:00
Compare commits
6 Commits
fix/duplic
...
hmaan-disp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
249e42b087 | ||
|
|
ba9b01a969 | ||
|
|
9ec4f22b1f | ||
|
|
ea00dec8e5 | ||
|
|
70cd751b0e | ||
|
|
4308d86bbc |
@@ -1,6 +1,7 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getDisplay } from "@/lib/display/service";
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -97,6 +98,14 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
return responses.badRequestResponse("Invalid file upload response");
|
||||
}
|
||||
|
||||
// check display
|
||||
if (responseInputData.displayId) {
|
||||
const display = await getDisplay(responseInputData.displayId);
|
||||
if (!display) {
|
||||
return responses.notFoundResponse("Display", responseInputData.displayId, true);
|
||||
}
|
||||
}
|
||||
|
||||
let response: TResponse;
|
||||
try {
|
||||
const meta: TResponseInput["meta"] = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/respons
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getDisplay } from "@/lib/display/service";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
||||
@@ -104,6 +105,14 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
);
|
||||
}
|
||||
|
||||
// check display
|
||||
if (responseInputData.displayId) {
|
||||
const display = await getDisplay(responseInputData.displayId);
|
||||
if (!display) {
|
||||
return responses.notFoundResponse("Display", responseInputData.displayId, true);
|
||||
}
|
||||
}
|
||||
|
||||
let response: TResponse;
|
||||
try {
|
||||
const meta: TResponseInputV2["meta"] = {
|
||||
|
||||
@@ -62,3 +62,19 @@ export const deleteDisplay = async (displayId: string): Promise<TDisplay> => {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDisplay = reactCache(async (displayId: string): Promise<{ id: string } | null> => {
|
||||
validateInputs([displayId, ZId]);
|
||||
try {
|
||||
const display = await prisma.display.findUnique({
|
||||
where: { id: displayId },
|
||||
select: { id: true },
|
||||
});
|
||||
return display;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { TagError } from "@/modules/projects/settings/types/tag";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { createTag, getTag, getTagsByEnvironmentId } from "./service";
|
||||
|
||||
@@ -113,7 +110,7 @@ describe("Tag Service", () => {
|
||||
vi.mocked(prisma.tag.create).mockResolvedValue(mockTag);
|
||||
|
||||
const result = await createTag("env1", "New Tag");
|
||||
expect(result).toEqual({ ok: true, data: mockTag });
|
||||
expect(result).toEqual(mockTag);
|
||||
expect(prisma.tag.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "New Tag",
|
||||
@@ -121,30 +118,5 @@ describe("Tag Service", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle duplicate tag name error", async () => {
|
||||
// const duplicateError = new Error("Unique constraint failed");
|
||||
// (duplicateError as any).code = "P2002";
|
||||
const duplicateError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "4.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.tag.create).mockRejectedValue(duplicateError);
|
||||
const result = await createTag("env1", "Duplicate Tag");
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: { message: "Tag with this name already exists", code: TagError.TAG_NAME_ALREADY_EXISTS },
|
||||
});
|
||||
});
|
||||
test("should handle general database errors", async () => {
|
||||
const generalError = new Error("Database connection failed");
|
||||
vi.mocked(prisma.tag.create).mockRejectedValue(generalError);
|
||||
const result = await createTag("env1", "New Tag");
|
||||
expect(result).toStrictEqual({
|
||||
ok: false,
|
||||
error: { message: "Database connection failed", code: TagError.UNEXPECTED_ERROR },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import "server-only";
|
||||
import { TagError } from "@/modules/projects/settings/types/tag";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
@@ -46,10 +42,7 @@ export const getTag = reactCache(async (id: string): Promise<TTag | null> => {
|
||||
}
|
||||
});
|
||||
|
||||
export const createTag = async (
|
||||
environmentId: string,
|
||||
name: string
|
||||
): Promise<Result<TTag, { code: TagError; message: string; meta?: Record<string, string> }>> => {
|
||||
export const createTag = async (environmentId: string, name: string): Promise<TTag> => {
|
||||
validateInputs([environmentId, ZId], [name, ZString]);
|
||||
|
||||
try {
|
||||
@@ -60,19 +53,8 @@ export const createTag = async (
|
||||
},
|
||||
});
|
||||
|
||||
return ok(tag);
|
||||
return tag;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
return err({
|
||||
code: TagError.TAG_NAME_ALREADY_EXISTS,
|
||||
message: "Tag with this name already exists",
|
||||
});
|
||||
}
|
||||
}
|
||||
return err({
|
||||
code: TagError.UNEXPECTED_ERROR,
|
||||
message: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1221,6 +1221,7 @@
|
||||
"copy_survey_description": "Kopiere diese Umfrage in eine andere Umgebung",
|
||||
"copy_survey_error": "Kopieren der Umfrage fehlgeschlagen",
|
||||
"copy_survey_link_to_clipboard": "Umfragelink in die Zwischenablage kopieren",
|
||||
"copy_survey_partially_success": "{success} Umfragen erfolgreich kopiert, {error} fehlgeschlagen.",
|
||||
"copy_survey_success": "Umfrage erfolgreich kopiert!",
|
||||
"delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest?",
|
||||
"edit": {
|
||||
|
||||
@@ -1221,6 +1221,7 @@
|
||||
"copy_survey_description": "Copy this survey to another environment",
|
||||
"copy_survey_error": "Failed to copy survey",
|
||||
"copy_survey_link_to_clipboard": "Copy survey link to clipboard",
|
||||
"copy_survey_partially_success": "{success} surveys copied successfully, {error} failed.",
|
||||
"copy_survey_success": "Survey copied successfully!",
|
||||
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses?",
|
||||
"edit": {
|
||||
|
||||
@@ -1221,6 +1221,7 @@
|
||||
"copy_survey_description": "Copier cette enquête dans un autre environnement",
|
||||
"copy_survey_error": "Échec de la copie du sondage",
|
||||
"copy_survey_link_to_clipboard": "Copier le lien du sondage dans le presse-papiers",
|
||||
"copy_survey_partially_success": "{success} enquêtes copiées avec succès, {error} échouées.",
|
||||
"copy_survey_success": "Enquête copiée avec succès !",
|
||||
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?",
|
||||
"edit": {
|
||||
|
||||
@@ -1221,6 +1221,7 @@
|
||||
"copy_survey_description": "Copiar essa pesquisa para outro ambiente",
|
||||
"copy_survey_error": "Falha ao copiar pesquisa",
|
||||
"copy_survey_link_to_clipboard": "Copiar link da pesquisa para a área de transferência",
|
||||
"copy_survey_partially_success": "{success} pesquisas copiadas com sucesso, {error} falharam.",
|
||||
"copy_survey_success": "Pesquisa copiada com sucesso!",
|
||||
"delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas?",
|
||||
"edit": {
|
||||
|
||||
@@ -1221,6 +1221,7 @@
|
||||
"copy_survey_description": "Copiar este questionário para outro ambiente",
|
||||
"copy_survey_error": "Falha ao copiar inquérito",
|
||||
"copy_survey_link_to_clipboard": "Copiar link do inquérito para a área de transferência",
|
||||
"copy_survey_partially_success": "{success} inquéritos copiados com sucesso, {error} falharam.",
|
||||
"copy_survey_success": "Inquérito copiado com sucesso!",
|
||||
"delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas?",
|
||||
"edit": {
|
||||
|
||||
@@ -1221,6 +1221,7 @@
|
||||
"copy_survey_description": "將此問卷複製到另一個環境",
|
||||
"copy_survey_error": "無法複製問卷",
|
||||
"copy_survey_link_to_clipboard": "將問卷連結複製到剪貼簿",
|
||||
"copy_survey_partially_success": "{success} 個問卷已成功複製,{error} 個失敗。",
|
||||
"copy_survey_success": "問卷已成功複製!",
|
||||
"delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?",
|
||||
"edit": {
|
||||
|
||||
@@ -50,14 +50,8 @@ export const createTagAction = authenticatedActionClient.schema(ZCreateTagAction
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
const result = await createTag(parsedInput.environmentId, parsedInput.tagName);
|
||||
|
||||
if (result.ok) {
|
||||
ctx.auditLoggingCtx.tagId = result.data.id;
|
||||
ctx.auditLoggingCtx.newObject = result.data;
|
||||
} else {
|
||||
ctx.auditLoggingCtx.newObject = null;
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.tagId = result.id;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { TagError } from "@/modules/projects/settings/types/tag";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -152,9 +150,7 @@ describe("ResponseTagsWrapper", () => {
|
||||
});
|
||||
|
||||
test("creates a new tag via TagsCombobox and calls updateFetchedResponses on success", async () => {
|
||||
vi.mocked(createTagAction).mockResolvedValueOnce({
|
||||
data: { ok: true, data: { id: "newTagId", name: "NewTag" } },
|
||||
} as any);
|
||||
vi.mocked(createTagAction).mockResolvedValueOnce({ data: { id: "newTagId", name: "NewTag" } } as any);
|
||||
vi.mocked(createTagToResponseAction).mockResolvedValueOnce({ data: "tagAdded" } as any);
|
||||
render(
|
||||
<ResponseTagsWrapper
|
||||
@@ -180,10 +176,7 @@ describe("ResponseTagsWrapper", () => {
|
||||
|
||||
test("handles createTagAction failure and shows toast error", async () => {
|
||||
vi.mocked(createTagAction).mockResolvedValueOnce({
|
||||
data: {
|
||||
ok: false,
|
||||
error: { message: "Unique constraint failed on the fields", code: TagError.TAG_NAME_ALREADY_EXISTS },
|
||||
},
|
||||
error: { details: [{ issue: "Unique constraint failed on the fields" }] },
|
||||
} as any);
|
||||
render(
|
||||
<ResponseTagsWrapper
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { TagError } from "@/modules/projects/settings/types/tag";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Tag } from "@/modules/ui/components/tag";
|
||||
import { TagsCombobox } from "@/modules/ui/components/tags-combobox";
|
||||
@@ -59,57 +58,6 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [tagIdToHighlight]);
|
||||
|
||||
const handleCreateTag = async (tagName: string) => {
|
||||
setOpen(false);
|
||||
|
||||
const createTagResponse = await createTagAction({
|
||||
environmentId,
|
||||
tagName: tagName?.trim() ?? "",
|
||||
});
|
||||
|
||||
if (createTagResponse?.data?.ok) {
|
||||
const tag = createTagResponse.data.data;
|
||||
setTagsState((prevTags) => [
|
||||
...prevTags,
|
||||
{
|
||||
tagId: tag.id,
|
||||
tagName: tag.name,
|
||||
},
|
||||
]);
|
||||
|
||||
const createTagToResponseActionResponse = await createTagToResponseAction({
|
||||
responseId,
|
||||
tagId: tag.id,
|
||||
});
|
||||
|
||||
if (createTagToResponseActionResponse?.data) {
|
||||
updateFetchedResponses();
|
||||
setSearchValue("");
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createTagToResponseActionResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (createTagResponse?.data?.error?.code === TagError.TAG_NAME_ALREADY_EXISTS) {
|
||||
toast.error(t("environments.surveys.responses.tag_already_exists"), {
|
||||
duration: 2000,
|
||||
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
|
||||
});
|
||||
|
||||
setSearchValue("");
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = getFormattedErrorMessage(createTagResponse);
|
||||
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
|
||||
duration: 2000,
|
||||
});
|
||||
setSearchValue("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-t border-slate-200 px-6 py-4">
|
||||
{!isReadOnly && (
|
||||
@@ -145,7 +93,46 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
setSearchValue={setSearchValue}
|
||||
tags={environmentTags?.map((tag) => ({ value: tag.id, label: tag.name })) ?? []}
|
||||
currentTags={tagsState.map((tag) => ({ value: tag.tagId, label: tag.tagName }))}
|
||||
createTag={handleCreateTag}
|
||||
createTag={async (tagName) => {
|
||||
setOpen(false);
|
||||
|
||||
const createTagResponse = await createTagAction({
|
||||
environmentId,
|
||||
tagName: tagName?.trim() ?? "",
|
||||
});
|
||||
if (createTagResponse?.data) {
|
||||
setTagsState((prevTags) => [
|
||||
...prevTags,
|
||||
{
|
||||
tagId: createTagResponse.data?.id ?? "",
|
||||
tagName: createTagResponse.data?.name ?? "",
|
||||
},
|
||||
]);
|
||||
const createTagToResponseActionResponse = await createTagToResponseAction({
|
||||
responseId,
|
||||
tagId: createTagResponse.data.id,
|
||||
});
|
||||
|
||||
if (createTagToResponseActionResponse?.data) {
|
||||
updateFetchedResponses();
|
||||
setSearchValue("");
|
||||
}
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createTagResponse);
|
||||
if (errorMessage.includes("Unique constraint failed on the fields")) {
|
||||
toast.error(t("environments.surveys.responses.tag_already_exists"), {
|
||||
duration: 2000,
|
||||
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
|
||||
});
|
||||
} else {
|
||||
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
|
||||
setSearchValue("");
|
||||
}
|
||||
}}
|
||||
addTag={(tagId) => {
|
||||
setTagsState((prevTags) => [
|
||||
...prevTags,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { TagError } from "@/modules/projects/settings/types/tag";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ok } from "@formbricks/types/error-handlers";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { deleteTag, mergeTags, updateTagName } from "./tag";
|
||||
|
||||
@@ -61,88 +57,25 @@ describe("tag lib", () => {
|
||||
test("deletes tag and revalidates cache", async () => {
|
||||
vi.mocked(prisma.tag.delete).mockResolvedValueOnce(baseTag);
|
||||
const result = await deleteTag(baseTag.id);
|
||||
expect(result).toEqual(ok(baseTag));
|
||||
expect(result).toEqual(baseTag);
|
||||
expect(prisma.tag.delete).toHaveBeenCalledWith({ where: { id: baseTag.id } });
|
||||
});
|
||||
test("returns tag_not_found on tag not found", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
|
||||
code: PrismaErrorType.RecordDoesNotExist,
|
||||
clientVersion: "test",
|
||||
});
|
||||
vi.mocked(prisma.tag.delete).mockRejectedValueOnce(prismaError);
|
||||
|
||||
const result = await deleteTag(baseTag.id);
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error).toStrictEqual({
|
||||
code: TagError.TAG_NOT_FOUND,
|
||||
message: "Tag not found",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("throws error on prisma error", async () => {
|
||||
vi.mocked(prisma.tag.delete).mockRejectedValueOnce(new Error("fail"));
|
||||
const result = await deleteTag(baseTag.id);
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error).toStrictEqual({
|
||||
code: "unexpected_error",
|
||||
message: "fail",
|
||||
});
|
||||
}
|
||||
await expect(deleteTag(baseTag.id)).rejects.toThrow("fail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTagName", () => {
|
||||
test("returns ok on successful update", async () => {
|
||||
test("updates tag name and revalidates cache", async () => {
|
||||
vi.mocked(prisma.tag.update).mockResolvedValueOnce(baseTag);
|
||||
|
||||
const result = await updateTagName(baseTag.id, "Tag1");
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(baseTag);
|
||||
}
|
||||
|
||||
expect(prisma.tag.update).toHaveBeenCalledWith({
|
||||
where: { id: baseTag.id },
|
||||
data: { name: "Tag1" },
|
||||
});
|
||||
expect(result).toEqual(baseTag);
|
||||
expect(prisma.tag.update).toHaveBeenCalledWith({ where: { id: baseTag.id }, data: { name: "Tag1" } });
|
||||
});
|
||||
|
||||
test("returns unique_constraint_failed on unique constraint violation", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "test",
|
||||
});
|
||||
vi.mocked(prisma.tag.update).mockRejectedValueOnce(prismaError);
|
||||
|
||||
const result = await updateTagName(baseTag.id, "Tag1");
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error).toStrictEqual({
|
||||
code: TagError.TAG_NAME_ALREADY_EXISTS,
|
||||
message: "Tag with this name already exists",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("returns internal_server_error on unknown error", async () => {
|
||||
test("throws error on prisma error", async () => {
|
||||
vi.mocked(prisma.tag.update).mockRejectedValueOnce(new Error("fail"));
|
||||
|
||||
const result = await updateTagName(baseTag.id, "Tag1");
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error).toStrictEqual({
|
||||
code: "unexpected_error",
|
||||
message: "fail",
|
||||
});
|
||||
}
|
||||
await expect(updateTagName(baseTag.id, "Tag1")).rejects.toThrow("fail");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,7 +87,7 @@ describe("tag lib", () => {
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValueOnce([{ id: "resp1" }] as any);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValueOnce(undefined).mockResolvedValueOnce(undefined);
|
||||
const result = await mergeTags(baseTag.id, newTag.id);
|
||||
expect(result).toEqual(ok(newTag));
|
||||
expect(result).toEqual(newTag);
|
||||
expect(prisma.tag.findUnique).toHaveBeenCalledWith({ where: { id: baseTag.id } });
|
||||
expect(prisma.tag.findUnique).toHaveBeenCalledWith({ where: { id: newTag.id } });
|
||||
expect(prisma.response.findMany).toHaveBeenCalled();
|
||||
@@ -167,45 +100,21 @@ describe("tag lib", () => {
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValueOnce([] as any);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValueOnce(undefined);
|
||||
const result = await mergeTags(baseTag.id, newTag.id);
|
||||
expect(result).toEqual(ok(newTag));
|
||||
expect(result).toEqual(newTag);
|
||||
});
|
||||
test("throws if original tag not found", async () => {
|
||||
vi.mocked(prisma.tag.findUnique).mockResolvedValueOnce(null);
|
||||
const result = await mergeTags(baseTag.id, newTag.id);
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error).toStrictEqual({
|
||||
code: "tag_not_found",
|
||||
message: "Tag not found",
|
||||
});
|
||||
}
|
||||
await expect(mergeTags(baseTag.id, newTag.id)).rejects.toThrow("Tag not found");
|
||||
});
|
||||
test("throws if new tag not found", async () => {
|
||||
vi.mocked(prisma.tag.findUnique)
|
||||
.mockResolvedValueOnce(baseTag as any)
|
||||
.mockResolvedValueOnce(null);
|
||||
const result = await mergeTags(baseTag.id, newTag.id);
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error).toStrictEqual({
|
||||
code: "tag_not_found",
|
||||
message: "Tag not found",
|
||||
});
|
||||
}
|
||||
await expect(mergeTags(baseTag.id, newTag.id)).rejects.toThrow("Tag not found");
|
||||
});
|
||||
test("throws on prisma error", async () => {
|
||||
vi.mocked(prisma.tag.findUnique).mockRejectedValueOnce(new Error("fail"));
|
||||
const result = await mergeTags(baseTag.id, newTag.id);
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error).toStrictEqual({
|
||||
code: "unexpected_error",
|
||||
message: "fail",
|
||||
});
|
||||
}
|
||||
await expect(mergeTags(baseTag.id, newTag.id)).rejects.toThrow("fail");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import "server-only";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { TagError } from "@/modules/projects/settings/types/tag";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { ZId, ZString } from "@formbricks/types/common";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
|
||||
export const deleteTag = async (
|
||||
id: string
|
||||
): Promise<Result<TTag, { code: TagError; message: string; meta?: Record<string, string> }>> => {
|
||||
export const deleteTag = async (id: string): Promise<TTag> => {
|
||||
validateInputs([id, ZId]);
|
||||
|
||||
try {
|
||||
@@ -20,56 +14,32 @@ export const deleteTag = async (
|
||||
},
|
||||
});
|
||||
|
||||
return ok(tag);
|
||||
return tag;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.RecordDoesNotExist) {
|
||||
return err({
|
||||
code: TagError.TAG_NOT_FOUND,
|
||||
message: "Tag not found",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return err({
|
||||
code: TagError.UNEXPECTED_ERROR,
|
||||
message: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateTagName = async (
|
||||
id: string,
|
||||
name: string
|
||||
): Promise<Result<TTag, { code: TagError; message: string; meta?: Record<string, string> }>> => {
|
||||
export const updateTagName = async (id: string, name: string): Promise<TTag> => {
|
||||
validateInputs([id, ZId], [name, ZString]);
|
||||
|
||||
try {
|
||||
const tag = await prisma.tag.update({
|
||||
where: { id },
|
||||
data: { name },
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
return ok(tag);
|
||||
return tag;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
return err({
|
||||
code: TagError.TAG_NAME_ALREADY_EXISTS,
|
||||
message: "Tag with this name already exists",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return err({
|
||||
code: TagError.UNEXPECTED_ERROR,
|
||||
message: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const mergeTags = async (
|
||||
originalTagId: string,
|
||||
newTagId: string
|
||||
): Promise<Result<TTag, { code: TagError; message: string; meta?: Record<string, string> }>> => {
|
||||
export const mergeTags = async (originalTagId: string, newTagId: string): Promise<TTag | undefined> => {
|
||||
validateInputs([originalTagId, ZId], [newTagId, ZId]);
|
||||
|
||||
try {
|
||||
@@ -82,10 +52,7 @@ export const mergeTags = async (
|
||||
});
|
||||
|
||||
if (!originalTag) {
|
||||
return err({
|
||||
code: TagError.TAG_NOT_FOUND,
|
||||
message: "Tag not found",
|
||||
});
|
||||
throw new Error("Tag not found");
|
||||
}
|
||||
|
||||
let newTag: TTag | null;
|
||||
@@ -97,10 +64,7 @@ export const mergeTags = async (
|
||||
});
|
||||
|
||||
if (!newTag) {
|
||||
return err({
|
||||
code: TagError.TAG_NOT_FOUND,
|
||||
message: "Tag not found",
|
||||
});
|
||||
throw new Error("Tag not found");
|
||||
}
|
||||
|
||||
// finds all the responses that have both the tags
|
||||
@@ -169,7 +133,7 @@ export const mergeTags = async (
|
||||
}),
|
||||
]);
|
||||
|
||||
return ok(newTag);
|
||||
return newTag;
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
@@ -189,11 +153,8 @@ export const mergeTags = async (
|
||||
}),
|
||||
]);
|
||||
|
||||
return ok(newTag);
|
||||
return newTag;
|
||||
} catch (error) {
|
||||
return err({
|
||||
code: TagError.UNEXPECTED_ERROR,
|
||||
message: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -46,11 +46,7 @@ export const deleteTagAction = authenticatedActionClient.schema(ZDeleteTagAction
|
||||
ctx.auditLoggingCtx.tagId = parsedInput.tagId;
|
||||
|
||||
const result = await deleteTag(parsedInput.tagId);
|
||||
if (result.ok) {
|
||||
ctx.auditLoggingCtx.oldObject = result.data;
|
||||
} else {
|
||||
ctx.auditLoggingCtx.oldObject = null;
|
||||
}
|
||||
ctx.auditLoggingCtx.oldObject = result;
|
||||
return result;
|
||||
}
|
||||
)
|
||||
@@ -89,11 +85,7 @@ export const updateTagNameAction = authenticatedActionClient.schema(ZUpdateTagNa
|
||||
|
||||
const result = await updateTagName(parsedInput.tagId, parsedInput.name);
|
||||
|
||||
if (result.ok) {
|
||||
ctx.auditLoggingCtx.newObject = result.data;
|
||||
} else {
|
||||
ctx.auditLoggingCtx.newObject = null;
|
||||
}
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
}
|
||||
)
|
||||
@@ -138,11 +130,7 @@ export const mergeTagsAction = authenticatedActionClient.schema(ZMergeTagsAction
|
||||
|
||||
const result = await mergeTags(parsedInput.originalTagId, parsedInput.newTagId);
|
||||
|
||||
if (result.ok) {
|
||||
ctx.auditLoggingCtx.newObject = result.data;
|
||||
} else {
|
||||
ctx.auditLoggingCtx.newObject = null;
|
||||
}
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
updateTagNameAction,
|
||||
} from "@/modules/projects/settings/tags/actions";
|
||||
import { MergeTagsCombobox } from "@/modules/projects/settings/tags/components/merge-tags-combobox";
|
||||
import { TagError } from "@/modules/projects/settings/types/tag";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
@@ -48,64 +47,15 @@ export const SingleTag: React.FC<SingleTagProps> = ({
|
||||
const confirmDeleteTag = async () => {
|
||||
const deleteTagResponse = await deleteTagAction({ tagId });
|
||||
if (deleteTagResponse?.data) {
|
||||
if (deleteTagResponse.data.ok) {
|
||||
toast.success(t("environments.project.tags.tag_deleted"));
|
||||
updateTagsCount();
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = deleteTagResponse.data?.error?.message;
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
toast.success(t("environments.project.tags.tag_deleted"));
|
||||
updateTagsCount();
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deleteTagResponse);
|
||||
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateTagName = async (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
const result = await updateTagNameAction({ tagId, name: e.target.value.trim() });
|
||||
if (result?.data) {
|
||||
if (result.data.ok) {
|
||||
setUpdateTagError(false);
|
||||
toast.success(t("environments.project.tags.tag_updated"));
|
||||
} else if (result.data?.error?.code === TagError.TAG_NAME_ALREADY_EXISTS) {
|
||||
toast.error(t("environments.project.tags.tag_already_exists"), {
|
||||
duration: 2000,
|
||||
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
|
||||
});
|
||||
setUpdateTagError(true);
|
||||
} else {
|
||||
const errorMessage = result.data?.error?.message;
|
||||
toast.error(errorMessage);
|
||||
setUpdateTagError(true);
|
||||
}
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"));
|
||||
setUpdateTagError(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMergeTags = async (newTagId: string) => {
|
||||
setIsMergingTags(true);
|
||||
const mergeTagsResponse = await mergeTagsAction({ originalTagId: tagId, newTagId });
|
||||
|
||||
if (mergeTagsResponse?.data) {
|
||||
if (mergeTagsResponse.data.ok) {
|
||||
toast.success(t("environments.project.tags.tags_merged"));
|
||||
updateTagsCount();
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = mergeTagsResponse.data?.error?.message;
|
||||
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(mergeTagsResponse);
|
||||
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
setIsMergingTags(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full" key={tagId}>
|
||||
<div className="grid h-16 grid-cols-4 content-center rounded-lg">
|
||||
@@ -120,7 +70,31 @@ export const SingleTag: React.FC<SingleTagProps> = ({
|
||||
: "border-slate-200 focus:border-slate-500"
|
||||
)}
|
||||
defaultValue={tagName}
|
||||
onBlur={handleUpdateTagName}
|
||||
onBlur={(e) => {
|
||||
updateTagNameAction({ tagId, name: e.target.value.trim() }).then((updateTagNameResponse) => {
|
||||
if (updateTagNameResponse?.data) {
|
||||
setUpdateTagError(false);
|
||||
toast.success(t("environments.project.tags.tag_updated"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updateTagNameResponse);
|
||||
if (
|
||||
errorMessage?.includes(
|
||||
t("environments.project.tags.unique_constraint_failed_on_the_fields")
|
||||
)
|
||||
) {
|
||||
toast.error(t("environments.project.tags.tag_already_exists"), {
|
||||
duration: 2000,
|
||||
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
|
||||
});
|
||||
} else {
|
||||
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
setUpdateTagError(true);
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,7 +117,20 @@ export const SingleTag: React.FC<SingleTagProps> = ({
|
||||
?.filter((tag) => tag.id !== tagId)
|
||||
?.map((tag) => ({ label: tag.name, value: tag.id })) ?? []
|
||||
}
|
||||
onSelect={handleMergeTags}
|
||||
onSelect={(newTagId) => {
|
||||
setIsMergingTags(true);
|
||||
mergeTagsAction({ originalTagId: tagId, newTagId }).then((mergeTagsResponse) => {
|
||||
if (mergeTagsResponse?.data) {
|
||||
toast.success(t("environments.project.tags.tags_merged"));
|
||||
updateTagsCount();
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(mergeTagsResponse);
|
||||
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
setIsMergingTags(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export enum TagError {
|
||||
TAG_NOT_FOUND = "tag_not_found",
|
||||
TAG_NAME_ALREADY_EXISTS = "tag_name_already_exists",
|
||||
UNEXPECTED_ERROR = "unexpected_error",
|
||||
}
|
||||
@@ -144,9 +144,7 @@ const evaluateFollowUp = async (
|
||||
*/
|
||||
export const sendFollowUpsForResponse = async (
|
||||
responseId: string
|
||||
): Promise<
|
||||
Result<FollowUpResult[], { code: FollowUpSendError; message: string; meta?: Record<string, string> }>
|
||||
> => {
|
||||
): Promise<Result<FollowUpResult[], { code: FollowUpSendError; message: string; meta?: any }>> => {
|
||||
try {
|
||||
validateInputs([responseId, ZId]);
|
||||
// Get the response first to get the survey ID
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
|
||||
import { TUserProject } from "@/modules/survey/list/types/projects";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { CopySurveyForm } from "./copy-survey-form";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/modules/survey/list/actions", () => ({
|
||||
copySurveyToOtherEnvironmentAction: vi.fn().mockResolvedValue({}),
|
||||
copySurveyToOtherEnvironmentAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
@@ -19,21 +20,40 @@ vi.mock("react-hot-toast", () => ({
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
t: (key: string, params?: any) => {
|
||||
if (key === "environments.surveys.copy_survey_partially_success") {
|
||||
return `Partially successful: ${params?.success} success, ${params?.error} error`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the Checkbox component to properly handle form changes
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn((result) => {
|
||||
if (result?.serverError) return result.serverError;
|
||||
if (result?.validationErrors) return "Validation error";
|
||||
return "Unknown error";
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the form components to make them testable
|
||||
vi.mock("@/modules/ui/components/form", () => ({
|
||||
FormProvider: ({ children }: any) => <div data-testid="form-provider">{children}</div>,
|
||||
FormField: ({ children, render }: any) => (
|
||||
<div data-testid="form-field">{render({ field: { value: [], onChange: vi.fn() } })}</div>
|
||||
),
|
||||
FormItem: ({ children }: any) => <div data-testid="form-item">{children}</div>,
|
||||
FormControl: ({ children }: any) => <div data-testid="form-control">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/checkbox", () => ({
|
||||
Checkbox: ({ id, onCheckedChange, ...props }: any) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
data-testid={id}
|
||||
name={props.name}
|
||||
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
|
||||
onChange={(e) => {
|
||||
// Call onCheckedChange with the checked state
|
||||
onCheckedChange && onCheckedChange(e.target.checked);
|
||||
}}
|
||||
{...props}
|
||||
@@ -54,10 +74,47 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/label", () => ({
|
||||
Label: ({ children, htmlFor }: any) => <label htmlFor={htmlFor}>{children}</label>,
|
||||
}));
|
||||
|
||||
// Create a mock submit handler
|
||||
let mockSubmitHandler: any = null;
|
||||
|
||||
// Mock react-hook-form
|
||||
vi.mock("react-hook-form", () => ({
|
||||
useForm: () => ({
|
||||
control: {},
|
||||
handleSubmit: (fn: any) => {
|
||||
mockSubmitHandler = fn;
|
||||
return (e: any) => {
|
||||
e.preventDefault();
|
||||
// Simulate form data with selected environments
|
||||
const mockFormData = {
|
||||
projects: [
|
||||
{
|
||||
project: "project-1",
|
||||
environments: ["env-2"], // Only env-2 selected
|
||||
},
|
||||
{
|
||||
project: "project-2",
|
||||
environments: ["env-3"], // Only env-3 selected
|
||||
},
|
||||
],
|
||||
};
|
||||
return fn(mockFormData);
|
||||
};
|
||||
},
|
||||
}),
|
||||
useFieldArray: () => ({
|
||||
fields: [{ project: "project-1" }, { project: "project-2" }],
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockSurvey = {
|
||||
id: "survey-1",
|
||||
name: "mockSurvey",
|
||||
name: "Test Survey",
|
||||
type: "link",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -90,11 +147,16 @@ const mockProjects = [
|
||||
describe("CopySurveyForm", () => {
|
||||
const mockSetOpen = vi.fn();
|
||||
const mockOnCancel = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const mockOnSurveysCopied = vi.fn();
|
||||
|
||||
// Get references to the mocked functions
|
||||
const mockCopySurveyAction = vi.mocked(copySurveyToOtherEnvironmentAction);
|
||||
const mockToastSuccess = vi.mocked(toast.success);
|
||||
const mockToastError = vi.mocked(toast.error);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(copySurveyToOtherEnvironmentAction).mockResolvedValue({ data: { id: "new-survey-id" } });
|
||||
mockSubmitHandler = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -111,22 +173,14 @@ describe("CopySurveyForm", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// Check if project names are rendered
|
||||
expect(screen.getByText("Project 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Project 2")).toBeInTheDocument();
|
||||
|
||||
// Check if environment types are rendered
|
||||
expect(screen.getAllByText("development").length).toBe(2);
|
||||
expect(screen.getAllByText("development").length).toBe(1);
|
||||
expect(screen.getAllByText("production").length).toBe(2);
|
||||
|
||||
// Check if checkboxes are rendered for each environment
|
||||
expect(screen.getByTestId("env-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("env-2")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("env-3")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("env-4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls onCancel when cancel button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<CopySurveyForm
|
||||
defaultProjects={mockProjects}
|
||||
@@ -142,45 +196,252 @@ describe("CopySurveyForm", () => {
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("toggles environment selection when checkbox is clicked", async () => {
|
||||
render(
|
||||
<CopySurveyForm
|
||||
defaultProjects={mockProjects}
|
||||
survey={mockSurvey}
|
||||
onCancel={mockOnCancel}
|
||||
setOpen={mockSetOpen}
|
||||
/>
|
||||
);
|
||||
describe("onSubmit function", () => {
|
||||
test("should handle successful operations", async () => {
|
||||
mockCopySurveyAction.mockResolvedValue({
|
||||
data: { id: "new-survey-1", environmentId: "env-2" },
|
||||
});
|
||||
|
||||
// Select multiple environments
|
||||
await user.click(screen.getByTestId("env-2"));
|
||||
await user.click(screen.getByTestId("env-3"));
|
||||
render(
|
||||
<CopySurveyForm
|
||||
defaultProjects={mockProjects}
|
||||
survey={mockSurvey}
|
||||
onCancel={mockOnCancel}
|
||||
setOpen={mockSetOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
// Submit the form
|
||||
await user.click(screen.getByTestId("button-submit"));
|
||||
// Call the submit handler directly
|
||||
const mockFormData = {
|
||||
projects: [
|
||||
{
|
||||
project: "project-1",
|
||||
environments: ["env-2"],
|
||||
},
|
||||
{
|
||||
project: "project-2",
|
||||
environments: ["env-3"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Just verify the form can be submitted (integration testing is complex with mocked components)
|
||||
expect(screen.getByTestId("button-submit")).toBeInTheDocument();
|
||||
});
|
||||
await mockSubmitHandler(mockFormData);
|
||||
|
||||
test("submits form with selected environments", async () => {
|
||||
render(
|
||||
<CopySurveyForm
|
||||
defaultProjects={mockProjects}
|
||||
survey={mockSurvey}
|
||||
onCancel={mockOnCancel}
|
||||
setOpen={mockSetOpen}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(mockCopySurveyAction).toHaveBeenCalledTimes(2);
|
||||
expect(mockCopySurveyAction).toHaveBeenCalledWith({
|
||||
environmentId: "env-1",
|
||||
surveyId: "survey-1",
|
||||
targetEnvironmentId: "env-2",
|
||||
});
|
||||
expect(mockCopySurveyAction).toHaveBeenCalledWith({
|
||||
environmentId: "env-1",
|
||||
surveyId: "survey-1",
|
||||
targetEnvironmentId: "env-3",
|
||||
});
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith("environments.surveys.copy_survey_success");
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Select environments
|
||||
await user.click(screen.getByTestId("env-2"));
|
||||
await user.click(screen.getByTestId("env-4"));
|
||||
test("should handle partial success with mixed results", async () => {
|
||||
mockCopySurveyAction
|
||||
.mockResolvedValueOnce({ data: { id: "new-survey-1", environmentId: "env-2" } })
|
||||
.mockResolvedValueOnce({ serverError: "Failed to copy" });
|
||||
|
||||
// Submit the form
|
||||
await user.click(screen.getByTestId("button-submit"));
|
||||
render(
|
||||
<CopySurveyForm
|
||||
defaultProjects={mockProjects}
|
||||
survey={mockSurvey}
|
||||
onCancel={mockOnCancel}
|
||||
setOpen={mockSetOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
// Just verify basic form functionality (complex integration testing with mocked components is challenging)
|
||||
expect(screen.getByTestId("button-submit")).toBeInTheDocument();
|
||||
const mockFormData = {
|
||||
projects: [
|
||||
{
|
||||
project: "project-1",
|
||||
environments: ["env-2"],
|
||||
},
|
||||
{
|
||||
project: "project-2",
|
||||
environments: ["env-3"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await mockSubmitHandler(mockFormData);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
"Partially successful: 1 success, 1 error",
|
||||
expect.objectContaining({
|
||||
icon: expect.anything(),
|
||||
})
|
||||
);
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
"[Project 2] - [development] - Failed to copy",
|
||||
expect.objectContaining({
|
||||
duration: 2000,
|
||||
})
|
||||
);
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle all failed operations", async () => {
|
||||
mockCopySurveyAction
|
||||
.mockResolvedValueOnce({ serverError: "Server error 1" })
|
||||
.mockResolvedValueOnce({ validationErrors: { surveyId: { _errors: ["Invalid survey ID"] } } });
|
||||
|
||||
render(
|
||||
<CopySurveyForm
|
||||
defaultProjects={mockProjects}
|
||||
survey={mockSurvey}
|
||||
onCancel={mockOnCancel}
|
||||
setOpen={mockSetOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
const mockFormData = {
|
||||
projects: [
|
||||
{
|
||||
project: "project-1",
|
||||
environments: ["env-2"],
|
||||
},
|
||||
{
|
||||
project: "project-2",
|
||||
environments: ["env-3"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await mockSubmitHandler(mockFormData);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
"[Project 1] - [production] - Server error 1",
|
||||
expect.objectContaining({
|
||||
duration: 2000,
|
||||
})
|
||||
);
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
"[Project 2] - [development] - Validation error",
|
||||
expect.objectContaining({
|
||||
duration: 4000,
|
||||
})
|
||||
);
|
||||
expect(mockToastSuccess).not.toHaveBeenCalled();
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle exceptions during form submission", async () => {
|
||||
mockCopySurveyAction.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
render(
|
||||
<CopySurveyForm
|
||||
defaultProjects={mockProjects}
|
||||
survey={mockSurvey}
|
||||
onCancel={mockOnCancel}
|
||||
setOpen={mockSetOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
const mockFormData = {
|
||||
projects: [
|
||||
{
|
||||
project: "project-1",
|
||||
environments: ["env-2"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await mockSubmitHandler(mockFormData);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith("environments.surveys.copy_survey_error");
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle staggered error toast durations", async () => {
|
||||
mockCopySurveyAction
|
||||
.mockResolvedValueOnce({ serverError: "Error 1" })
|
||||
.mockResolvedValueOnce({ serverError: "Error 2" })
|
||||
.mockResolvedValueOnce({ serverError: "Error 3" });
|
||||
|
||||
render(
|
||||
<CopySurveyForm
|
||||
defaultProjects={mockProjects}
|
||||
survey={mockSurvey}
|
||||
onCancel={mockOnCancel}
|
||||
setOpen={mockSetOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
const mockFormData = {
|
||||
projects: [
|
||||
{
|
||||
project: "project-1",
|
||||
environments: ["env-2"],
|
||||
},
|
||||
{
|
||||
project: "project-2",
|
||||
environments: ["env-3", "env-4"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await mockSubmitHandler(mockFormData);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
"[Project 1] - [production] - Error 1",
|
||||
expect.objectContaining({ duration: 2000 })
|
||||
);
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
"[Project 2] - [development] - Error 2",
|
||||
expect.objectContaining({ duration: 4000 })
|
||||
);
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
"[Project 2] - [production] - Error 3",
|
||||
expect.objectContaining({ duration: 6000 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should not call onSurveysCopied when it's not provided", async () => {
|
||||
mockCopySurveyAction.mockResolvedValue({
|
||||
data: { id: "new-survey-1", environmentId: "env-1" },
|
||||
});
|
||||
|
||||
render(
|
||||
<CopySurveyForm
|
||||
defaultProjects={mockProjects}
|
||||
survey={mockSurvey}
|
||||
onCancel={mockOnCancel}
|
||||
setOpen={mockSetOpen}
|
||||
// onSurveysCopied not provided
|
||||
/>
|
||||
);
|
||||
|
||||
const mockFormData = {
|
||||
projects: [
|
||||
{
|
||||
project: "project-1",
|
||||
environments: ["env-2"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await mockSubmitHandler(mockFormData);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
// Should not throw an error even when onSurveysCopied is not provided
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,22 +10,100 @@ import { FormControl, FormField, FormItem, FormProvider } from "@/modules/ui/com
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface ICopySurveyFormProps {
|
||||
defaultProjects: TUserProject[];
|
||||
survey: TSurvey;
|
||||
onCancel: () => void;
|
||||
setOpen: (value: boolean) => void;
|
||||
interface CopySurveyFormProps {
|
||||
readonly defaultProjects: TUserProject[];
|
||||
readonly survey: TSurvey;
|
||||
readonly onCancel: () => void;
|
||||
readonly setOpen: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: ICopySurveyFormProps) => {
|
||||
interface EnvironmentCheckboxProps {
|
||||
readonly environmentId: string;
|
||||
readonly environmentType: string;
|
||||
readonly fieldValue: string[];
|
||||
readonly onChange: (value: string[]) => void;
|
||||
}
|
||||
|
||||
function EnvironmentCheckbox({
|
||||
environmentId,
|
||||
environmentType,
|
||||
fieldValue,
|
||||
onChange,
|
||||
}: EnvironmentCheckboxProps) {
|
||||
const handleCheckedChange = () => {
|
||||
if (fieldValue.includes(environmentId)) {
|
||||
onChange(fieldValue.filter((id) => id !== environmentId));
|
||||
} else {
|
||||
onChange([...fieldValue, environmentId]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<div className="flex items-center">
|
||||
<FormControl>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
type="button"
|
||||
checked={fieldValue.includes(environmentId)}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
|
||||
id={environmentId}
|
||||
/>
|
||||
<Label htmlFor={environmentId}>
|
||||
<p className="text-sm font-medium capitalize text-slate-900">{environmentType}</p>
|
||||
</Label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</div>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
interface EnvironmentCheckboxGroupProps {
|
||||
readonly project: TUserProject;
|
||||
readonly form: ReturnType<typeof useForm<TSurveyCopyFormData>>;
|
||||
readonly projectIndex: number;
|
||||
}
|
||||
|
||||
function EnvironmentCheckboxGroup({ project, form, projectIndex }: EnvironmentCheckboxGroupProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{project.environments.map((environment) => (
|
||||
<FormField
|
||||
key={environment.id}
|
||||
control={form.control}
|
||||
name={`projects.${projectIndex}.environments`}
|
||||
render={({ field }) => (
|
||||
<EnvironmentCheckbox
|
||||
environmentId={environment.id}
|
||||
environmentType={environment.type}
|
||||
fieldValue={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: CopySurveyFormProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const filteredProjects = defaultProjects.map((project) => ({
|
||||
...project,
|
||||
environments: project.environments.filter((env) => env.id !== survey.environmentId),
|
||||
}));
|
||||
|
||||
const form = useForm<TSurveyCopyFormData>({
|
||||
resolver: zodResolver(ZSurveyCopyFormValidation),
|
||||
defaultValues: {
|
||||
projects: defaultProjects.map((project) => ({
|
||||
projects: filteredProjects.map((project) => ({
|
||||
project: project.id,
|
||||
environments: [],
|
||||
})),
|
||||
@@ -37,32 +115,79 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
|
||||
control: form.control,
|
||||
});
|
||||
|
||||
const onSubmit = async (data: TSurveyCopyFormData) => {
|
||||
async function onSubmit(data: TSurveyCopyFormData) {
|
||||
const filteredData = data.projects.filter((project) => project.environments.length > 0);
|
||||
|
||||
try {
|
||||
filteredData.forEach(async (project) => {
|
||||
project.environments.forEach(async (environment) => {
|
||||
const result = await copySurveyToOtherEnvironmentAction({
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
targetEnvironmentId: environment,
|
||||
});
|
||||
const copyOperationsWithMetadata = filteredData.flatMap((projectData) => {
|
||||
const project = filteredProjects.find((p) => p.id === projectData.project);
|
||||
return projectData.environments.map((environmentId) => {
|
||||
const environment =
|
||||
project?.environments[0]?.id === environmentId
|
||||
? project?.environments[0]
|
||||
: project?.environments[1];
|
||||
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.surveys.copy_survey_success"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
return {
|
||||
operation: copySurveyToOtherEnvironmentAction({
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
targetEnvironmentId: environmentId,
|
||||
}),
|
||||
projectName: project?.name ?? "Unknown Project",
|
||||
environmentType: environment?.type ?? "unknown",
|
||||
environmentId,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const results = await Promise.all(copyOperationsWithMetadata.map((item) => item.operation));
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
const errorsIndexes: number[] = [];
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result?.data) {
|
||||
successCount++;
|
||||
} else {
|
||||
errorsIndexes.push(index);
|
||||
errorCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (successCount > 0) {
|
||||
if (errorCount === 0) {
|
||||
toast.success(t("environments.surveys.copy_survey_success"));
|
||||
} else {
|
||||
toast.error(
|
||||
t("environments.surveys.copy_survey_partially_success", {
|
||||
success: successCount,
|
||||
error: errorCount,
|
||||
}),
|
||||
{
|
||||
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errorsIndexes.length > 0) {
|
||||
errorsIndexes.forEach((index, idx) => {
|
||||
const { projectName, environmentType } = copyOperationsWithMetadata[index];
|
||||
const result = results[index];
|
||||
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(`[${projectName}] - [${environmentType}] - ${errorMessage}`, {
|
||||
duration: 2000 + 2000 * idx,
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.copy_survey_error"));
|
||||
} finally {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
@@ -71,58 +196,16 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
|
||||
className="relative flex h-full w-full flex-col gap-8 overflow-y-auto bg-white p-4">
|
||||
<div className="space-y-8 pb-12">
|
||||
{formFields.fields.map((field, projectIndex) => {
|
||||
const project = defaultProjects.find((project) => project.id === field.project);
|
||||
const project = filteredProjects.find((project) => project.id === field.project);
|
||||
if (!project) return null;
|
||||
|
||||
return (
|
||||
<div key={project?.id}>
|
||||
<div key={project.id}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="w-fit">
|
||||
<p className="text-base font-semibold text-slate-900">{project?.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{project?.environments.map((environment) => {
|
||||
return (
|
||||
<FormField
|
||||
key={environment.id}
|
||||
control={form.control}
|
||||
name={`projects.${projectIndex}.environments`}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<div className="flex items-center">
|
||||
<FormControl>
|
||||
<>
|
||||
<Checkbox
|
||||
{...field}
|
||||
type="button"
|
||||
onCheckedChange={() => {
|
||||
if (field.value.includes(environment.id)) {
|
||||
field.onChange(
|
||||
field.value.filter((id: string) => id !== environment.id)
|
||||
);
|
||||
} else {
|
||||
field.onChange([...field.value, environment.id]);
|
||||
}
|
||||
}}
|
||||
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
|
||||
id={environment.id}
|
||||
/>
|
||||
<Label htmlFor={environment.id}>
|
||||
<p className="text-sm font-medium capitalize text-slate-900">
|
||||
{environment.type}
|
||||
</p>
|
||||
</Label>
|
||||
</>
|
||||
</FormControl>
|
||||
</div>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<p className="text-base font-semibold text-slate-900">{project.name}</p>
|
||||
</div>
|
||||
<EnvironmentCheckboxGroup project={project} form={form} projectIndex={projectIndex} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -133,7 +216,6 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
|
||||
<Button type="button" onClick={onCancel} variant="ghost">
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
|
||||
<Button type="submit">{t("environments.surveys.copy_survey")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,9 +17,9 @@ interface SurveyCardProps {
|
||||
environmentId: string;
|
||||
isReadOnly: boolean;
|
||||
publicDomain: string;
|
||||
duplicateSurvey: (survey: TSurvey) => void;
|
||||
deleteSurvey: (surveyId: string) => void;
|
||||
locale: TUserLocale;
|
||||
onSurveysCopied?: () => void;
|
||||
}
|
||||
export const SurveyCard = ({
|
||||
survey,
|
||||
@@ -27,8 +27,8 @@ export const SurveyCard = ({
|
||||
isReadOnly,
|
||||
publicDomain,
|
||||
deleteSurvey,
|
||||
duplicateSurvey,
|
||||
locale,
|
||||
onSurveysCopied,
|
||||
}: SurveyCardProps) => {
|
||||
const { t } = useTranslate();
|
||||
const surveyStatusLabel = (() => {
|
||||
@@ -106,8 +106,8 @@ export const SurveyCard = ({
|
||||
disabled={isDraftAndReadOnly}
|
||||
refreshSingleUseId={refreshSingleUseId}
|
||||
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
|
||||
duplicateSurvey={duplicateSurvey}
|
||||
deleteSurvey={deleteSurvey}
|
||||
onSurveysCopied={onSurveysCopied}
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -86,7 +86,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={{ ...fakeSurvey, status: "completed" }}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={mockRefresh}
|
||||
duplicateSurvey={mockDuplicateSurvey}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
@@ -118,7 +117,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={vi.fn()}
|
||||
disabled={false}
|
||||
isSurveyCreationDeletionDisabled={false}
|
||||
@@ -158,7 +156,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={vi.fn()}
|
||||
/>
|
||||
);
|
||||
@@ -181,7 +178,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={{ ...fakeSurvey, responseCount: 0 }}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={vi.fn()}
|
||||
/>
|
||||
);
|
||||
@@ -200,14 +196,12 @@ describe("SurveyDropDownMenu", () => {
|
||||
});
|
||||
|
||||
test("<DropdownMenuItem> renders and triggers actions correctly", async () => {
|
||||
const mockDuplicateSurvey = vi.fn();
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
environmentId="env123"
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={mockDuplicateSurvey}
|
||||
deleteSurvey={vi.fn()}
|
||||
/>
|
||||
);
|
||||
@@ -220,21 +214,15 @@ describe("SurveyDropDownMenu", () => {
|
||||
const duplicateButton = screen.getByText("common.duplicate");
|
||||
expect(duplicateButton).toBeInTheDocument();
|
||||
await userEvent.click(duplicateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDuplicateSurvey).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test("<EditPublicSurveyAlertDialog> displays and handles actions correctly", async () => {
|
||||
const mockDuplicateSurvey = vi.fn();
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
environmentId="env123"
|
||||
survey={{ ...fakeSurvey, responseCount: 5 }}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={mockDuplicateSurvey}
|
||||
deleteSurvey={vi.fn()}
|
||||
/>
|
||||
);
|
||||
@@ -260,10 +248,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
const duplicateButton = screen.getByRole("button", { name: "common.duplicate" });
|
||||
expect(duplicateButton).toBeInTheDocument();
|
||||
await userEvent.click(duplicateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDuplicateSurvey).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleDeleteSurvey", () => {
|
||||
@@ -281,7 +265,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
@@ -317,7 +300,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
@@ -354,7 +336,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
@@ -391,7 +372,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
@@ -429,7 +409,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
@@ -484,7 +463,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -41,8 +41,8 @@ interface SurveyDropDownMenuProps {
|
||||
refreshSingleUseId: () => Promise<string | undefined>;
|
||||
disabled?: boolean;
|
||||
isSurveyCreationDeletionDisabled?: boolean;
|
||||
duplicateSurvey: (survey: TSurvey) => void;
|
||||
deleteSurvey: (surveyId: string) => void;
|
||||
onSurveysCopied?: () => void;
|
||||
}
|
||||
|
||||
export const SurveyDropDownMenu = ({
|
||||
@@ -53,7 +53,7 @@ export const SurveyDropDownMenu = ({
|
||||
disabled,
|
||||
isSurveyCreationDeletionDisabled,
|
||||
deleteSurvey,
|
||||
duplicateSurvey,
|
||||
onSurveysCopied,
|
||||
}: SurveyDropDownMenuProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
@@ -102,13 +102,14 @@ export const SurveyDropDownMenu = ({
|
||||
surveyId,
|
||||
targetEnvironmentId: environmentId,
|
||||
});
|
||||
router.refresh();
|
||||
|
||||
if (duplicatedSurveyResponse?.data) {
|
||||
const transformedDuplicatedSurvey = await getSurveyAction({
|
||||
surveyId: duplicatedSurveyResponse.data.id,
|
||||
});
|
||||
if (transformedDuplicatedSurvey?.data) duplicateSurvey(transformedDuplicatedSurvey.data);
|
||||
if (transformedDuplicatedSurvey?.data) {
|
||||
onSurveysCopied?.();
|
||||
}
|
||||
toast.success(t("environments.surveys.survey_duplicated_successfully"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(duplicatedSurveyResponse);
|
||||
|
||||
@@ -341,27 +341,6 @@ describe("SurveysList", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("handleDuplicateSurvey adds the duplicated survey to the beginning of the list", async () => {
|
||||
const initialSurvey = { ...surveyMock, id: "s1", name: "Original Survey" };
|
||||
vi.mocked(getSurveysAction).mockResolvedValueOnce({ data: [initialSurvey] });
|
||||
const user = userEvent.setup();
|
||||
render(<SurveysList {...defaultProps} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Original Survey")).toBeInTheDocument());
|
||||
|
||||
const duplicateButtonS1 = screen.getByTestId("duplicate-s1");
|
||||
// The mock SurveyCard calls duplicateSurvey(survey) with the original survey object.
|
||||
await user.click(duplicateButtonS1);
|
||||
|
||||
await waitFor(() => {
|
||||
const surveyCards = screen.getAllByTestId(/survey-card-/);
|
||||
expect(surveyCards).toHaveLength(2);
|
||||
// Both cards will show "Original Survey" as the object is prepended.
|
||||
expect(surveyCards[0]).toHaveTextContent("Original Survey");
|
||||
expect(surveyCards[1]).toHaveTextContent("Original Survey");
|
||||
});
|
||||
});
|
||||
|
||||
test("applies useAutoAnimate ref to the survey list container", async () => {
|
||||
const surveysData = [{ ...surveyMock, id: "s1" }];
|
||||
vi.mocked(getSurveysAction).mockResolvedValueOnce({ data: surveysData });
|
||||
|
||||
@@ -46,6 +46,7 @@ export const SurveysList = ({
|
||||
const [surveys, setSurveys] = useState<TSurvey[]>([]);
|
||||
const [isFetching, setIsFetching] = useState(true);
|
||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(false);
|
||||
const { t } = useTranslate();
|
||||
const [surveyFilters, setSurveyFilters] = useState<TSurveyFilters>(initialFilters);
|
||||
const [isFilterInitialized, setIsFilterInitialized] = useState(false);
|
||||
@@ -98,7 +99,7 @@ export const SurveysList = ({
|
||||
};
|
||||
fetchInitialSurveys();
|
||||
}
|
||||
}, [environmentId, surveysLimit, filters, isFilterInitialized]);
|
||||
}, [environmentId, surveysLimit, filters, isFilterInitialized, refreshTrigger]);
|
||||
|
||||
const fetchNextPage = useCallback(async () => {
|
||||
setIsFetching(true);
|
||||
@@ -126,10 +127,9 @@ export const SurveysList = ({
|
||||
if (newSurveys.length === 0) setIsFetching(true);
|
||||
};
|
||||
|
||||
const handleDuplicateSurvey = async (survey: TSurvey) => {
|
||||
const newSurveys = [survey, ...surveys];
|
||||
setSurveys(newSurveys);
|
||||
};
|
||||
const triggerRefresh = useCallback(() => {
|
||||
setRefreshTrigger((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -158,9 +158,9 @@ export const SurveysList = ({
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
publicDomain={publicDomain}
|
||||
duplicateSurvey={handleDuplicateSurvey}
|
||||
deleteSurvey={handleDeleteSurvey}
|
||||
locale={locale}
|
||||
onSurveysCopied={triggerRefresh}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -58,8 +58,6 @@ export const TagsCombobox = ({
|
||||
}
|
||||
}, [open, setSearchValue]);
|
||||
|
||||
const trimmedSearchValue = useMemo(() => searchValue.trim(), [searchValue]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -92,13 +90,13 @@ export const TagsCombobox = ({
|
||||
value={searchValue}
|
||||
onValueChange={(search) => setSearchValue(search)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && trimmedSearchValue !== "") {
|
||||
const alreadyExists =
|
||||
currentTags.some((tag) => tag.label === trimmedSearchValue) ||
|
||||
tagsToSearch.some((tag) => tag.label === trimmedSearchValue);
|
||||
|
||||
if (!alreadyExists) {
|
||||
createTag?.(trimmedSearchValue);
|
||||
if (e.key === "Enter" && searchValue !== "") {
|
||||
if (
|
||||
!tagsToSearch?.find((tag) =>
|
||||
tag?.label?.toLowerCase().includes(searchValue?.toLowerCase())
|
||||
)
|
||||
) {
|
||||
createTag?.(searchValue);
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -120,16 +118,15 @@ export const TagsCombobox = ({
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
{trimmedSearchValue !== "" &&
|
||||
!currentTags.find((tag) => tag.label === trimmedSearchValue) &&
|
||||
!tagsToSearch.find((tag) => tag.label === trimmedSearchValue) && (
|
||||
{searchValue !== "" &&
|
||||
!currentTags.find((tag) => tag.label === searchValue) &&
|
||||
!tagsToSearch.find((tag) => tag.label === searchValue) && (
|
||||
<CommandItem value="_create">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createTag?.(trimmedSearchValue)}
|
||||
onClick={() => createTag?.(searchValue)}
|
||||
className="h-8 w-full text-left hover:cursor-pointer hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!!currentTags.find((tag) => tag.label === trimmedSearchValue)}>
|
||||
+ {t("environments.project.tags.add")} {trimmedSearchValue}
|
||||
disabled={!!currentTags.find((tag) => tag.label === searchValue)}>
|
||||
+ {t("environments.project.tags.add")} {searchValue}
|
||||
</button>
|
||||
</CommandItem>
|
||||
)}
|
||||
|
||||
@@ -297,7 +297,7 @@ export const ZResponseInput = z.object({
|
||||
environmentId: z.string().cuid2(),
|
||||
surveyId: z.string().cuid2(),
|
||||
userId: z.string().nullish(),
|
||||
displayId: z.string().nullish(),
|
||||
displayId: z.string().cuid2().nullish(),
|
||||
singleUseId: z.string().nullable().optional(),
|
||||
finished: z.boolean(),
|
||||
endingId: z.string().nullish(),
|
||||
|
||||
Reference in New Issue
Block a user