mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-21 10:08:34 -06: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,8 @@
|
||||
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";
|
||||
|
||||
@@ -110,7 +113,7 @@ describe("Tag Service", () => {
|
||||
vi.mocked(prisma.tag.create).mockResolvedValue(mockTag);
|
||||
|
||||
const result = await createTag("env1", "New Tag");
|
||||
expect(result).toEqual(mockTag);
|
||||
expect(result).toEqual({ ok: true, data: mockTag });
|
||||
expect(prisma.tag.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "New Tag",
|
||||
@@ -118,5 +121,30 @@ 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,7 +1,11 @@
|
||||
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";
|
||||
@@ -42,7 +46,10 @@ export const getTag = reactCache(async (id: string): Promise<TTag | null> => {
|
||||
}
|
||||
});
|
||||
|
||||
export const createTag = async (environmentId: string, name: string): Promise<TTag> => {
|
||||
export const createTag = async (
|
||||
environmentId: string,
|
||||
name: string
|
||||
): Promise<Result<TTag, { code: TagError; message: string; meta?: Record<string, string> }>> => {
|
||||
validateInputs([environmentId, ZId], [name, ZString]);
|
||||
|
||||
try {
|
||||
@@ -53,8 +60,19 @@ export const createTag = async (environmentId: string, name: string): Promise<TT
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,8 +50,14 @@ export const createTagAction = authenticatedActionClient.schema(ZCreateTagAction
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
const result = await createTag(parsedInput.environmentId, parsedInput.tagName);
|
||||
ctx.auditLoggingCtx.tagId = result.id;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
|
||||
if (result.ok) {
|
||||
ctx.auditLoggingCtx.tagId = result.data.id;
|
||||
ctx.auditLoggingCtx.newObject = result.data;
|
||||
} else {
|
||||
ctx.auditLoggingCtx.newObject = null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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";
|
||||
@@ -150,7 +152,9 @@ describe("ResponseTagsWrapper", () => {
|
||||
});
|
||||
|
||||
test("creates a new tag via TagsCombobox and calls updateFetchedResponses on success", async () => {
|
||||
vi.mocked(createTagAction).mockResolvedValueOnce({ data: { id: "newTagId", name: "NewTag" } } as any);
|
||||
vi.mocked(createTagAction).mockResolvedValueOnce({
|
||||
data: { ok: true, data: { id: "newTagId", name: "NewTag" } },
|
||||
} as any);
|
||||
vi.mocked(createTagToResponseAction).mockResolvedValueOnce({ data: "tagAdded" } as any);
|
||||
render(
|
||||
<ResponseTagsWrapper
|
||||
@@ -176,7 +180,10 @@ describe("ResponseTagsWrapper", () => {
|
||||
|
||||
test("handles createTagAction failure and shows toast error", async () => {
|
||||
vi.mocked(createTagAction).mockResolvedValueOnce({
|
||||
error: { details: [{ issue: "Unique constraint failed on the fields" }] },
|
||||
data: {
|
||||
ok: false,
|
||||
error: { message: "Unique constraint failed on the fields", code: TagError.TAG_NAME_ALREADY_EXISTS },
|
||||
},
|
||||
} as any);
|
||||
render(
|
||||
<ResponseTagsWrapper
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"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";
|
||||
@@ -58,6 +59,57 @@ 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 && (
|
||||
@@ -93,46 +145,7 @@ 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={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("");
|
||||
}
|
||||
}}
|
||||
createTag={handleCreateTag}
|
||||
addTag={(tagId) => {
|
||||
setTagsState((prevTags) => [
|
||||
...prevTags,
|
||||
|
||||
@@ -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>
|
||||
|
||||
5
apps/web/modules/projects/settings/types/tag.ts
Normal file
5
apps/web/modules/projects/settings/types/tag.ts
Normal file
@@ -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",
|
||||
}
|
||||
@@ -144,7 +144,9 @@ const evaluateFollowUp = async (
|
||||
*/
|
||||
export const sendFollowUpsForResponse = async (
|
||||
responseId: string
|
||||
): Promise<Result<FollowUpResult[], { code: FollowUpSendError; message: string; meta?: any }>> => {
|
||||
): Promise<
|
||||
Result<FollowUpResult[], { code: FollowUpSendError; message: string; meta?: Record<string, string> }>
|
||||
> => {
|
||||
try {
|
||||
validateInputs([responseId, ZId]);
|
||||
// Get the response first to get the survey ID
|
||||
|
||||
@@ -58,6 +58,8 @@ export const TagsCombobox = ({
|
||||
}
|
||||
}, [open, setSearchValue]);
|
||||
|
||||
const trimmedSearchValue = useMemo(() => searchValue.trim(), [searchValue]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -90,13 +92,13 @@ export const TagsCombobox = ({
|
||||
value={searchValue}
|
||||
onValueChange={(search) => setSearchValue(search)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && searchValue !== "") {
|
||||
if (
|
||||
!tagsToSearch?.find((tag) =>
|
||||
tag?.label?.toLowerCase().includes(searchValue?.toLowerCase())
|
||||
)
|
||||
) {
|
||||
createTag?.(searchValue);
|
||||
if (e.key === "Enter" && trimmedSearchValue !== "") {
|
||||
const alreadyExists =
|
||||
currentTags.some((tag) => tag.label === trimmedSearchValue) ||
|
||||
tagsToSearch.some((tag) => tag.label === trimmedSearchValue);
|
||||
|
||||
if (!alreadyExists) {
|
||||
createTag?.(trimmedSearchValue);
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -118,15 +120,16 @@ export const TagsCombobox = ({
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
{searchValue !== "" &&
|
||||
!currentTags.find((tag) => tag.label === searchValue) &&
|
||||
!tagsToSearch.find((tag) => tag.label === searchValue) && (
|
||||
{trimmedSearchValue !== "" &&
|
||||
!currentTags.find((tag) => tag.label === trimmedSearchValue) &&
|
||||
!tagsToSearch.find((tag) => tag.label === trimmedSearchValue) && (
|
||||
<CommandItem value="_create">
|
||||
<button
|
||||
onClick={() => createTag?.(searchValue)}
|
||||
type="button"
|
||||
onClick={() => createTag?.(trimmedSearchValue)}
|
||||
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 === searchValue)}>
|
||||
+ {t("environments.project.tags.add")} {searchValue}
|
||||
disabled={!!currentTags.find((tag) => tag.label === trimmedSearchValue)}>
|
||||
+ {t("environments.project.tags.add")} {trimmedSearchValue}
|
||||
</button>
|
||||
</CommandItem>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user