mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-02 03:15:05 -05:00
fix: Show Specific Error for Duplicate Tag Names (#6057)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com> Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
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";
|
||||
|
||||
@@ -57,25 +61,88 @@ 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(baseTag);
|
||||
expect(result).toEqual(ok(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"));
|
||||
await expect(deleteTag(baseTag.id)).rejects.toThrow("fail");
|
||||
const result = await deleteTag(baseTag.id);
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error).toStrictEqual({
|
||||
code: "unexpected_error",
|
||||
message: "fail",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTagName", () => {
|
||||
test("updates tag name and revalidates cache", async () => {
|
||||
test("returns ok on successful update", async () => {
|
||||
vi.mocked(prisma.tag.update).mockResolvedValueOnce(baseTag);
|
||||
|
||||
const result = await updateTagName(baseTag.id, "Tag1");
|
||||
expect(result).toEqual(baseTag);
|
||||
expect(prisma.tag.update).toHaveBeenCalledWith({ where: { id: baseTag.id }, data: { name: "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" },
|
||||
});
|
||||
});
|
||||
test("throws error on prisma error", async () => {
|
||||
|
||||
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 () => {
|
||||
vi.mocked(prisma.tag.update).mockRejectedValueOnce(new Error("fail"));
|
||||
await expect(updateTagName(baseTag.id, "Tag1")).rejects.toThrow("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",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,7 +154,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(newTag);
|
||||
expect(result).toEqual(ok(newTag));
|
||||
expect(prisma.tag.findUnique).toHaveBeenCalledWith({ where: { id: baseTag.id } });
|
||||
expect(prisma.tag.findUnique).toHaveBeenCalledWith({ where: { id: newTag.id } });
|
||||
expect(prisma.response.findMany).toHaveBeenCalled();
|
||||
@@ -100,21 +167,45 @@ 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(newTag);
|
||||
expect(result).toEqual(ok(newTag));
|
||||
});
|
||||
test("throws if original tag not found", async () => {
|
||||
vi.mocked(prisma.tag.findUnique).mockResolvedValueOnce(null);
|
||||
await expect(mergeTags(baseTag.id, newTag.id)).rejects.toThrow("Tag not found");
|
||||
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",
|
||||
});
|
||||
}
|
||||
});
|
||||
test("throws if new tag not found", async () => {
|
||||
vi.mocked(prisma.tag.findUnique)
|
||||
.mockResolvedValueOnce(baseTag as any)
|
||||
.mockResolvedValueOnce(null);
|
||||
await expect(mergeTags(baseTag.id, newTag.id)).rejects.toThrow("Tag not found");
|
||||
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",
|
||||
});
|
||||
}
|
||||
});
|
||||
test("throws on prisma error", async () => {
|
||||
vi.mocked(prisma.tag.findUnique).mockRejectedValueOnce(new Error("fail"));
|
||||
await expect(mergeTags(baseTag.id, newTag.id)).rejects.toThrow("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",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
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 { ZId, ZString } from "@formbricks/types/common";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
|
||||
export const deleteTag = async (id: string): Promise<TTag> => {
|
||||
export const deleteTag = async (
|
||||
id: string
|
||||
): Promise<Result<TTag, { code: TagError; message: string; meta?: Record<string, string> }>> => {
|
||||
validateInputs([id, ZId]);
|
||||
|
||||
try {
|
||||
@@ -14,32 +20,56 @@ export const deleteTag = async (id: string): Promise<TTag> => {
|
||||
},
|
||||
});
|
||||
|
||||
return tag;
|
||||
return ok(tag);
|
||||
} catch (error) {
|
||||
throw 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const updateTagName = async (id: string, name: string): Promise<TTag> => {
|
||||
validateInputs([id, ZId], [name, ZString]);
|
||||
|
||||
export const updateTagName = async (
|
||||
id: string,
|
||||
name: string
|
||||
): Promise<Result<TTag, { code: TagError; message: string; meta?: Record<string, string> }>> => {
|
||||
try {
|
||||
const tag = await prisma.tag.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
where: { id },
|
||||
data: { name },
|
||||
});
|
||||
|
||||
return tag;
|
||||
return ok(tag);
|
||||
} catch (error) {
|
||||
throw 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const mergeTags = async (originalTagId: string, newTagId: string): Promise<TTag | undefined> => {
|
||||
export const mergeTags = async (
|
||||
originalTagId: string,
|
||||
newTagId: string
|
||||
): Promise<Result<TTag, { code: TagError; message: string; meta?: Record<string, string> }>> => {
|
||||
validateInputs([originalTagId, ZId], [newTagId, ZId]);
|
||||
|
||||
try {
|
||||
@@ -52,7 +82,10 @@ export const mergeTags = async (originalTagId: string, newTagId: string): Promis
|
||||
});
|
||||
|
||||
if (!originalTag) {
|
||||
throw new Error("Tag not found");
|
||||
return err({
|
||||
code: TagError.TAG_NOT_FOUND,
|
||||
message: "Tag not found",
|
||||
});
|
||||
}
|
||||
|
||||
let newTag: TTag | null;
|
||||
@@ -64,7 +97,10 @@ export const mergeTags = async (originalTagId: string, newTagId: string): Promis
|
||||
});
|
||||
|
||||
if (!newTag) {
|
||||
throw new Error("Tag not found");
|
||||
return err({
|
||||
code: TagError.TAG_NOT_FOUND,
|
||||
message: "Tag not found",
|
||||
});
|
||||
}
|
||||
|
||||
// finds all the responses that have both the tags
|
||||
@@ -133,7 +169,7 @@ export const mergeTags = async (originalTagId: string, newTagId: string): Promis
|
||||
}),
|
||||
]);
|
||||
|
||||
return newTag;
|
||||
return ok(newTag);
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
@@ -153,8 +189,11 @@ export const mergeTags = async (originalTagId: string, newTagId: string): Promis
|
||||
}),
|
||||
]);
|
||||
|
||||
return newTag;
|
||||
return ok(newTag);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
return err({
|
||||
code: TagError.UNEXPECTED_ERROR,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -46,7 +46,11 @@ export const deleteTagAction = authenticatedActionClient.schema(ZDeleteTagAction
|
||||
ctx.auditLoggingCtx.tagId = parsedInput.tagId;
|
||||
|
||||
const result = await deleteTag(parsedInput.tagId);
|
||||
ctx.auditLoggingCtx.oldObject = result;
|
||||
if (result.ok) {
|
||||
ctx.auditLoggingCtx.oldObject = result.data;
|
||||
} else {
|
||||
ctx.auditLoggingCtx.oldObject = null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
)
|
||||
@@ -85,7 +89,11 @@ export const updateTagNameAction = authenticatedActionClient.schema(ZUpdateTagNa
|
||||
|
||||
const result = await updateTagName(parsedInput.tagId, parsedInput.name);
|
||||
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
if (result.ok) {
|
||||
ctx.auditLoggingCtx.newObject = result.data;
|
||||
} else {
|
||||
ctx.auditLoggingCtx.newObject = null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
)
|
||||
@@ -130,7 +138,11 @@ export const mergeTagsAction = authenticatedActionClient.schema(ZMergeTagsAction
|
||||
|
||||
const result = await mergeTags(parsedInput.originalTagId, parsedInput.newTagId);
|
||||
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
if (result.ok) {
|
||||
ctx.auditLoggingCtx.newObject = result.data;
|
||||
} else {
|
||||
ctx.auditLoggingCtx.newObject = null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ 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";
|
||||
@@ -47,15 +48,64 @@ export const SingleTag: React.FC<SingleTagProps> = ({
|
||||
const confirmDeleteTag = async () => {
|
||||
const deleteTagResponse = await deleteTagAction({ tagId });
|
||||
if (deleteTagResponse?.data) {
|
||||
toast.success(t("environments.project.tags.tag_deleted"));
|
||||
updateTagsCount();
|
||||
router.refresh();
|
||||
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);
|
||||
}
|
||||
} 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">
|
||||
@@ -70,31 +120,7 @@ export const SingleTag: React.FC<SingleTagProps> = ({
|
||||
: "border-slate-200 focus:border-slate-500"
|
||||
)}
|
||||
defaultValue={tagName}
|
||||
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);
|
||||
}
|
||||
});
|
||||
}}
|
||||
onBlur={handleUpdateTagName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,20 +143,7 @@ export const SingleTag: React.FC<SingleTagProps> = ({
|
||||
?.filter((tag) => tag.id !== tagId)
|
||||
?.map((tag) => ({ label: tag.name, value: tag.id })) ?? []
|
||||
}
|
||||
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);
|
||||
});
|
||||
}}
|
||||
onSelect={handleMergeTags}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum TagError {
|
||||
TAG_NOT_FOUND = "tag_not_found",
|
||||
TAG_NAME_ALREADY_EXISTS = "tag_name_already_exists",
|
||||
UNEXPECTED_ERROR = "unexpected_error",
|
||||
}
|
||||
Reference in New Issue
Block a user