fix: Backport/critical fixes to 4.0 (#6563)

Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
Dhruwang Jariwala
2025-09-19 14:32:54 +05:30
committed by GitHub
parent 3ba6dd9ada
commit bdfbc4b0f6
30 changed files with 179 additions and 251 deletions

View File

@@ -41,7 +41,7 @@ jobs:
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U testuser"
--health-cmd="pg_isready -U postgres"
--health-interval=10s
--health-timeout=5s
--health-retries=5
@@ -49,25 +49,15 @@ jobs:
image: valkey/valkey@sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d
ports:
- 6379:6379
minio:
image: bitnami/minio:2025.7.23-debian-12-r5
env:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- 9000:9000
options: >-
--health-cmd="curl -fsS http://localhost:9000/minio/health/live || exit 1"
--health-interval=10s
--health-timeout=5s
--health-retries=20
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: allow
egress-policy: audit
allowed-endpoints: |
ee.formbricks.com:443
registry-1.docker.io:443
docker.io:443
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout
@@ -101,8 +91,8 @@ jobs:
echo "S3_REGION=us-east-1" >> .env
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
echo "S3_ACCESS_KEY=minioadmin" >> .env
echo "S3_SECRET_KEY=minioadmin" >> .env
echo "S3_ACCESS_KEY=devminio" >> .env
echo "S3_SECRET_KEY=devminio123" >> .env
echo "S3_FORCE_PATH_STYLE=1" >> .env
shell: bash
@@ -122,6 +112,22 @@ jobs:
chmod +x "${MC_BIN}"
sudo mv "${MC_BIN}" /usr/local/bin/mc
- name: Start MinIO Server
run: |
set -euo pipefail
# Start MinIO server in background
docker run -d \
--name minio-server \
-p 9000:9000 \
-p 9001:9001 \
-e MINIO_ROOT_USER=devminio \
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
echo "MinIO server started"
- name: Wait for MinIO and create S3 bucket
run: |
set -euo pipefail
@@ -142,7 +148,7 @@ jobs:
exit 1
fi
mc alias set local http://localhost:9000 minioadmin minioadmin
mc alias set local http://localhost:9000 devminio devminio123
mc mb --ignore-existing local/formbricks-e2e
- name: Build App
@@ -233,4 +239,4 @@ jobs:
- name: Output App Logs
if: failure()
run: cat app.log
run: cat app.log

View File

@@ -31,6 +31,6 @@ describe("IntegrationsTip", () => {
const linkElement = screen.getByText("environments.settings.notifications.use_the_integration");
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute("href", `/environments/${environmentId}/integrations`);
expect(linkElement).toHaveAttribute("href", `/environments/${environmentId}/project/integrations`);
});
});

View File

@@ -16,7 +16,7 @@ export const IntegrationsTip = ({ environmentId }: IntegrationsTipProps) => {
<p className="text-sm">
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
<a
href={`/environments/${environmentId}/integrations`}
href={`/environments/${environmentId}/project/integrations`}
className="ml-1 cursor-pointer text-sm underline">
{t("environments.settings.notifications.use_the_integration")}
</a>

View File

@@ -75,7 +75,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
{t("environments.surveys.summary.configure_alerts")}
</Link>
<Link
href={`/environments/${environmentId}/integrations`}
href={`/environments/${environmentId}/project/integrations`}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<BlocksIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.setup_integrations")}

View File

@@ -357,7 +357,10 @@ const buildNotionPayloadProperties = (
// notion requires specific payload for each column type
// * TYPES NOT SUPPORTED BY NOTION API - rollup, created_by, created_time, last_edited_by, or last_edited_time
const getValue = (colType: string, value: string | string[] | Date | number | Record<string, string>) => {
const getValue = (
colType: string,
value: string | string[] | Date | number | Record<string, string> | undefined
) => {
try {
switch (colType) {
case "select":

View File

@@ -62,9 +62,10 @@ export const GET = async (req: Request) => {
};
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
if (result) {
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/google-sheets`);
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/google-sheets`
);
}
return responses.internalServerErrorResponse("Failed to create or update Google Sheets integration");

View File

@@ -90,7 +90,9 @@ export const GET = withV1ApiWrapper({
};
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
return {
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`),
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/airtable`
),
};
} catch (error) {
logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");

View File

@@ -86,13 +86,15 @@ export const GET = withV1ApiWrapper({
if (result) {
return {
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/notion`),
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/notion`
),
};
}
} else if (error) {
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/integrations/notion?error=${error}`
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/notion?error=${error}`
),
};
}

View File

@@ -93,13 +93,15 @@ export const GET = withV1ApiWrapper({
if (result) {
return {
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack`),
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/slack`
),
};
}
} else if (error) {
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/integrations/slack?error=${error}`
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/slack?error=${error}`
),
};
}

View File

@@ -1,6 +1,5 @@
import { describe, expect, test } from "vitest";
import { TShuffleOption, TSurveyLogic, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TTemplateRole } from "@formbricks/types/templates";
import {
buildCTAQuestion,
buildConsentQuestion,

View File

@@ -19,7 +19,7 @@ import {
TSurveyRatingQuestion,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { TTemplate } from "@formbricks/types/templates";
import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
const getDefaultButtonLabel = (label: string | undefined, t: TFnType) =>
createI18nString(label || t("common.next"), []);
@@ -391,6 +391,7 @@ export const buildSurvey = (
name: string;
industries: ("eCommerce" | "saas" | "other")[];
channels: ("link" | "app" | "website")[];
role: TTemplateRole;
description: string;
questions: TSurveyQuestion[];
endings?: TSurveyEnding[];
@@ -403,6 +404,7 @@ export const buildSurvey = (
name: config.name,
industries: config.industries,
channels: config.channels,
role: config.role,
description: config.description,
preset: {
...localSurvey,

View File

@@ -24,6 +24,7 @@ const cartAbandonmentSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.card_abandonment_survey"),
role: "productManager",
industries: ["eCommerce"],
channels: ["app", "website", "link"],
description: t("templates.card_abandonment_survey_description"),
@@ -124,6 +125,7 @@ const siteAbandonmentSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.site_abandonment_survey"),
role: "productManager",
industries: ["eCommerce"],
channels: ["app", "website"],
description: t("templates.site_abandonment_survey_description"),
@@ -221,6 +223,7 @@ const productMarketFitSuperhuman = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.product_market_fit_superhuman"),
role: "productManager",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.product_market_fit_superhuman_description"),
@@ -295,6 +298,7 @@ const onboardingSegmentation = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.onboarding_segmentation"),
role: "productManager",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.onboarding_segmentation_description"),
@@ -358,6 +362,7 @@ const churnSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.churn_survey"),
role: "sales",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link"],
description: t("templates.churn_survey_description"),
@@ -447,6 +452,7 @@ const earnedAdvocacyScore = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.earned_advocacy_score_name"),
role: "customerSuccess",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link"],
description: t("templates.earned_advocacy_score_description"),
@@ -519,6 +525,7 @@ const usabilityScoreRatingSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.usability_score_name"),
role: "customerSuccess",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.usability_rating_description"),
@@ -644,6 +651,7 @@ const improveTrialConversion = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.improve_trial_conversion_name"),
role: "sales",
industries: ["saas"],
channels: ["link", "app"],
description: t("templates.improve_trial_conversion_description"),
@@ -745,6 +753,7 @@ const reviewPrompt = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.review_prompt_name"),
role: "marketing",
industries: ["saas", "eCommerce", "other"],
channels: ["link", "app"],
description: t("templates.review_prompt_description"),
@@ -823,6 +832,7 @@ const interviewPrompt = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.interview_prompt_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.interview_prompt_description"),
@@ -850,6 +860,7 @@ const improveActivationRate = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.improve_activation_rate_name"),
role: "productManager",
industries: ["saas"],
channels: ["link"],
description: t("templates.improve_activation_rate_description"),
@@ -940,6 +951,7 @@ const employeeSatisfaction = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.employee_satisfaction_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link"],
description: t("templates.employee_satisfaction_description"),
@@ -1017,6 +1029,7 @@ const uncoverStrengthsAndWeaknesses = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.uncover_strengths_and_weaknesses_name"),
role: "productManager",
industries: ["saas", "other"],
channels: ["app", "link"],
description: t("templates.uncover_strengths_and_weaknesses_description"),
@@ -1070,6 +1083,7 @@ const productMarketFitShort = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.product_market_fit_short_name"),
role: "productManager",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.product_market_fit_short_description"),
@@ -1106,6 +1120,7 @@ const marketAttribution = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.market_attribution_name"),
role: "marketing",
industries: ["saas", "eCommerce"],
channels: ["website", "app", "link"],
description: t("templates.market_attribution_description"),
@@ -1136,6 +1151,7 @@ const changingSubscriptionExperience = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.changing_subscription_experience_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.changing_subscription_experience_description"),
@@ -1178,6 +1194,7 @@ const identifyCustomerGoals = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.identify_customer_goals_name"),
role: "productManager",
industries: ["saas", "other"],
channels: ["app", "website"],
description: t("templates.identify_customer_goals_description"),
@@ -1207,6 +1224,7 @@ const featureChaser = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.feature_chaser_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.feature_chaser_description"),
@@ -1245,6 +1263,7 @@ const fakeDoorFollowUp = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.fake_door_follow_up_name"),
role: "productManager",
industries: ["saas", "eCommerce"],
channels: ["app", "website"],
description: t("templates.fake_door_follow_up_description"),
@@ -1288,6 +1307,7 @@ const feedbackBox = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.feedback_box_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.feedback_box_description"),
@@ -1357,6 +1377,7 @@ const integrationSetupSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.integration_setup_survey_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.integration_setup_survey_description"),
@@ -1429,6 +1450,7 @@ const newIntegrationSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.new_integration_survey_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.new_integration_survey_description"),
@@ -1460,6 +1482,7 @@ const docsFeedback = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.docs_feedback_name"),
role: "productManager",
industries: ["saas"],
channels: ["app", "website", "link"],
description: t("templates.docs_feedback_description"),
@@ -1499,6 +1522,7 @@ const nps = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.nps_name"),
role: "customerSuccess",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link", "website"],
description: t("templates.nps_description"),
@@ -1539,6 +1563,7 @@ const customerSatisfactionScore = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.csat_name"),
role: "customerSuccess",
industries: ["saas", "eCommerce", "other"],
channels: ["app", "link", "website"],
description: t("templates.csat_description"),
@@ -1707,6 +1732,7 @@ const collectFeedback = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.collect_feedback_name"),
role: "productManager",
industries: ["other", "eCommerce"],
channels: ["website", "link"],
description: t("templates.collect_feedback_description"),
@@ -1853,6 +1879,7 @@ const identifyUpsellOpportunities = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.identify_upsell_opportunities_name"),
role: "sales",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.identify_upsell_opportunities_description"),
@@ -1882,6 +1909,7 @@ const prioritizeFeatures = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.prioritize_features_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.prioritize_features_description"),
@@ -1934,6 +1962,7 @@ const gaugeFeatureSatisfaction = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.gauge_feature_satisfaction_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.gauge_feature_satisfaction_description"),
@@ -1967,6 +1996,7 @@ const marketSiteClarity = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.market_site_clarity_name"),
role: "marketing",
industries: ["saas", "eCommerce", "other"],
channels: ["website"],
description: t("templates.market_site_clarity_description"),
@@ -2008,6 +2038,7 @@ const customerEffortScore = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.customer_effort_score_name"),
role: "productManager",
industries: ["saas"],
channels: ["app"],
description: t("templates.customer_effort_score_description"),
@@ -2039,6 +2070,7 @@ const careerDevelopmentSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.career_development_survey_name"),
role: "productManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.career_development_survey_description"),
@@ -2125,6 +2157,7 @@ const professionalDevelopmentSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.professional_development_survey_name"),
role: "productManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.professional_development_survey_description"),
@@ -2212,6 +2245,7 @@ const rateCheckoutExperience = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.rate_checkout_experience_name"),
role: "productManager",
industries: ["eCommerce"],
channels: ["website", "app"],
description: t("templates.rate_checkout_experience_description"),
@@ -2288,6 +2322,7 @@ const measureSearchExperience = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.measure_search_experience_name"),
role: "productManager",
industries: ["saas", "eCommerce"],
channels: ["app", "website"],
description: t("templates.measure_search_experience_description"),
@@ -2364,6 +2399,7 @@ const evaluateContentQuality = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.evaluate_content_quality_name"),
role: "marketing",
industries: ["other"],
channels: ["website"],
description: t("templates.evaluate_content_quality_description"),
@@ -2441,6 +2477,7 @@ const measureTaskAccomplishment = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.measure_task_accomplishment_name"),
role: "productManager",
industries: ["saas"],
channels: ["app", "website"],
description: t("templates.measure_task_accomplishment_description"),
@@ -2623,6 +2660,7 @@ const identifySignUpBarriers = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.identify_sign_up_barriers_name"),
role: "marketing",
industries: ["saas", "eCommerce", "other"],
channels: ["website"],
description: t("templates.identify_sign_up_barriers_description"),
@@ -2774,6 +2812,7 @@ const buildProductRoadmap = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.build_product_roadmap_name"),
role: "productManager",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.build_product_roadmap_description"),
@@ -2808,6 +2847,7 @@ const understandPurchaseIntention = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.understand_purchase_intention_name"),
role: "sales",
industries: ["eCommerce"],
channels: ["website", "link", "app"],
description: t("templates.understand_purchase_intention_description"),
@@ -2863,6 +2903,7 @@ const improveNewsletterContent = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.improve_newsletter_content_name"),
role: "marketing",
industries: ["eCommerce", "saas", "other"],
channels: ["link"],
description: t("templates.improve_newsletter_content_description"),
@@ -2953,6 +2994,7 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.evaluate_a_product_idea_name"),
role: "productManager",
industries: ["saas", "other"],
channels: ["link", "app"],
description: t("templates.evaluate_a_product_idea_description"),
@@ -3055,6 +3097,7 @@ const understandLowEngagement = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.understand_low_engagement_name"),
role: "productManager",
industries: ["saas"],
channels: ["link"],
description: t("templates.understand_low_engagement_description"),
@@ -3140,6 +3183,7 @@ const employeeWellBeing = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.employee_well_being_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.employee_well_being_description"),
@@ -3189,6 +3233,7 @@ const longTermRetentionCheckIn = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.long_term_retention_check_in_name"),
role: "peopleManager",
industries: ["saas", "other"],
channels: ["app", "link"],
description: t("templates.long_term_retention_check_in_description"),
@@ -3297,6 +3342,7 @@ const professionalDevelopmentGrowth = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.professional_development_growth_survey_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.professional_development_growth_survey_description"),
@@ -3346,6 +3392,7 @@ const recognitionAndReward = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.recognition_and_reward_survey_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.recognition_and_reward_survey_description"),
@@ -3394,6 +3441,7 @@ const alignmentAndEngagement = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.alignment_and_engagement_survey_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.alignment_and_engagement_survey_description"),
@@ -3442,6 +3490,7 @@ const supportiveWorkCulture = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.supportive_work_culture_survey_name"),
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.supportive_work_culture_survey_description"),

View File

@@ -1,11 +1,11 @@
import { parseRecallInfo } from "@/lib/utils/recall";
import { TResponse } from "@formbricks/types/responses";
import { TResponse, TResponseDataValue } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys/types";
import { getLanguageCode, getLocalizedValue } from "./i18n/utils";
// function to convert response value of type string | number | string[] or Record<string, string> to string | string[]
export const convertResponseValue = (
answer: string | number | string[] | Record<string, string>,
answer: TResponseDataValue,
question: TSurveyQuestion
): string | string[] => {
switch (question.type) {
@@ -57,9 +57,7 @@ export const getQuestionResponseMapping = (
return questionResponseMapping;
};
export const processResponseData = (
responseData: string | number | string[] | Record<string, string>
): string => {
export const processResponseData = (responseData: TResponseDataValue): string => {
switch (typeof responseData) {
case "string":
return responseData;

View File

@@ -450,7 +450,7 @@ const evaluateSingleCondition = (
return (
Array.isArray(leftValue) &&
Array.isArray(rightValue) &&
rightValue.some((v) => !leftValue.includes(v))
!rightValue.some((v) => leftValue.includes(v))
);
case "isAccepted":
return leftValue === "accepted";

View File

@@ -13,6 +13,7 @@ import { RatingResponse } from "@/modules/ui/components/rating-response";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { CheckCheckIcon, MousePointerClickIcon, PhoneIcon } from "lucide-react";
import React from "react";
import { TResponseDataValue } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyMatrixQuestion,
@@ -23,7 +24,7 @@ import {
} from "@formbricks/types/surveys/types";
interface RenderResponseProps {
responseData: string | number | string[] | Record<string, string>;
responseData: TResponseDataValue;
question: TSurveyQuestion;
survey: TSurvey;
language: string | null;

View File

@@ -1,4 +1,6 @@
export const isValidValue = (value: string | number | Record<string, string> | string[]) => {
import { TResponseDataValue } from "@formbricks/types/responses";
export const isValidValue = (value: TResponseDataValue) => {
return (
(typeof value === "string" && value.trim() !== "") ||
(Array.isArray(value) && value.length > 0) ||

View File

@@ -32,13 +32,7 @@ describe("TemplateFilters", () => {
test("renders all filter categories and options", () => {
const setSelectedFilter = vi.fn();
render(
<TemplateFilters
selectedFilter={[null, null, null]}
setSelectedFilter={setSelectedFilter}
prefilledFilters={[null, null, null]}
/>
);
render(<TemplateFilters selectedFilter={[null, null, null]} setSelectedFilter={setSelectedFilter} />);
expect(screen.getByText("environments.surveys.templates.all_channels")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.templates.all_industries")).toBeInTheDocument();
@@ -54,13 +48,7 @@ describe("TemplateFilters", () => {
const setSelectedFilter = vi.fn();
const user = userEvent.setup();
render(
<TemplateFilters
selectedFilter={[null, null, null]}
setSelectedFilter={setSelectedFilter}
prefilledFilters={[null, null, null]}
/>
);
render(<TemplateFilters selectedFilter={[null, null, null]} setSelectedFilter={setSelectedFilter} />);
await user.click(screen.getByText("environments.surveys.templates.channel1"));
expect(setSelectedFilter).toHaveBeenCalledWith(["channel1", null, null]);
@@ -74,11 +62,7 @@ describe("TemplateFilters", () => {
const user = userEvent.setup();
render(
<TemplateFilters
selectedFilter={["link", "app", "website"]}
setSelectedFilter={setSelectedFilter}
prefilledFilters={[null, null, null]}
/>
<TemplateFilters selectedFilter={["link", "app", "website"]} setSelectedFilter={setSelectedFilter} />
);
await user.click(screen.getByText("environments.surveys.templates.all_channels"));
@@ -93,7 +77,6 @@ describe("TemplateFilters", () => {
selectedFilter={[null, null, null]}
setSelectedFilter={setSelectedFilter}
templateSearch="search term"
prefilledFilters={[null, null, null]}
/>
);
@@ -102,20 +85,4 @@ describe("TemplateFilters", () => {
expect(button).toBeDisabled();
});
});
test("does not render filter categories that are prefilled", () => {
const setSelectedFilter = vi.fn();
render(
<TemplateFilters
selectedFilter={["link", null, null]}
setSelectedFilter={setSelectedFilter}
prefilledFilters={["link", null, null]}
/>
);
expect(screen.queryByText("environments.surveys.templates.all_channels")).not.toBeInTheDocument();
expect(screen.getByText("environments.surveys.templates.all_industries")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.templates.all_roles")).toBeInTheDocument();
});
});

View File

@@ -9,14 +9,12 @@ interface TemplateFiltersProps {
selectedFilter: TTemplateFilter[];
setSelectedFilter: (filter: TTemplateFilter[]) => void;
templateSearch?: string;
prefilledFilters: TTemplateFilter[];
}
export const TemplateFilters = ({
selectedFilter,
setSelectedFilter,
templateSearch,
prefilledFilters,
}: TemplateFiltersProps) => {
const { t } = useTranslate();
const handleFilterSelect = (filterValue: TTemplateFilter, index: number) => {
@@ -31,7 +29,6 @@ export const TemplateFilters = ({
return (
<div className="mb-6 gap-3">
{allFilters.map((filters, index) => {
if (prefilledFilters[index] !== null) return;
return (
<div key={filters[0]?.value || index} className="mt-2 flex flex-wrap gap-1 last:border-r-0">
<button

View File

@@ -102,41 +102,20 @@ describe("TemplateList", () => {
});
test("renders correctly with default props", () => {
render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
/>
);
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
expect(screen.getByText("Start from scratch")).toBeInTheDocument();
});
test("renders filters when showFilters is true", () => {
render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
showFilters={true}
/>
);
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} showFilters={true} />);
expect(screen.queryByTestId("template-filters")).toBeInTheDocument();
});
test("doesn't render filters when showFilters is false", () => {
render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
showFilters={false}
/>
<TemplateList userId="user-id" environmentId="env-id" project={mockProject} showFilters={false} />
);
expect(screen.queryByTestId("template-filters")).not.toBeInTheDocument();
@@ -150,7 +129,6 @@ describe("TemplateList", () => {
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
templateSearch="Template 1"
/>
);
@@ -167,7 +145,6 @@ describe("TemplateList", () => {
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
onTemplateClick={onTemplateClickMock}
noPreview={true}
/>
@@ -186,14 +163,7 @@ describe("TemplateList", () => {
const user = userEvent.setup();
render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
/>
);
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
// First select the template
const selectButton = screen.getAllByText("Select")[0];
@@ -220,14 +190,7 @@ describe("TemplateList", () => {
const user = userEvent.setup();
render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
/>
);
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
// First select the template
const selectButton = screen.getAllByText("Select")[0];
@@ -250,12 +213,7 @@ describe("TemplateList", () => {
};
const { rerender } = render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mobileProject as Project}
prefilledFilters={[null, null, null]}
/>
<TemplateList userId="user-id" environmentId="env-id" project={mobileProject as Project} />
);
// Test with no channel config
@@ -264,14 +222,7 @@ describe("TemplateList", () => {
config: {},
};
rerender(
<TemplateList
userId="user-id"
environmentId="env-id"
project={noChannelProject as Project}
prefilledFilters={[null, null, null]}
/>
);
rerender(<TemplateList userId="user-id" environmentId="env-id" project={noChannelProject as Project} />);
expect(screen.getByText("Template 1")).toBeInTheDocument();
});
@@ -279,14 +230,7 @@ describe("TemplateList", () => {
test("development mode shows templates correctly", () => {
vi.stubEnv("NODE_ENV", "development");
render(
<TemplateList
userId="user-id"
environmentId="env-id"
project={mockProject}
prefilledFilters={[null, null, null]}
/>
);
render(<TemplateList userId="user-id" environmentId="env-id" project={mockProject} />);
expect(screen.getByText("Template 1")).toBeInTheDocument();
expect(screen.getByText("Template 2")).toBeInTheDocument();

View File

@@ -21,7 +21,6 @@ interface TemplateListProps {
project: Project;
templateSearch?: string;
showFilters?: boolean;
prefilledFilters: TTemplateFilter[];
onTemplateClick?: (template: TTemplate) => void;
noPreview?: boolean; // single click to create survey
}
@@ -32,7 +31,6 @@ export const TemplateList = ({
environmentId,
showFilters = true,
templateSearch,
prefilledFilters,
onTemplateClick = () => {},
noPreview,
}: TemplateListProps) => {
@@ -40,7 +38,7 @@ export const TemplateList = ({
const router = useRouter();
const [activeTemplate, setActiveTemplate] = useState<TTemplate | null>(null);
const [loading, setLoading] = useState(false);
const [selectedFilter, setSelectedFilter] = useState<TTemplateFilter[]>(prefilledFilters);
const [selectedFilter, setSelectedFilter] = useState<TTemplateFilter[]>([null, null, null]);
const surveyType: TSurveyType = useMemo(() => {
if (project.config.channel) {
if (project.config.channel === "website") {
@@ -111,7 +109,6 @@ export const TemplateList = ({
selectedFilter={selectedFilter}
setSelectedFilter={setSelectedFilter}
templateSearch={templateSearch}
prefilledFilters={prefilledFilters}
/>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">

View File

@@ -5,7 +5,6 @@ import { Session } from "next-auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TProject } from "@formbricks/types/project";
import { TTemplateRole } from "@formbricks/types/templates";
import { SurveysPage } from "./page";
// Mock all dependencies
@@ -53,19 +52,16 @@ vi.mock("@/modules/survey/list/lib/survey", () => ({
}));
vi.mock("@/modules/survey/templates/components/template-container", () => ({
TemplateContainerWithPreview: vi.fn(
({ userId, environment, project, prefilledFilters, isTemplatePage }) => (
<div
data-testid="template-container"
data-user-id={userId}
data-environment-id={environment.id}
data-project-id={project.id}
data-prefilled-filters={JSON.stringify(prefilledFilters)}
data-is-template-page={isTemplatePage}>
Template Container
</div>
)
),
TemplateContainerWithPreview: vi.fn(({ userId, environment, project, isTemplatePage }) => (
<div
data-testid="template-container"
data-user-id={userId}
data-environment-id={environment.id}
data-project-id={project.id}
data-is-template-page={isTemplatePage}>
Template Container
</div>
)),
}));
vi.mock("@/modules/ui/components/button", () => ({
@@ -207,9 +203,8 @@ describe("SurveysPage", () => {
mockTranslate.mockReturnValue("Project not found");
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
await expect(SurveysPage({ params, searchParams })).rejects.toThrow("Project not found");
await expect(SurveysPage({ params })).rejects.toThrow("Project not found");
expect(mockGetProjectWithTeamIdsByEnvironmentId).toHaveBeenCalledWith("env-123");
expect(mockTranslate).toHaveBeenCalledWith("common.project_not_found");
@@ -225,9 +220,8 @@ describe("SurveysPage", () => {
});
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
await SurveysPage({ params, searchParams });
await SurveysPage({ params });
expect(mockRedirect).toHaveBeenCalledWith("/environments/env-123/settings/billing");
});
@@ -236,9 +230,8 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(0);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({ role: "product_manager" as TTemplateRole });
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("template-container")).toBeInTheDocument();
@@ -246,20 +239,14 @@ describe("SurveysPage", () => {
expect(screen.getByTestId("template-container")).toHaveAttribute("data-environment-id", "env-123");
expect(screen.getByTestId("template-container")).toHaveAttribute("data-project-id", "project-123");
expect(screen.getByTestId("template-container")).toHaveAttribute("data-is-template-page", "false");
const prefilledFilters = JSON.parse(
screen.getByTestId("template-container").getAttribute("data-prefilled-filters") || "[]"
);
expect(prefilledFilters).toEqual(["website", "other", "product_manager"]);
});
test("renders surveys list when survey count is greater than 0", async () => {
mockGetSurveyCount.mockResolvedValue(5);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
@@ -289,9 +276,8 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(5);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("page-header")).toBeInTheDocument();
@@ -307,9 +293,8 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(0);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
// When survey count is 0, it should render TemplateContainer regardless of read-only status
@@ -330,16 +315,11 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(0);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("template-container")).toBeInTheDocument();
const prefilledFilters = JSON.parse(
screen.getByTestId("template-container").getAttribute("data-prefilled-filters") || "[]"
);
expect(prefilledFilters).toEqual([null, null, null]);
});
test("handles project with null styling", async () => {
@@ -351,9 +331,8 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(0);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("template-container")).toBeInTheDocument();
@@ -365,9 +344,8 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(5);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("surveys-list")).toHaveAttribute("data-locale", "en-US");
@@ -377,9 +355,8 @@ describe("SurveysPage", () => {
mockGetSurveyCount.mockResolvedValue(5);
const params = Promise.resolve({ environmentId: "env-123" });
const searchParams = Promise.resolve({});
const result = await SurveysPage({ params, searchParams });
const result = await SurveysPage({ params });
render(result);
expect(screen.getByTestId("link")).toHaveAttribute("href", "/environments/env-123/surveys/templates");

View File

@@ -14,7 +14,6 @@ import { PlusIcon } from "lucide-react";
import { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { TTemplateRole } from "@formbricks/types/templates";
export const metadata: Metadata = {
title: "Your Surveys",
@@ -24,17 +23,10 @@ interface SurveyTemplateProps {
params: Promise<{
environmentId: string;
}>;
searchParams: Promise<{
role?: TTemplateRole;
}>;
}
export const SurveysPage = async ({
params: paramsProps,
searchParams: searchParamsProps,
}: SurveyTemplateProps) => {
export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps) => {
const publicDomain = getPublicDomain();
const searchParams = await searchParamsProps;
const params = await paramsProps;
const t = await getTranslate();
@@ -46,8 +38,6 @@ export const SurveysPage = async ({
const { session, isBilling, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const prefilledFilters = [project?.config.channel, project.config.industry, searchParams.role ?? null];
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
@@ -79,7 +69,6 @@ export const SurveysPage = async ({
userId={session.user.id}
environment={environment}
project={projectWithRequiredProps}
prefilledFilters={prefilledFilters}
isTemplatePage={false}
/>
);

View File

@@ -3,7 +3,6 @@ import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
import { TTemplateRole } from "@formbricks/types/templates";
import { TemplateContainerWithPreview } from "./template-container";
// Mock dependencies
@@ -59,8 +58,6 @@ const mockEnvironment = {
appSetupCompleted: true,
};
const mockPrefilledFilters: (TProjectConfigChannel | TProjectConfigIndustry | TTemplateRole | null)[] = [];
describe("TemplateContainerWithPreview", () => {
afterEach(() => {
cleanup();
@@ -72,7 +69,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={true}
/>
);
@@ -86,7 +82,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={false}
/>
);
@@ -100,7 +95,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={true}
/>
);
@@ -114,7 +108,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={false}
/>
);
@@ -128,7 +121,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={true}
/>
);
@@ -144,7 +136,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={true}
/>
);
@@ -158,7 +149,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={true}
/>
);
@@ -172,7 +162,6 @@ describe("TemplateContainerWithPreview", () => {
project={mockProject}
environment={mockEnvironment}
userId="user1"
prefilledFilters={mockPrefilledFilters}
isTemplatePage={true}
/>
);

View File

@@ -5,19 +5,16 @@ import { TemplateList } from "@/modules/survey/components/template-list";
import { MenuBar } from "@/modules/survey/templates/components/menu-bar";
import { PreviewSurvey } from "@/modules/ui/components/preview-survey";
import { SearchBar } from "@/modules/ui/components/search-bar";
import { Project } from "@prisma/client";
import { Environment } from "@prisma/client";
import type { Environment, Project } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { useState } from "react";
import type { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
import type { TTemplate, TTemplateRole } from "@formbricks/types/templates";
import type { TTemplate } from "@formbricks/types/templates";
import { getMinimalSurvey } from "../lib/minimal-survey";
type TemplateContainerWithPreviewProps = {
project: Project;
environment: Pick<Environment, "id" | "appSetupCompleted">;
userId: string;
prefilledFilters: (TProjectConfigChannel | TProjectConfigIndustry | TTemplateRole | null)[];
isTemplatePage?: boolean;
};
@@ -25,7 +22,6 @@ export const TemplateContainerWithPreview = ({
project,
environment,
userId,
prefilledFilters,
isTemplatePage = true,
}: TemplateContainerWithPreviewProps) => {
const { t } = useTranslate();
@@ -63,7 +59,6 @@ export const TemplateContainerWithPreview = ({
setActiveQuestionId(template.preset.questions[0].id);
setActiveTemplate(template);
}}
prefilledFilters={prefilledFilters}
/>
</div>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 md:flex md:flex-col">

View File

@@ -2,23 +2,15 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
import { getTranslate } from "@/tolgee/server";
import { redirect } from "next/navigation";
import { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
import { TTemplateRole } from "@formbricks/types/templates";
import { TemplateContainerWithPreview } from "./components/template-container";
interface SurveyTemplateProps {
params: Promise<{
environmentId: string;
}>;
searchParams: Promise<{
channel?: TProjectConfigChannel;
industry?: TProjectConfigIndustry;
role?: TTemplateRole;
}>;
}
export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => {
const searchParams = await props.searchParams;
const t = await getTranslate();
const params = await props.params;
const environmentId = params.environmentId;
@@ -35,14 +27,7 @@ export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => {
return redirect(`/environments/${environment.id}/surveys`);
}
const prefilledFilters = [project.config.channel, project.config.industry, searchParams.role ?? null];
return (
<TemplateContainerWithPreview
userId={session.user.id}
environment={environment}
project={project}
prefilledFilters={prefilledFilters}
/>
<TemplateContainerWithPreview userId={session.user.id} environment={environment} project={project} />
);
};

View File

@@ -27,7 +27,7 @@ import {
interface QuestionConditionalProps {
question: TSurveyQuestion;
value: string | number | string[] | Record<string, string>;
value: TResponseDataValue;
onChange: (responseData: TResponseData) => void;
onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
onBack: () => void;

View File

@@ -112,7 +112,7 @@ export function MultipleChoiceSingleQuestion({
e.preventDefault();
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: value ?? "" }, updatedTtcObj);
onSubmit({ [question.id]: value }, updatedTtcObj);
}}
className="fb-w-full">
{isMediaAvailable ? <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} /> : null}
@@ -208,9 +208,13 @@ export function MultipleChoiceSingleQuestion({
value={getLocalizedValue(otherOption.label, languageCode)}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${otherOption.id}-label`}
onChange={() => {
setOtherSelected(!otherSelected);
onChange({ [question.id]: "" });
onClick={() => {
if (otherSelected) {
onChange({ [question.id]: undefined });
} else {
setOtherSelected(!otherSelected);
onChange({ [question.id]: "" });
}
}}
checked={otherSelected}
/>

View File

@@ -407,7 +407,7 @@ const evaluateSingleCondition = (
return (
Array.isArray(leftValue) &&
Array.isArray(rightValue) &&
rightValue.some((v) => !leftValue.includes(v))
!rightValue.some((v) => leftValue.includes(v))
);
case "isAccepted":
return leftValue === "accepted";

View File

@@ -42,14 +42,34 @@ export const safeUrlRefinement = (url: string, ctx: z.RefinementCtx): void => {
});
}
// Allow localhost for easy recall testing on self-hosted environments
if (!url.startsWith("https://") && !url.startsWith("http://localhost")) {
// Allow localhost for easy recall testing on self-hosted environments and mailto links
if (!url.startsWith("https://") && !url.startsWith("http://localhost") && !url.startsWith("mailto:")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "URL must start with https://",
message: "URL must start with https:// or mailto:",
});
}
// Skip further validation for mailto URLs as they have different structure
if (url.startsWith("mailto:")) {
try {
const parsed = new URL(url);
if (parsed.protocol !== "mailto:") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid mailto URL format",
});
return;
}
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid mailto URL format",
});
}
return;
}
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname;

View File

@@ -4,12 +4,9 @@ import { ZSurveyQuota } from "./quota";
import { ZSurvey } from "./surveys/types";
import { ZTag } from "./tags";
export const ZResponseDataValue = z.union([
z.string(),
z.number(),
z.array(z.string()),
z.record(z.string()),
]);
export const ZResponseDataValue = z
.union([z.string(), z.number(), z.array(z.string()), z.record(z.string())])
.optional();
export const ZResponseFilterCondition = z.enum([
"accepted",