Compare commits

..

1 Commits

Author SHA1 Message Date
Cursor Agent
cdd59da00b fix: filter undefined choices in multiple choice elements
Fixes FORMBRICKS-VX

- Add filter to remove undefined values from elementChoices array
- Prevents TypeError when calling .has() on Set constructed from elementChoices
- Occurs when shuffledChoicesIds contains IDs not present in element.choices
- Applied fix to both MultipleChoiceMultiElement and MultipleChoiceSingleElement
- Added comprehensive unit tests to verify the fix
2026-03-25 10:28:59 +00:00
27 changed files with 198 additions and 153 deletions

View File

@@ -106,7 +106,10 @@ describe("billing actions", () => {
});
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"start-hobby"
);
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -125,7 +128,10 @@ describe("billing actions", () => {
} as any);
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"start-hobby"
);
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -139,7 +145,7 @@ describe("billing actions", () => {
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -159,7 +165,7 @@ describe("billing actions", () => {
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_existing");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});

View File

@@ -216,7 +216,7 @@ export const startHobbyAction = authenticatedActionClient
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
}
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "start-hobby");
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});
@@ -248,7 +248,7 @@ export const startProTrialAction = authenticatedActionClient
}
await createProTrialSubscription(parsedInput.organizationId, customerId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "pro-trial");
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});

View File

@@ -150,7 +150,7 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
await handleSetupCheckoutCompleted(event.data.object, stripe);
}
await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, event.id);
await syncOrganizationBillingFromStripe(organizationId, {
id: event.id,
created: event.created,

View File

@@ -1905,7 +1905,7 @@ describe("organization-billing", () => {
items: [{ price: "price_hobby_monthly", quantity: 1 }],
metadata: { organizationId: "org_1" },
},
{ idempotencyKey: "ensure-hobby-subscription-org_1-0" }
{ idempotencyKey: "ensure-hobby-subscription-org_1-bootstrap" }
);
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith({
where: { organizationId: "org_1" },
@@ -1974,7 +1974,7 @@ describe("organization-billing", () => {
],
});
await reconcileCloudStripeSubscriptionsForOrganization("org_1");
await reconcileCloudStripeSubscriptionsForOrganization("org_1", "evt_123");
expect(mocks.subscriptionsCancel).toHaveBeenCalledWith("sub_hobby", { prorate: false });
expect(mocks.subscriptionsCreate).not.toHaveBeenCalled();

View File

@@ -458,21 +458,18 @@ const resolvePendingChangeEffectiveAt = (
const ensureHobbySubscription = async (
organizationId: string,
customerId: string,
subscriptionCount: number
idempotencySuffix: string
): Promise<void> => {
if (!stripeClient) return;
const hobbyItems = await getCatalogItemsForPlan("hobby", "monthly");
// Include subscriptionCount so the key is stable across concurrent calls (same
// count → same key → Stripe deduplicates) but changes after a cancellation
// (count increases → new key → allows legitimate re-creation).
await stripeClient.subscriptions.create(
{
customer: customerId,
items: hobbyItems,
metadata: { organizationId },
},
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${subscriptionCount}` }
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${idempotencySuffix}` }
);
};
@@ -1267,7 +1264,8 @@ export const findOrganizationIdByStripeCustomerId = async (customerId: string):
};
export const reconcileCloudStripeSubscriptionsForOrganization = async (
organizationId: string
organizationId: string,
idempotencySuffix = "reconcile"
): Promise<void> => {
const client = stripeClient;
if (!IS_FORMBRICKS_CLOUD || !client) return;
@@ -1344,14 +1342,12 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
// (e.g. webhook + bootstrap) both seeing 0 and creating duplicate hobbies.
const freshSubscriptions = await client.subscriptions.list({
customer: customerId,
status: "all",
limit: 20,
status: "active",
limit: 1,
});
const freshActive = freshSubscriptions.data.filter((sub) => ACTIVE_SUBSCRIPTION_STATUSES.has(sub.status));
if (freshActive.length === 0) {
await ensureHobbySubscription(organizationId, customerId, freshSubscriptions.data.length);
if (freshSubscriptions.data.length === 0) {
await ensureHobbySubscription(organizationId, customerId, idempotencySuffix);
}
}
};
@@ -1359,6 +1355,6 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
export const ensureCloudStripeSetupForOrganization = async (organizationId: string): Promise<void> => {
if (!IS_FORMBRICKS_CLOUD || !stripeClient) return;
await ensureStripeCustomerForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, "bootstrap");
await syncOrganizationBillingFromStripe(organizationId);
};

View File

@@ -42,14 +42,14 @@ export interface ButtonProps
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, loading, asChild = false, disabled, children, ...props }, ref) => {
({ className, variant, size, loading, asChild = false, children, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, loading, className }))}
disabled={loading}
ref={ref}
{...props}
disabled={loading || disabled}>
{...props}>
{loading ? (
<>
<Loader2 className="animate-spin" />

View File

@@ -73,7 +73,7 @@ function Consent({
/>
{/* Consent Checkbox */}
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<label

View File

@@ -83,7 +83,7 @@ function CTA({
/>
{/* CTA Button */}
<div className="relative space-y-2" data-element-input>
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={dir} />
{buttonExternal ? (
@@ -95,7 +95,7 @@ function CTA({
disabled={disabled}
className="text-button font-button-weight flex items-center gap-2"
variant={buttonVariant}
size="custom">
size={"custom"}>
{buttonLabel}
<SquareArrowOutUpRightIcon className="size-4" />
</Button>

View File

@@ -161,7 +161,7 @@ function DateElement({
videoUrl={videoUrl}
/>
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
{/* Calendar - Always visible */}
<div className="w-full">

View File

@@ -292,7 +292,7 @@ function FileUpload({
imageAltText={imageAltText}
/>
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<div

View File

@@ -112,7 +112,7 @@ function FormField({
/>
{/* Form Fields */}
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<div className="space-y-3">
{visibleFields.map((field) => {

View File

@@ -94,7 +94,7 @@ function Matrix({
/>
{/* Matrix Table */}
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
{/* Table container with overflow for mobile */}

View File

@@ -145,7 +145,7 @@ function DropdownVariant({
searchPlaceholder,
searchNoResultsText,
}: Readonly<DropdownVariantProps>): React.JSX.Element {
const handleOptionToggle = (optionId: string): void => {
const handleOptionToggle = (optionId: string) => {
if (selectedValues.includes(optionId)) {
handleOptionRemove(optionId);
} else {
@@ -540,7 +540,7 @@ function MultiSelect({
/>
{/* Options */}
<div className="relative" data-element-input>
<div className="relative">
{variant === "dropdown" ? (
<DropdownVariant
inputId={inputId}

View File

@@ -172,7 +172,7 @@ function NPS({
/>
{/* NPS Options */}
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<fieldset className="w-full px-[2px]" dir={dir}>
<legend className="sr-only">NPS rating options</legend>

View File

@@ -79,7 +79,7 @@ function OpenText({
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} />
{/* Input or Textarea */}
<div className="space-y-1">

View File

@@ -106,7 +106,7 @@ function PictureSelect({
/>
{/* Picture Grid - 2 columns */}
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
{allowMulti ? (
<div className="grid grid-cols-2 gap-2">

View File

@@ -223,7 +223,7 @@ function Ranking({
/>
{/* Ranking Options */}
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<fieldset className="w-full" dir={dir}>
<legend className="sr-only">Ranking options</legend>

View File

@@ -407,7 +407,7 @@ function Rating({
/>
{/* Rating Options */}
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<fieldset className="w-full" dir={dir}>
<legend className="sr-only">Rating options</legend>

View File

@@ -181,7 +181,7 @@ function SingleSelect({
/>
{/* Options */}
<div data-element-input>
<div>
{variant === "dropdown" ? (
<>
<ElementError errorMessage={errorMessage} dir={dir} />
@@ -278,7 +278,7 @@ function SingleSelect({
) : null}
</>
) : (
<div className="relative" data-element-input>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<RadioGroup
name={inputId}

View File

@@ -1,85 +0,0 @@
{
"common": {
"and": "ja",
"apply": "rakenda",
"auto_close_wrapper": "Automaatse sulgemise ümbris",
"back": "Tagasi",
"close_survey": "Sulge küsitlus",
"company_logo": "Ettevõtte logo",
"finish": "Lõpeta",
"language_switch": "Keele vahetamine",
"next": "Edasi",
"no_results_found": "Tulemusi ei leitud",
"open_in_new_tab": "Ava uuel vahelehel",
"people_responded": "{count, plural, one {1 inimene vastas} other {{count} inimest vastas}}",
"please_retry_now_or_try_again_later": "Palun proovi uuesti kohe või hiljem.",
"powered_by": "Teenust pakub",
"privacy_policy": "Privaatsuspoliitika",
"protected_by_reCAPTCHA_and_the_Google": "Kaitstud reCAPTCHA ja Google'i poolt",
"question": "Küsimus",
"question_video": "Küsimuse video",
"required": "Kohustuslik",
"respondents_will_not_see_this_card": "Vastajad ei näe seda kaarti",
"retry": "Proovi uuesti",
"retrying": "Proovin uuesti…",
"search": "Otsi...",
"select_option": "Vali variant",
"select_options": "Vali variandid",
"sending_responses": "Vastuste saatmine…",
"takes_less_than_x_minutes": "{count, plural, one {Võtab vähem kui 1 minuti} other {Võtab vähem kui {count} minutit}}",
"takes_x_minutes": "{count, plural, one {Võtab 1 minuti} other {Võtab {count} minutit}}",
"takes_x_plus_minutes": "Võtab {count}+ minutit",
"terms_of_service": "Teenusetingimused",
"the_servers_cannot_be_reached_at_the_moment": "Serveritega ei saa hetkel ühendust.",
"they_will_be_redirected_immediately": "Nad suunatakse kohe ümber",
"your_feedback_is_stuck": "Sinu tagasiside on kinni jäänud :("
},
"errors": {
"all_options_must_be_ranked": "Palun järjesta kõik variandid",
"all_rows_must_be_answered": "Palun vasta kõikidele ridadele",
"file_extension_must_be": "Faililaiend peab olema {extension}",
"file_extension_must_not_be": "Faililaiend ei tohi olla {extension}",
"file_input": {
"duplicate_files": "Järgmised failid on juba üles laaditud: {duplicateNames}. Duplikaatfailid ei ole lubatud.",
"file_size_exceeded": "Järgmised failid ületavad maksimaalse suuruse {maxSizeInMB} MB ja eemaldati: {fileNames}",
"file_size_exceeded_alert": "Fail peab olema väiksem kui {maxSizeInMB} MB",
"no_valid_file_types_selected": "Ühtegi kehtivat failitüüpi pole valitud. Palun vali kehtiv failitüüp.",
"only_one_file_can_be_uploaded_at_a_time": "Korraga saab üles laadida ainult ühe faili.",
"placeholder_text": "Klõpsa või lohista failide üleslaadimiseks",
"upload_failed": "Üleslaadimine ebaõnnestus! Palun proovi uuesti.",
"uploading": "Üleslaadimine...",
"you_can_only_upload_a_maximum_of_files": "Saad üles laadida maksimaalselt {FILE_LIMIT} faili."
},
"invalid_device_error": {
"message": "Palun keela küsitluse seadetes rämpsposti kaitse, et jätkata selle seadmega.",
"title": "See seade ei toeta rämpsposti kaitset."
},
"invalid_format": "Palun sisesta kehtiv vorming",
"is_between": "Palun vali kuupäev vahemikus {startDate} kuni {endDate}",
"is_earlier_than": "Palun vali kuupäev enne {date}",
"is_greater_than": "Palun sisesta väärtus, mis on suurem kui {min}",
"is_later_than": "Palun vali kuupäev pärast {date}",
"is_less_than": "Palun sisesta väärtus, mis on väiksem kui {max}",
"is_not_between": "Palun vali kuupäev, mis ei jää vahemikku {startDate} kuni {endDate}",
"max_length": "Palun sisesta mitte rohkem kui {max} tähemärki",
"max_selections": "Palun vali mitte rohkem kui {max} varianti",
"max_value": "Palun sisesta väärtus, mis ei ole suurem kui {max}",
"min_length": "Palun sisesta vähemalt {min} tähemärki",
"min_selections": "Palun vali vähemalt {min} varianti",
"min_value": "Palun sisesta väärtus vähemalt {min}",
"minimum_options_ranked": "Palun järjesta vähemalt {min} varianti",
"minimum_rows_answered": "Palun vasta vähemalt {min} reale",
"please_enter_a_valid_email_address": "Palun sisesta kehtiv e-posti aadress",
"please_enter_a_valid_phone_number": "Palun sisesta kehtiv telefoninumber",
"please_enter_a_valid_url": "Palun sisesta kehtiv URL",
"please_fill_out_this_field": "Palun täida see väli",
"recaptcha_error": {
"message": "Sinu vastust ei saanud esitada, kuna see märgiti automatiseeritud tegevuseks. Kui sa hingad, palun proovi uuesti.",
"title": "Me ei suutnud kinnitada, et sa oled inimene."
},
"value_must_contain": "Väärtus peab sisaldama {value}",
"value_must_equal": "Väärtus peab võrduma {value}",
"value_must_not_contain": "Väärtus ei tohi sisaldada {value}",
"value_must_not_equal": "Väärtus ei tohi võrduda {value}"
}
}

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import type { TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
/**
* Test to verify that the elementChoices useMemo properly filters out undefined values
* when shuffledChoicesIds contains IDs that don't exist in element.choices.
*
* This test simulates the bug scenario where:
* 1. shuffledChoicesIds contains a choice ID
* 2. element.choices.find() returns undefined for that ID
* 3. The undefined value should be filtered out to prevent TypeError
*/
describe("MultipleChoiceMultiElement - elementChoices filtering", () => {
it("should filter out undefined choices when shuffled IDs don't match", () => {
// Simulate the scenario where shuffledChoicesIds might contain IDs
// that are no longer in element.choices
const mockElement: TSurveyMultipleChoiceElement = {
id: "test-element",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Test Question" },
required: false,
shuffleOption: "all",
choices: [
{ id: "choice-1", label: { default: "Option 1" } },
{ id: "choice-2", label: { default: "Option 2" } },
{ id: "choice-3", label: { default: "Option 3" } },
],
};
// Simulate shuffledChoicesIds that includes an ID not in element.choices
const shuffledChoicesIds = ["choice-1", "invalid-id", "choice-2", "choice-3"];
// This simulates the logic in the elementChoices useMemo
const elementChoices = shuffledChoicesIds
.map((choiceId) => {
const choice = mockElement.choices.find((currentChoice) => {
return currentChoice.id === choiceId;
});
return choice;
})
.filter((choice): choice is NonNullable<typeof choice> => choice !== undefined);
// Verify that undefined values are filtered out
expect(elementChoices).toHaveLength(3);
expect(elementChoices.every((choice) => choice !== undefined)).toBe(true);
// Verify that we can safely create a Set from the filtered choices
const knownLabels = new Set(
elementChoices.filter((c) => c && c.id !== "other").map((c) => c!.label.default)
);
expect(knownLabels.size).toBe(3);
expect(() => knownLabels.has("Option 1")).not.toThrow();
});
it("should handle empty choices array", () => {
const mockElement: TSurveyMultipleChoiceElement = {
id: "test-element",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Test Question" },
required: false,
shuffleOption: "all",
choices: [],
};
const shuffledChoicesIds: string[] = [];
const elementChoices = shuffledChoicesIds
.map((choiceId) => {
const choice = mockElement.choices.find((currentChoice) => {
return currentChoice.id === choiceId;
});
return choice;
})
.filter((choice): choice is NonNullable<typeof choice> => choice !== undefined);
expect(elementChoices).toHaveLength(0);
});
it("should preserve all choices when all IDs are valid", () => {
const mockElement: TSurveyMultipleChoiceElement = {
id: "test-element",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Test Question" },
required: false,
shuffleOption: "all",
choices: [
{ id: "choice-1", label: { default: "Option 1" } },
{ id: "choice-2", label: { default: "Option 2" } },
],
};
const shuffledChoicesIds = ["choice-2", "choice-1"];
const elementChoices = shuffledChoicesIds
.map((choiceId) => {
const choice = mockElement.choices.find((currentChoice) => {
return currentChoice.id === choiceId;
});
return choice;
})
.filter((choice): choice is NonNullable<typeof choice> => choice !== undefined);
expect(elementChoices).toHaveLength(2);
expect(elementChoices[0].id).toBe("choice-2");
expect(elementChoices[1].id).toBe("choice-1");
});
});

View File

@@ -51,12 +51,14 @@ export function MultipleChoiceMultiElement({
return [];
}
if (element.shuffleOption === "none" || element.shuffleOption === undefined) return element.choices;
return shuffledChoicesIds.map((choiceId) => {
const choice = element.choices.find((currentChoice) => {
return currentChoice.id === choiceId;
});
return choice;
});
return shuffledChoicesIds
.map((choiceId) => {
const choice = element.choices.find((currentChoice) => {
return currentChoice.id === choiceId;
});
return choice;
})
.filter((choice): choice is NonNullable<typeof choice> => choice !== undefined);
}, [element.choices, element.shuffleOption, shuffledChoicesIds]);
const otherOption = useMemo(

View File

@@ -51,12 +51,14 @@ export function MultipleChoiceSingleElement({
return [];
}
if (element.shuffleOption === "none" || element.shuffleOption === undefined) return element.choices;
return shuffledChoicesIds.map((choiceId) => {
const choice = element.choices.find((selectedChoice) => {
return selectedChoice.id === choiceId;
});
return choice;
});
return shuffledChoicesIds
.map((choiceId) => {
const choice = element.choices.find((selectedChoice) => {
return selectedChoice.id === choiceId;
});
return choice;
})
.filter((choice): choice is NonNullable<typeof choice> => choice !== undefined);
}, [element.choices, element.shuffleOption, shuffledChoicesIds]);
const otherOption = useMemo(

View File

@@ -278,12 +278,11 @@ export function BlockConditional({
if (hasValidationErrors) {
setElementErrors(errorMap);
// Find the first element with an error and scroll to its input area (not the headline)
// Find the first element with an error and scroll to it
const firstErrorElementId = Object.keys(errorMap)[0];
const form = elementFormRefs.current.get(firstErrorElementId);
if (form) {
const scrollTarget = form.querySelector("[data-element-input]") ?? form;
scrollTarget.scrollIntoView({ behavior: "smooth", block: "center" });
form.scrollIntoView({ behavior: "smooth", block: "center" });
}
return;
}
@@ -291,8 +290,7 @@ export function BlockConditional({
// Also run legacy validation for elements not yet migrated to centralized validation
const firstInvalidForm = findFirstInvalidForm();
if (firstInvalidForm) {
const scrollTarget = firstInvalidForm.querySelector("[data-element-input]") ?? firstInvalidForm;
scrollTarget.scrollIntoView({ behavior: "smooth", block: "center" });
firstInvalidForm.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}

View File

@@ -6,7 +6,6 @@ import daTranslations from "../../locales/da.json";
import deTranslations from "../../locales/de.json";
import enTranslations from "../../locales/en.json";
import esTranslations from "../../locales/es.json";
import etTranslations from "../../locales/et.json";
import frTranslations from "../../locales/fr.json";
import hiTranslations from "../../locales/hi.json";
import huTranslations from "../../locales/hu.json";
@@ -31,7 +30,6 @@ i18n
"de",
"en",
"es",
"et",
"fr",
"hi",
"hu",
@@ -52,7 +50,6 @@ i18n
de: { translation: deTranslations },
en: { translation: enTranslations },
es: { translation: esTranslations },
et: { translation: etTranslations },
fr: { translation: frTranslations },
hi: { translation: hiTranslations },
hu: { translation: huTranslations },

View File

@@ -1,7 +1,7 @@
import { type z } from "zod";
import type { TI18nString } from "../i18n";
import type { TSurveyLanguage } from "./types";
import { findLanguageCodesForDuplicateLabels, getTextContent } from "./validation";
import { getTextContent } from "./validation";
const extractLanguageCodes = (surveyLanguages?: TSurveyLanguage[]): string[] => {
if (!surveyLanguages) return [];
@@ -92,5 +92,28 @@ export const validateElementLabels = (
return null;
};
// Re-export for backwards compatibility
export { findLanguageCodesForDuplicateLabels };
export const findLanguageCodesForDuplicateLabels = (
labels: TI18nString[],
surveyLanguages: TSurveyLanguage[]
): string[] => {
const enabledLanguages = surveyLanguages.filter((lang) => lang.enabled);
const languageCodes = extractLanguageCodes(enabledLanguages);
const languagesToCheck = languageCodes.length === 0 ? ["default"] : languageCodes;
const duplicateLabels = new Set<string>();
for (const language of languagesToCheck) {
const labelTexts = labels
.map((label) => label[language])
.filter((text): text is string => typeof text === "string" && text.trim().length > 0)
.map((text) => text.trim());
const uniqueLabels = new Set(labelTexts);
if (uniqueLabels.size !== labelTexts.length) {
duplicateLabels.add(language);
}
}
return Array.from(duplicateLabels);
};

View File

@@ -228,10 +228,7 @@ export const findLanguageCodesForDuplicateLabels = (
const duplicateLabels = new Set<string>();
for (const language of languagesToCheck) {
const labelTexts = labels
.map((label) => label[language])
.filter((text): text is string => typeof text === "string" && text.trim().length > 0)
.map((text) => text.trim());
const labelTexts = labels.map((label) => label[language].trim()).filter(Boolean);
const uniqueLabels = new Set(labelTexts);
if (uniqueLabels.size !== labelTexts.length) {