Compare commits

..

6 Commits

Author SHA1 Message Date
pandeymangg
249e42b087 Merge branch 'main' into hmaan-displayId 2025-07-04 14:19:05 +05:30
Piyush Gupta
ba9b01a969 fix: survey list refresh (#6104)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-04 08:16:27 +00:00
pandeymangg
9ec4f22b1f Merge remote-tracking branch 'origin/main' into hmaan-displayId 2025-07-03 13:26:52 +05:30
Harsh-deepsingh
ea00dec8e5 fix: rename function as requested 2025-07-02 01:21:34 -06:00
Harsh-deepsingh
70cd751b0e displayId validation as cuid2 if present 2025-06-29 17:50:20 -06:00
Harsh-deepsingh
4308d86bbc fix: add validation for displayId in client response APIs 2025-06-29 17:03:36 -06:00
29 changed files with 661 additions and 557 deletions

View File

@@ -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"] = {

View File

@@ -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"] = {

View File

@@ -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;
}
});

View File

@@ -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 },
});
});
});
});

View File

@@ -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;
}
};

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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;
}
)

View File

@@ -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

View File

@@ -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,

View File

@@ -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");
});
});
});

View File

@@ -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;
}
};

View File

@@ -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;
}
)

View File

@@ -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>

View File

@@ -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",
}

View File

@@ -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

View File

@@ -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
});
});
});
});

View File

@@ -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>

View File

@@ -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>
</>

View File

@@ -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}
/>
);

View File

@@ -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);

View File

@@ -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 });

View File

@@ -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}
/>
);
})}

View File

@@ -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>
)}

View File

@@ -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(),