mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-29 17:40:43 -05:00
Compare commits
1 Commits
feat/eston
...
typeerror-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdd59da00b |
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -73,7 +73,7 @@ function Consent({
|
||||
/>
|
||||
|
||||
{/* Consent Checkbox */}
|
||||
<div className="relative" data-element-input>
|
||||
<div className="relative">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
<label
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -292,7 +292,7 @@ function FileUpload({
|
||||
imageAltText={imageAltText}
|
||||
/>
|
||||
|
||||
<div className="relative" data-element-input>
|
||||
<div className="relative">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
<div
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user