mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-26 00:10:21 -06:00
Compare commits
25 Commits
add-bernie
...
fix/duplic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07e074f8e4 | ||
|
|
e6fede577d | ||
|
|
ab2fc0ae2d | ||
|
|
5b2964bc61 | ||
|
|
40588c2a2e | ||
|
|
dc8dbd7506 | ||
|
|
a0601dc32a | ||
|
|
dafad724ef | ||
|
|
2b20d0a6b2 | ||
|
|
9181baea38 | ||
|
|
a278e1664a | ||
|
|
b78951cb79 | ||
|
|
c723e0169e | ||
|
|
36ad5f9442 | ||
|
|
8a432cfbe8 | ||
|
|
849cf79e88 | ||
|
|
c8d8e92f59 | ||
|
|
c4ac69ac94 | ||
|
|
22535d2d70 | ||
|
|
95838d7cd1 | ||
|
|
907899c3d6 | ||
|
|
ea8de20ff4 | ||
|
|
bc4a6f36e4 | ||
|
|
05c48d88c9 | ||
|
|
3f9f097260 |
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "無限回應",
|
||||
|
||||
@@ -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