Compare commits

...

25 Commits

Author SHA1 Message Date
Piyush Gupta
07e074f8e4 fix: cursor comments 2025-07-03 22:16:04 +05:30
Piyush Gupta
e6fede577d Merge branch 'fix/duplicate-tag' into Naidu-4444/main 2025-07-03 22:05:49 +05:30
Piyush Gupta
ab2fc0ae2d fix: tests 2025-07-03 22:05:04 +05:30
Piyush Gupta
5b2964bc61 Merge branch 'main' of https://github.com/formbricks/formbricks into fix/duplicate-tag 2025-07-03 21:36:42 +05:30
Piyush Gupta
40588c2a2e Merge branch 'main' of https://github.com/formbricks/formbricks into Naidu-4444/main 2025-07-03 21:35:35 +05:30
Piyush Gupta
dc8dbd7506 fix: tests 2025-07-03 21:32:29 +05:30
Piyush Gupta
a0601dc32a fix: unit tests 2025-07-03 19:01:15 +05:30
Piyush Gupta
dafad724ef fix: review suggestions 2025-07-03 17:55:14 +05:30
pandeymangg
2b20d0a6b2 refactors 2025-07-03 11:55:11 +05:30
Piyush Gupta
9181baea38 fix: coderabbit suggestions 2025-07-03 09:59:29 +05:30
Piyush Gupta
a278e1664a fix: review 2025-07-02 17:43:38 +05:30
Piyush Gupta
b78951cb79 fix: review suggestions and unit tests 2025-07-02 11:59:05 +05:30
Naidu_4444
c723e0169e fixed 2025-07-01 14:47:32 +00:00
Naidu_4444
36ad5f9442 prevent tag creation on Enter if tag exists + refactor tag update error handling 2025-07-01 13:45:22 +00:00
Aditya
8a432cfbe8 Merge branch 'formbricks:main' into main 2025-07-01 19:10:15 +05:30
Aditya
849cf79e88 Merge branch 'formbricks:main' into main 2025-06-30 15:30:00 +05:30
Aditya
c8d8e92f59 Merge branch 'formbricks:main' into main 2025-06-26 20:02:46 +05:30
Aditya
c4ac69ac94 Merge branch 'formbricks:main' into main 2025-06-25 10:49:43 +05:30
Piyush Gupta
22535d2d70 fix: unique check 2025-06-24 18:36:09 +05:30
Piyush Gupta
95838d7cd1 fix: unique check 2025-06-24 18:14:54 +05:30
Piyush Gupta
907899c3d6 fix: functionality 2025-06-24 18:12:58 +05:30
Piyush Gupta
ea8de20ff4 Merge branch 'main' of https://github.com/formbricks/formbricks into Naidu-4444/main 2025-06-24 17:53:27 +05:30
Naidu_4444
bc4a6f36e4 test: fix updateTagName error test to expect thrown error 2025-06-24 09:19:00 +00:00
Naidu_4444
05c48d88c9 fix: improve duplicate tag error handling 2025-06-24 08:21:26 +00:00
Naidu_4444
3f9f097260 fix: show specific error when duplicate tag name is entered 2025-06-23 11:51:05 +00:00
18 changed files with 377 additions and 272 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

@@ -980,43 +980,29 @@
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
},
"billing": {
"10000_monthly_responses": "10,000 monatliche Antworten",
"1000_monthly_responses": "1,000 monatliche Antworten",
"1500_monthly_responses": "1,500 monatliche Antworten",
"1_project": "1 Projekt",
"2000_contacts": "2,000 Kontakte",
"2000_monthly_identified_users": "2,000 monatlich identifizierte Nutzer",
"30000_monthly_identified_users": "30,000 monatlich identifizierte Nutzer",
"3_projects": "3 Projekte",
"5000_monthly_responses": "5,000 monatliche Antworten",
"5_projects": "5 Projekte",
"7500_contacts": "7,500 Kontakte",
"7500_monthly_identified_users": "7,500 monatlich identifizierte Nutzer",
"advanced_targeting": "Erweitertes Targeting",
"all_integrations": "Alle Integrationen",
"all_surveying_features": "Alle Umfragefunktionen",
"annually": "Jährlich",
"api_webhooks": "API & Webhooks",
"app_surveys": "In-app Umfragen",
"attribute_based_targeting": "Attributbasiertes Targeting",
"contact_us": "Kontaktiere uns",
"current": "aktuell",
"current_plan": "Aktueller Plan",
"current_tier_limit": "Aktuelles Limit",
"custom": "Benutzerdefiniert & Skalierung",
"custom_contacts_limit": "Benutzerdefiniertes Kontaktlimit",
"custom_miu_limit": "Benutzerdefiniertes MIU-Limit",
"custom_project_limit": "Benutzerdefiniertes Projektlimit",
"custom_response_limit": "Benutzerdefiniertes Antwortlimit",
"customer_success_manager": "Customer Success Manager",
"email_embedded_surveys": "Eingebettete Umfragen in E-Mails",
"email_follow_ups": "E-Mail Follow-ups",
"email_support": "E-Mail-Support",
"enterprise": "Enterprise",
"enterprise_description": "Premium-Support und benutzerdefinierte Limits.",
"everybody_has_the_free_plan_by_default": "Jeder hat standardmäßig den kostenlosen Plan!",
"everything_in_free": "Alles in 'Free''",
"everything_in_scale": "Alles in 'Scale''",
"everything_in_startup": "Alles in 'Startup''",
"free": "Kostenlos",
"free_description": "Unbegrenzte Umfragen, Teammitglieder und mehr.",
@@ -1030,25 +1016,17 @@
"manage_subscription": "Abonnement verwalten",
"monthly": "Monatlich",
"monthly_identified_users": "Monatlich identifizierte Nutzer",
"multi_language_surveys": "Mehrsprachige Umfragen",
"per_month": "pro Monat",
"per_year": "pro Jahr",
"plan_upgraded_successfully": "Plan erfolgreich aktualisiert",
"premium_support_with_slas": "Premium-Support mit SLAs",
"priority_support": "Priorisierter Support",
"remove_branding": "Branding entfernen",
"say_hi": "Sag Hi!",
"scale": "Scale",
"scale_and_enterprise": "Scale & Enterprise",
"scale_description": "Erweiterte Funktionen für größere Unternehmen.",
"startup": "Start-up",
"startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.",
"switch_plan": "Plan wechseln",
"switch_plan_confirmation_text": "Bist du sicher, dass du zum {plan}-Plan wechseln möchtest? Dir werden {price} {period} berechnet.",
"team_access_roles": "Rollen für Teammitglieder",
"technical_onboarding": "Technische Einführung",
"unable_to_upgrade_plan": "Plan kann nicht aktualisiert werden",
"unlimited_apps_websites": "Unbegrenzte Apps & Websites",
"unlimited_miu": "Unbegrenzte MIU",
"unlimited_projects": "Unbegrenzte Projekte",
"unlimited_responses": "Unbegrenzte Antworten",

View File

@@ -980,43 +980,29 @@
"api_keys_description": "Manage API keys to access Formbricks management APIs"
},
"billing": {
"10000_monthly_responses": "10000 Monthly Responses",
"1000_monthly_responses": "Monthly 1,000 Responses",
"1500_monthly_responses": "1500 Monthly Responses",
"1_project": "1 Project",
"2000_contacts": "2,000 Contacts",
"2000_monthly_identified_users": "2000 Monthly Identified Users",
"30000_monthly_identified_users": "30000 Monthly Identified Users",
"3_projects": "3 Projects",
"5000_monthly_responses": "5,000 Monthly Responses",
"5_projects": "5 Projects",
"7500_contacts": "7,500 Contacts",
"7500_monthly_identified_users": "7500 Monthly Identified Users",
"advanced_targeting": "Advanced Targeting",
"all_integrations": "All Integrations",
"all_surveying_features": "All surveying features",
"annually": "Annually",
"api_webhooks": "API & Webhooks",
"app_surveys": "App Surveys",
"attribute_based_targeting": "Attribute-based Targeting",
"contact_us": "Contact Us",
"current": "Current",
"current_plan": "Current Plan",
"current_tier_limit": "Current Tier Limit",
"custom": "Custom & Scale",
"custom_contacts_limit": "Custom Contacts Limit",
"custom_miu_limit": "Custom MIU limit",
"custom_project_limit": "Custom Project Limit",
"custom_response_limit": "Custom Response Limit",
"customer_success_manager": "Customer Success Manager",
"email_embedded_surveys": "Email Embedded Surveys",
"email_follow_ups": "Email Follow-ups",
"email_support": "Email Support",
"enterprise": "Enterprise",
"enterprise_description": "Premium support and custom limits.",
"everybody_has_the_free_plan_by_default": "Everybody has the free plan by default!",
"everything_in_free": "Everything in Free",
"everything_in_scale": "Everything in Scale",
"everything_in_startup": "Everything in Startup",
"free": "Free",
"free_description": "Unlimited Surveys, Team Members, and more.",
@@ -1030,25 +1016,17 @@
"manage_subscription": "Manage Subscription",
"monthly": "Monthly",
"monthly_identified_users": "Monthly Identified Users",
"multi_language_surveys": "Multi-Language Surveys",
"per_month": "per month",
"per_year": "per year",
"plan_upgraded_successfully": "Plan upgraded successfully",
"premium_support_with_slas": "Premium support with SLAs",
"priority_support": "Priority Support",
"remove_branding": "Remove Branding",
"say_hi": "Say Hi!",
"scale": "Scale",
"scale_and_enterprise": "Scale & Enterprise",
"scale_description": "Advanced features for scaling your business.",
"startup": "Startup",
"startup_description": "Everything in Free with additional features.",
"switch_plan": "Switch Plan",
"switch_plan_confirmation_text": "Are you sure you want to switch to the {plan} plan? You will be charged {price} {period}.",
"team_access_roles": "Team Access Roles",
"technical_onboarding": "Technical Onboarding",
"unable_to_upgrade_plan": "Unable to upgrade plan",
"unlimited_apps_websites": "Unlimited Apps & Websites",
"unlimited_miu": "Unlimited MIU",
"unlimited_projects": "Unlimited Projects",
"unlimited_responses": "Unlimited Responses",

View File

@@ -980,43 +980,29 @@
"api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks"
},
"billing": {
"10000_monthly_responses": "10000 Réponses Mensuelles",
"1000_monthly_responses": "1000 Réponses Mensuelles",
"1500_monthly_responses": "1500 Réponses Mensuelles",
"1_project": "1 Projet",
"2000_contacts": "2 000 Contacts",
"2000_monthly_identified_users": "2000 Utilisateurs Identifiés Mensuels",
"30000_monthly_identified_users": "30000 Utilisateurs Identifiés Mensuels",
"3_projects": "3 Projets",
"5000_monthly_responses": "5,000 Réponses Mensuelles",
"5_projects": "5 Projets",
"7500_contacts": "7 500 Contacts",
"7500_monthly_identified_users": "7500 Utilisateurs Identifiés Mensuels",
"advanced_targeting": "Ciblage Avancé",
"all_integrations": "Toutes les intégrations",
"all_surveying_features": "Tous les outils d'arpentage",
"annually": "Annuellement",
"api_webhooks": "API et Webhooks",
"app_surveys": "Sondages d'application",
"attribute_based_targeting": "Ciblage basé sur les attributs",
"contact_us": "Contactez-nous",
"current": "Actuel",
"current_plan": "Plan actuel",
"current_tier_limit": "Limite de niveau actuel",
"custom": "Personnalisé et Échelle",
"custom_contacts_limit": "Limite de contacts personnalisé",
"custom_miu_limit": "Limite MIU personnalisé",
"custom_project_limit": "Limite de projet personnalisé",
"custom_response_limit": "Limite de réponse personnalisé",
"customer_success_manager": "Responsable de la réussite client",
"email_embedded_surveys": "Sondages intégrés par e-mail",
"email_follow_ups": "Relances par e-mail",
"email_support": "Support par e-mail",
"enterprise": "Entreprise",
"enterprise_description": "Soutien premium et limites personnalisées.",
"everybody_has_the_free_plan_by_default": "Tout le monde a le plan gratuit par défaut !",
"everything_in_free": "Tout est gratuit",
"everything_in_scale": "Tout à l'échelle",
"everything_in_startup": "Tout dans le Startup",
"free": "Gratuit",
"free_description": "Sondages illimités, membres d'équipe, et plus encore.",
@@ -1030,25 +1016,17 @@
"manage_subscription": "Gérer l'abonnement",
"monthly": "Mensuel",
"monthly_identified_users": "Utilisateurs Identifiés Mensuels",
"multi_language_surveys": "Sondages multilingues",
"per_month": "par mois",
"per_year": "par an",
"plan_upgraded_successfully": "Plan mis à jour avec succès",
"premium_support_with_slas": "Soutien premium avec SLA",
"priority_support": "Soutien Prioritaire",
"remove_branding": "Supprimer la marque",
"say_hi": "Dis bonjour !",
"scale": "Échelle",
"scale_and_enterprise": "Échelle et Entreprise",
"scale_description": "Fonctionnalités avancées pour développer votre entreprise.",
"startup": "Startup",
"startup_description": "Tout est gratuit avec des fonctionnalités supplémentaires.",
"switch_plan": "Changer de plan",
"switch_plan_confirmation_text": "Êtes-vous sûr de vouloir passer au plan {plan} ? Vous serez facturé {price} {period}.",
"team_access_roles": "Rôles d'accès d'équipe",
"technical_onboarding": "Intégration technique",
"unable_to_upgrade_plan": "Impossible de mettre à niveau le plan",
"unlimited_apps_websites": "Applications et sites Web illimités",
"unlimited_miu": "MIU Illimité",
"unlimited_projects": "Projets illimités",
"unlimited_responses": "Réponses illimitées",

View File

@@ -980,43 +980,29 @@
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
},
"billing": {
"10000_monthly_responses": "10000 Respostas Mensais",
"1000_monthly_responses": "1000 Respostas Mensais",
"1500_monthly_responses": "1500 Respostas Mensais",
"1_project": "1 Projeto",
"2000_contacts": "2.000 Contatos",
"2000_monthly_identified_users": "2000 Usuários Identificados Mensalmente",
"30000_monthly_identified_users": "30000 Usuários Identificados Mensalmente",
"3_projects": "3 Projetos",
"5000_monthly_responses": "5,000 Respostas Mensais",
"5_projects": "5 Projetos",
"7500_contacts": "7.500 Contatos",
"7500_monthly_identified_users": "7500 Usuários Identificados Mensalmente",
"advanced_targeting": "Mira Avançada",
"all_integrations": "Todas as Integrações",
"all_surveying_features": "Todos os recursos de levantamento",
"annually": "anualmente",
"api_webhooks": "API e Webhooks",
"app_surveys": "Pesquisas de App",
"attribute_based_targeting": "Segmentação Baseada em Atributos",
"contact_us": "Fale Conosco",
"current": "atual",
"current_plan": "Plano Atual",
"current_tier_limit": "Limite Atual de Nível",
"custom": "Personalizado e Escala",
"custom_contacts_limit": "Limite de Contatos Personalizado",
"custom_miu_limit": "Limite MIU personalizado",
"custom_project_limit": "Limite de Projeto Personalizado",
"custom_response_limit": "Limite de Resposta Personalizado",
"customer_success_manager": "Gerente de Sucesso do Cliente",
"email_embedded_surveys": "Pesquisas Incorporadas no Email",
"email_follow_ups": "Acompanhamentos por Email",
"email_support": "Suporte por Email",
"enterprise": "Empresa",
"enterprise_description": "Suporte premium e limites personalizados.",
"everybody_has_the_free_plan_by_default": "Todo mundo tem o plano gratuito por padrão!",
"everything_in_free": "Tudo de graça",
"everything_in_scale": "Tudo em Escala",
"everything_in_startup": "Tudo em Startup",
"free": "grátis",
"free_description": "Pesquisas ilimitadas, membros da equipe e mais.",
@@ -1030,25 +1016,17 @@
"manage_subscription": "Gerenciar Assinatura",
"monthly": "mensal",
"monthly_identified_users": "Usuários Identificados Mensalmente",
"multi_language_surveys": "Pesquisas Multilíngues",
"per_month": "por mês",
"per_year": "por ano",
"plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs",
"priority_support": "Suporte Prioritário",
"remove_branding": "Remover Marca",
"say_hi": "Diz oi!",
"scale": "escala",
"scale_and_enterprise": "Escala e Empresa",
"scale_description": "Recursos avançados pra escalar seu negócio.",
"startup": "startup",
"startup_description": "Tudo no Grátis com recursos adicionais.",
"switch_plan": "Mudar Plano",
"switch_plan_confirmation_text": "Tem certeza de que deseja mudar para o plano {plan}? Você será cobrado {price} {period}.",
"team_access_roles": "Funções de Acesso da Equipe",
"technical_onboarding": "Integração Técnica",
"unable_to_upgrade_plan": "Não foi possível atualizar o plano",
"unlimited_apps_websites": "Apps e Sites Ilimitados",
"unlimited_miu": "MIU Ilimitado",
"unlimited_projects": "Projetos Ilimitados",
"unlimited_responses": "Respostas Ilimitadas",

View File

@@ -980,43 +980,29 @@
"api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks"
},
"billing": {
"10000_monthly_responses": "10000 Respostas Mensais",
"1000_monthly_responses": "1000 Respostas Mensais",
"1500_monthly_responses": "1500 Respostas Mensais",
"1_project": "1 Projeto",
"2000_contacts": "2,000 Contactos",
"2000_monthly_identified_users": "2000 Utilizadores Identificados Mensalmente",
"30000_monthly_identified_users": "30000 Utilizadores Identificados Mensalmente",
"3_projects": "3 Projetos",
"5000_monthly_responses": "5,000 Respostas Mensais",
"5_projects": "5 Projetos",
"7500_contacts": "7,500 Contactos",
"7500_monthly_identified_users": "7500 Utilizadores Identificados Mensalmente",
"advanced_targeting": "Segmentação Avançada",
"all_integrations": "Todas as Integrações",
"all_surveying_features": "Todas as funcionalidades de inquérito",
"annually": "Anualmente",
"api_webhooks": "API e Webhooks",
"app_surveys": "Inquéritos da Aplicação",
"attribute_based_targeting": "Segmentação Baseada em Atributos",
"contact_us": "Contacte-nos",
"current": "Atual",
"current_plan": "Plano Atual",
"current_tier_limit": "Limite Atual do Nível",
"custom": "Personalizado e Escala",
"custom_contacts_limit": "Limite de Contactos Personalizado",
"custom_miu_limit": "Limite MIU Personalizado",
"custom_project_limit": "Limite de Projeto Personalizado",
"custom_response_limit": "Limite de Resposta Personalizado",
"customer_success_manager": "Gestor de Sucesso do Cliente",
"email_embedded_surveys": "Inquéritos Incorporados no Email",
"email_follow_ups": "Acompanhamentos por Email",
"email_support": "Suporte por Email",
"enterprise": "Empresa",
"enterprise_description": "Suporte premium e limites personalizados.",
"everybody_has_the_free_plan_by_default": "Todos têm o plano gratuito por defeito!",
"everything_in_free": "Tudo em Gratuito",
"everything_in_scale": "Tudo em Escala",
"everything_in_startup": "Tudo em Startup",
"free": "Grátis",
"free_description": "Inquéritos ilimitados, membros da equipa e mais.",
@@ -1030,25 +1016,17 @@
"manage_subscription": "Gerir Subscrição",
"monthly": "Mensal",
"monthly_identified_users": "Utilizadores Identificados Mensalmente",
"multi_language_surveys": "Inquéritos Multilingues",
"per_month": "por mês",
"per_year": "por ano",
"plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs",
"priority_support": "Suporte Prioritário",
"remove_branding": "Remover Marca",
"say_hi": "Diga Olá!",
"scale": "Escala",
"scale_and_enterprise": "Escala e Empresa",
"scale_description": "Funcionalidades avançadas para escalar o seu negócio.",
"startup": "Inicialização",
"startup_description": "Tudo no plano Gratuito com funcionalidades adicionais.",
"switch_plan": "Mudar Plano",
"switch_plan_confirmation_text": "Tem a certeza de que deseja mudar para o plano {plan}? Ser-lhe-á cobrado {price} {period}.",
"team_access_roles": "Funções de Acesso da Equipa",
"technical_onboarding": "Integração Técnica",
"unable_to_upgrade_plan": "Não é possível atualizar o plano",
"unlimited_apps_websites": "Aplicações e Websites Ilimitados",
"unlimited_miu": "MIU Ilimitado",
"unlimited_projects": "Projetos Ilimitados",
"unlimited_responses": "Respostas Ilimitadas",

View File

@@ -980,43 +980,29 @@
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
},
"billing": {
"10000_monthly_responses": "10000 個每月回應",
"1000_monthly_responses": "1000 個每月回應",
"1500_monthly_responses": "1500 個每月回應",
"1_project": "1 個專案",
"2000_contacts": "2000 個聯絡人",
"2000_monthly_identified_users": "2000 個每月識別使用者",
"30000_monthly_identified_users": "30000 個每月識別使用者",
"3_projects": "3 個專案",
"5000_monthly_responses": "5000 個每月回應",
"5_projects": "5 個專案",
"7500_contacts": "7500 個聯絡人",
"7500_monthly_identified_users": "7500 個每月識別使用者",
"advanced_targeting": "進階目標設定",
"all_integrations": "所有整合",
"all_surveying_features": "所有調查功能",
"annually": "每年",
"api_webhooks": "API 和 Webhook",
"app_surveys": "應用程式問卷",
"attribute_based_targeting": "基於屬性的定位",
"contact_us": "聯絡我們",
"current": "目前",
"current_plan": "目前方案",
"current_tier_limit": "目前層級限制",
"custom": "自訂 & 規模",
"custom_contacts_limit": "自訂聯絡人上限",
"custom_miu_limit": "自訂 MIU 上限",
"custom_project_limit": "自訂專案上限",
"custom_response_limit": "自訂回應上限",
"customer_success_manager": "客戶成功經理",
"email_embedded_surveys": "電子郵件嵌入式問卷",
"email_follow_ups": "電子郵件後續追蹤",
"email_support": "電子郵件支援",
"enterprise": "企業版",
"enterprise_description": "頂級支援和自訂限制。",
"everybody_has_the_free_plan_by_default": "每個人預設都有免費方案!",
"everything_in_free": "免費方案中的所有功能",
"everything_in_scale": "進階方案中的所有功能",
"everything_in_startup": "啟動方案中的所有功能",
"free": "免費",
"free_description": "無限問卷、團隊成員等。",
@@ -1030,25 +1016,17 @@
"manage_subscription": "管理訂閱",
"monthly": "每月",
"monthly_identified_users": "每月識別使用者",
"multi_language_surveys": "多語言問卷",
"per_month": "每月",
"per_year": "每年",
"plan_upgraded_successfully": "方案已成功升級",
"premium_support_with_slas": "具有 SLA 的頂級支援",
"priority_support": "優先支援",
"remove_branding": "移除品牌",
"say_hi": "打個招呼!",
"scale": "進階版",
"scale_and_enterprise": "規模 & 企業版",
"scale_description": "用於擴展業務的進階功能。",
"startup": "啟動版",
"startup_description": "免費方案中的所有功能以及其他功能。",
"switch_plan": "切換方案",
"switch_plan_confirmation_text": "您確定要切換到 {plan} 計劃嗎?您將被收取 {price} {period}。",
"team_access_roles": "團隊存取角色",
"technical_onboarding": "技術新手上路",
"unable_to_upgrade_plan": "無法升級方案",
"unlimited_apps_websites": "無限應用程式和網站",
"unlimited_miu": "無限 MIU",
"unlimited_projects": "無限專案",
"unlimited_responses": "無限回應",

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