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:
Aditya
2025-07-04 14:17:49 +05:30
committed by GitHub
parent ba9b01a969
commit ef1be219b4
12 changed files with 377 additions and 140 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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