Compare commits

..

7 Commits

Author SHA1 Message Date
Cursor Agent 1a6b612ba7 test: cover survey status list update 2026-05-18 10:35:46 +00:00
Cursor Agent 80d3d88532 refactor: scope survey status action to list 2026-05-18 10:26:11 +00:00
Cursor Agent 778a02d3b3 feat: add survey status menu action 2026-05-18 07:29:44 +00:00
Johannes c172e2a33c fix: use block terminology in conditional logic docs (#7942)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-18 06:57:46 +00:00
Harsh Bhat 9a5780d510 chore: A/B test reduce cog. load in question section (#7944)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-05-18 06:56:38 +00:00
Johannes e333d8ba02 docs: add custom CSS guide for website & app surveys (#8031)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-18 06:47:05 +00:00
Johannes 16463960ad fix: flag id for A/B test re onboarding step 1 (#8035) 2026-05-18 06:45:37 +00:00
43 changed files with 1005 additions and 475 deletions
@@ -25,7 +25,7 @@ const Page = async (props: ModePageProps) => {
}
const experimentVariant =
(await getPostHogFeatureFlag(session.user.id, "onboarding-mode-experiment")) || "control";
(await getPostHogFeatureFlag(session.user.id, "a-b_onboarding_skip-first-screen")) || "control";
if (experimentVariant === "remove-cx-and-surveys-mode") {
return redirect(`/organizations/${params.organizationId}/workspaces/new/channel`);
@@ -5,35 +5,29 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryOpenText } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
import { ResponseSampleModal } from "./ResponseSampleModal";
interface OpenTextSummaryProps {
elementSummary: TSurveyElementSummaryOpenText;
survey: TSurvey;
locale: TUserLocale;
isReadOnly: boolean;
}
export const OpenTextSummary = ({
elementSummary,
survey,
locale,
isReadOnly,
}: Readonly<OpenTextSummaryProps>) => {
export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSummaryProps) => {
const { t } = useTranslation();
const { workspace } = useWorkspace();
const [visibleResponses, setVisibleResponses] = useState(10);
const [selectedResponseId, setSelectedResponseId] = useState<string | null>(null);
const handleLoadMore = () => {
// Increase the number of visible responses by 10, not exceeding the total number of responses
setVisibleResponses((prevVisibleResponses) =>
Math.min(prevVisibleResponses + 10, elementSummary.samples.length)
);
@@ -54,31 +48,17 @@ export const OpenTextSummary = ({
<TableRow>
<TableHead className="w-1/4">{t("common.user")}</TableHead>
<TableHead className="w-2/4">{t("common.response")}</TableHead>
<TableHead className="w-1/6">{t("common.time")}</TableHead>
<TableHead className="w-1/6">{t("common.response_id")}</TableHead>
<TableHead className="w-1/4">{t("common.time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{elementSummary.samples.slice(0, visibleResponses).map((response) => (
<TableRow
key={response.id}
role="button"
tabIndex={0}
aria-label={t("workspace.surveys.summary.open_response_details")}
className="cursor-pointer hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
onClick={() => setSelectedResponseId(response.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedResponseId(response.id);
}
}}>
<TableRow key={response.id}>
<TableCell className="w-1/4">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/workspaces/${survey.workspaceId}/contacts/${response.contact.id}`}
onClick={(e) => e.stopPropagation()}>
href={`/workspaces/${workspace?.id}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
@@ -100,12 +80,9 @@ export const OpenTextSummary = ({
? renderHyperlinkedContent(response.value)
: response.value}
</TableCell>
<TableCell className="w-1/6">
<TableCell className="w-1/4">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</TableCell>
<TableCell className="w-1/6" onClick={(e) => e.stopPropagation()}>
<IdBadge id={response.id} />
</TableCell>
</TableRow>
))}
</TableBody>
@@ -119,14 +96,6 @@ export const OpenTextSummary = ({
)}
</div>
)}
<ResponseSampleModal
responseId={selectedResponseId}
onClose={() => setSelectedResponseId(null)}
survey={survey}
isReadOnly={isReadOnly}
locale={locale}
/>
</div>
);
};
@@ -1,144 +0,0 @@
"use client";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUserLocale } from "@formbricks/types/user";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import {
getResponseAction,
getTagsByWorkspaceIdAction,
} from "@/modules/analysis/components/SingleResponseCard/actions";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
interface ResponseSampleModalProps {
responseId: string | null;
onClose: () => void;
survey: TSurvey;
isReadOnly: boolean;
locale: TUserLocale;
}
export const ResponseSampleModal = ({
responseId,
onClose,
survey,
isReadOnly,
locale,
}: Readonly<ResponseSampleModalProps>) => {
const { t } = useTranslation();
const [response, setResponse] = useState<TResponseWithQuotas | null>(null);
const [tags, setTags] = useState<TTag[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Cache fetched data per response ID to avoid re-fetching on re-open
const cache = useRef<Map<string, { response: TResponseWithQuotas; tags: TTag[] }>>(new Map());
// Track the in-flight request so stale resolutions can be ignored when the user
// switches rows quickly.
const latestRequestId = useRef<string | null>(null);
useEffect(() => {
if (!responseId) return;
const cached = cache.current.get(responseId);
if (cached) {
setResponse(cached.response);
setTags(cached.tags);
setErrorMessage(null);
return;
}
latestRequestId.current = responseId;
setIsLoading(true);
setResponse(null);
setErrorMessage(null);
Promise.all([
getResponseAction({ responseId }),
getTagsByWorkspaceIdAction({ workspaceId: survey.workspaceId }),
])
.then(([responseResult, tagsResult]) => {
// Discard if a newer request has started or the modal has been closed.
if (latestRequestId.current !== responseId) return;
const responseError = getFormattedErrorMessage(responseResult);
const tagsError = getFormattedErrorMessage(tagsResult);
const fetchedResponse = responseResult?.data ?? null;
const fetchedTags = tagsResult?.data ?? [];
if (responseError || tagsError || !fetchedResponse) {
const message = responseError || tagsError || t("common.something_went_wrong");
toast.error(message);
setErrorMessage(message);
return;
}
const entry = { response: fetchedResponse, tags: fetchedTags };
cache.current.set(responseId, entry);
setResponse(entry.response);
setTags(entry.tags);
})
.catch(() => {
if (latestRequestId.current !== responseId) return;
const message = t("common.something_went_wrong");
toast.error(message);
setErrorMessage(message);
})
.finally(() => {
if (latestRequestId.current !== responseId) return;
setIsLoading(false);
});
}, [responseId, survey.workspaceId, t]);
const handleOpenChange = (open: boolean) => {
if (!open) {
// Drop any in-flight request so it can't commit after close.
latestRequestId.current = null;
setErrorMessage(null);
onClose();
}
};
return (
<Dialog open={!!responseId} onOpenChange={handleOpenChange}>
<DialogContent width="wide">
<VisuallyHidden asChild>
<DialogTitle>{t("common.response")}</DialogTitle>
</VisuallyHidden>
<VisuallyHidden asChild>
<DialogDescription>{t("common.response")}</DialogDescription>
</VisuallyHidden>
<DialogBody>
{isLoading ? (
<div className="py-12">
<LoadingSpinner />
</div>
) : errorMessage ? (
<div className="py-12 text-center text-sm text-slate-600">{errorMessage}</div>
) : response ? (
<SingleResponseCard
survey={survey}
response={response}
environmentTags={tags}
isReadOnly={isReadOnly}
locale={locale}
/>
) : null}
</DialogBody>
</DialogContent>
</Dialog>
);
};
@@ -41,16 +41,9 @@ interface SummaryListProps {
responseCount: number | null;
survey: TSurvey;
locale: TUserLocale;
isReadOnly: boolean;
}
export const SummaryList = ({
summary,
responseCount,
survey,
locale,
isReadOnly,
}: Readonly<SummaryListProps>) => {
export const SummaryList = ({ summary, responseCount, survey, locale }: SummaryListProps) => {
const { workspace } = useWorkspaceContext();
const { setSelectedFilter, selectedFilter } = useResponseFilter();
const { t } = useTranslation();
@@ -123,7 +116,6 @@ export const SummaryList = ({
elementSummary={elementSummary}
survey={survey}
locale={locale}
isReadOnly={isReadOnly}
/>
);
}
@@ -49,7 +49,6 @@ interface SummaryPageProps {
locale: TUserLocale;
initialSurveySummary?: TSurveySummary;
isQuotasAllowed: boolean;
isReadOnly: boolean;
}
export const SummaryPage = ({
@@ -58,8 +57,7 @@ export const SummaryPage = ({
locale,
initialSurveySummary,
isQuotasAllowed,
isReadOnly,
}: Readonly<SummaryPageProps>) => {
}: SummaryPageProps) => {
const { t } = useTranslation();
const searchParams = useSearchParams();
@@ -227,7 +225,6 @@ export const SummaryPage = ({
responseCount={surveySummary.meta.totalResponses}
survey={surveyMemoized}
locale={locale}
isReadOnly={isReadOnly}
/>
</>
);
@@ -22,7 +22,7 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const SurveyPage = async (props: Readonly<{ params: Promise<{ workspaceId: string; surveyId: string }> }>) => {
const SurveyPage = async (props: { params: Promise<{ workspaceId: string; surveyId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
@@ -88,7 +88,6 @@ const SurveyPage = async (props: Readonly<{ params: Promise<{ workspaceId: strin
locale={user.locale ?? DEFAULT_LOCALE}
initialSurveySummary={initialSurveySummary}
isQuotasAllowed={isQuotasAllowed}
isReadOnly={isReadOnly}
/>
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />
+2 -1
View File
@@ -2902,6 +2902,7 @@ checksums:
workspace/surveys/edit/hidden_field_used_in_recall: 15d959528c3e817dce95640173d5d6a8
workspace/surveys/edit/hidden_field_used_in_recall_ending_card: ea0d0b12ca1c9400690658cb1b537025
workspace/surveys/edit/hidden_field_used_in_recall_welcome: bb498b6ee69c6311a3977d454866b610
workspace/surveys/edit/hidden_fields_description: e9221cd00ae2944602c19ffbc82358a4
workspace/surveys/edit/hide_back_button: 91355864b3032c3f57689074e2173544
workspace/surveys/edit/hide_back_button_description: caaa30cf43c5611577933a1c9f44b9ee
workspace/surveys/edit/hide_block_settings: c24c3d3892c251792e297cdc036d2fde
@@ -3203,6 +3204,7 @@ checksums:
workspace/surveys/edit/variable_used_in_recall: 1979c231569117297d1a19972b349617
workspace/surveys/edit/variable_used_in_recall_ending_card: e6ab9a124985708dd77067c014b7c514
workspace/surveys/edit/variable_used_in_recall_welcome: 60b995389b488366d8f6f53df35b6d8d
workspace/surveys/edit/variables_description: 4a55faa279acc675228f54dccf63db6a
workspace/surveys/edit/verify_email_before_submission: c05d345dc35f2d33839e4cfd72d11eb2
workspace/surveys/edit/verify_email_before_submission_description: 434ab3ee6134367513b633a9d4f7d772
workspace/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84
@@ -3446,7 +3448,6 @@ checksums:
workspace/surveys/summary/no_identified_impressions: c3bc42e6feb9010ced905ded51c5afc4
workspace/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
workspace/surveys/summary/nps_promoters_tooltip: dea6a683c0c36189e325656d5a7596b8
workspace/surveys/summary/open_response_details: 0e5de115b5e605f68ea857cf8ef5533a
workspace/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
workspace/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
workspace/surveys/summary/promoters: 41fbb8d0439227661253a82fda39f521
-37
View File
@@ -216,43 +216,6 @@ export const getResponse = reactCache(async (responseId: string): Promise<TRespo
}
});
export const getResponseWithQuotas = reactCache(
async (responseId: string): Promise<TResponseWithQuotas | null> => {
validateInputs([responseId, ZId]);
try {
const responsePrisma = await prisma.response.findUnique({
where: {
id: responseId,
},
select: {
...responseSelection,
quotaLinks: {
where: { status: "screenedIn" },
include: { quota: { select: { id: true, name: true } } },
},
},
});
if (!responsePrisma) {
return null;
}
const { quotaLinks, ...rest } = responsePrisma;
return {
...mapResponsePrismaToResponse(rest),
quotas: quotaLinks.map((ql) => ql.quota),
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const getResponseSnapshotForPipeline = async (responseId: string): Promise<TResponse | null> => {
validateInputs([responseId, ZId]);
@@ -31,7 +31,6 @@ import {
getResponseBySingleUseId,
getResponseCountBySurveyId,
getResponseDownloadFile,
getResponseWithQuotas,
getResponsesByWorkspaceId,
responseSelection,
updateResponse,
@@ -171,70 +170,6 @@ describe("Tests for getResponse service", () => {
});
});
describe("Tests for getResponseWithQuotas service", () => {
describe("Happy Path", () => {
test("Returns the response with screened-in quotas", async () => {
prisma.response.findUnique.mockResolvedValue(mockResponseWithQuotas);
const result = await getResponseWithQuotas(mockResponseWithQuotas.id);
expect(result).toEqual({
...expectedResponseWithoutPerson,
quotas: mockResponseWithQuotas.quotaLinks.map(
(ql: { quota: { id: string; name: string } }) => ql.quota
),
});
});
test("Returns an empty quotas array when no quotaLinks are screened in", async () => {
prisma.response.findUnique.mockResolvedValue({ ...mockResponse, quotaLinks: [] } as any);
const result = await getResponseWithQuotas(mockResponse.id);
expect(result).toEqual({ ...expectedResponseWithoutPerson, quotas: [] });
});
test("Selects only screened-in quotaLinks", async () => {
prisma.response.findUnique.mockResolvedValue({ ...mockResponse, quotaLinks: [] } as any);
await getResponseWithQuotas(mockResponse.id);
const findUniqueCall = prisma.response.findUnique.mock.calls.at(-1)?.[0];
expect(findUniqueCall?.select?.quotaLinks).toEqual({
where: { status: "screenedIn" },
include: { quota: { select: { id: true, name: true } } },
});
});
});
describe("Sad Path", () => {
testInputValidation(getResponseWithQuotas, "123#");
test("Returns null when no response is found", async () => {
prisma.response.findUnique.mockResolvedValue(null);
const result = await getResponseWithQuotas(mockResponse.id);
expect(result).toBeNull();
});
test("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
prisma.response.findUnique.mockRejectedValue(errToThrow);
await expect(getResponseWithQuotas(mockResponse.id)).rejects.toThrow(DatabaseError);
});
test("Rethrows generic errors", async () => {
prisma.response.findUnique.mockRejectedValue(new Error("boom"));
await expect(getResponseWithQuotas(mockResponse.id)).rejects.toThrow("boom");
});
});
});
describe("Tests for getSurveySummary service", () => {
describe("Happy Path", () => {
test("Returns a summary of the survey responses", async () => {
+2 -1
View File
@@ -3021,6 +3021,7 @@
"hidden_field_used_in_recall": "Verborgenes Feld \"{hiddenField}\" wird in Frage {questionIndex} abgerufen.",
"hidden_field_used_in_recall_ending_card": "Verborgenes Feld \"{hiddenField}\" wird in der Abschlusskarte abgerufen",
"hidden_field_used_in_recall_welcome": "Verborgenes Feld \"{hiddenField}\" wird in der Willkommenskarte abgerufen.",
"hidden_fields_description": "Übergib versteckte Daten an deine Umfrage, ohne sie den Teilnehmern zu zeigen.",
"hide_back_button": "\"Zurück\"-Button ausblenden",
"hide_back_button_description": "Zeige den Zurück-Button in der Umfrage nicht an",
"hide_block_settings": "Block-Einstellungen ausblenden",
@@ -3326,6 +3327,7 @@
"variable_used_in_recall": "Die Variable \"{variable}\" wird in Frage {questionIndex} abgerufen.",
"variable_used_in_recall_ending_card": "Die Variable {variable} wird in der Abschlusskarte abgerufen",
"variable_used_in_recall_welcome": "Die Variable \"{variable}\" wird in der Willkommenskarte abgerufen.",
"variables_description": "Definiere und berechne Werte während deiner Umfrage.",
"verify_email_before_submission": "E-Mail vor dem Absenden verifizieren",
"verify_email_before_submission_description": "Lass nur Personen mit einer echten E-Mail-Adresse antworten.",
"visibility_and_recontact": "Sichtbarkeit & erneute Kontaktaufnahme",
@@ -3597,7 +3599,6 @@
"no_identified_impressions": "Keine Impressionen von identifizierten Kontakten",
"no_responses_found": "Keine Antworten gefunden",
"nps_promoters_tooltip": "{percentage}% der Befragten haben eine Bewertung von 9 oder 10 gegeben (NPS-Promotoren).",
"open_response_details": "Details zu offenen Antworten",
"other_values_found": "Andere Werte gefunden",
"overall": "Gesamt",
"promoters": "Promotoren",
+2 -1
View File
@@ -3021,6 +3021,7 @@
"hidden_field_used_in_recall": "Hidden field “{hiddenField}” is being recalled in question {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Hidden field “{hiddenField}” is being recalled in Ending Card",
"hidden_field_used_in_recall_welcome": "Hidden field “{hiddenField}” is being recalled in Welcome card.",
"hidden_fields_description": "Pass hidden data into your survey without showing it to respondents.",
"hide_back_button": "Hide “Back” button",
"hide_back_button_description": "Do not display the back button in the survey",
"hide_block_settings": "Hide Block settings",
@@ -3326,6 +3327,7 @@
"variable_used_in_recall": "Variable “{variable}” is being recalled in question {questionIndex}.",
"variable_used_in_recall_ending_card": "Variable {variable} is being recalled in Ending Card",
"variable_used_in_recall_welcome": "Variable “{variable}” is being recalled in Welcome Card.",
"variables_description": "Define and compute values throughout your survey.",
"verify_email_before_submission": "Verify email before submission",
"verify_email_before_submission_description": "Only let people with a real email respond.",
"visibility_and_recontact": "Visibility & Recontact",
@@ -3597,7 +3599,6 @@
"no_identified_impressions": "No impressions from identified contacts",
"no_responses_found": "No responses found",
"nps_promoters_tooltip": "{percentage}% of respondents gave a rating of 9 or 10 (NPS promoters).",
"open_response_details": "Open response details",
"other_values_found": "Other values found",
"overall": "Overall",
"promoters": "Promoters",
+2 -1
View File
@@ -3021,6 +3021,7 @@
"hidden_field_used_in_recall": "El campo oculto \"{hiddenField}\" se está recordando en la pregunta {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "El campo oculto \"{hiddenField}\" se está recordando en la tarjeta final.",
"hidden_field_used_in_recall_welcome": "El campo oculto \"{hiddenField}\" se está recordando en la tarjeta de bienvenida.",
"hidden_fields_description": "Pasa datos ocultos a tu encuesta sin mostrárselos a los encuestados.",
"hide_back_button": "Ocultar botón 'Atrás'",
"hide_back_button_description": "No mostrar el botón de retroceso en la encuesta",
"hide_block_settings": "Ocultar ajustes del bloque",
@@ -3326,6 +3327,7 @@
"variable_used_in_recall": "La variable \"{variable}\" se está recuperando en la pregunta {questionIndex}.",
"variable_used_in_recall_ending_card": "La variable {variable} se está recuperando en la tarjeta final",
"variable_used_in_recall_welcome": "La variable \"{variable}\" se está recuperando en la tarjeta de bienvenida.",
"variables_description": "Define y calcula valores a lo largo de tu encuesta.",
"verify_email_before_submission": "Verificar correo electrónico antes del envío",
"verify_email_before_submission_description": "Solo permite responder a personas con un correo electrónico real.",
"visibility_and_recontact": "Visibilidad y recontacto",
@@ -3597,7 +3599,6 @@
"no_identified_impressions": "No hay impresiones de contactos identificados",
"no_responses_found": "No se han encontrado respuestas",
"nps_promoters_tooltip": "El {percentage}% de los encuestados dieron una puntuación de 9 o 10 (promotores NPS).",
"open_response_details": "Detalles de respuesta abierta",
"other_values_found": "Otros valores encontrados",
"overall": "General",
"promoters": "Promotores",
+2 -1
View File
@@ -3021,6 +3021,7 @@
"hidden_field_used_in_recall": "Le champ caché \"{hiddenField}\" est rappelé dans la question {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de fin.",
"hidden_field_used_in_recall_welcome": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de bienvenue.",
"hidden_fields_description": "Transmets des données masquées dans ton questionnaire sans les montrer aux répondants.",
"hide_back_button": "Masquer le bouton 'Retour'",
"hide_back_button_description": "Ne pas afficher le bouton retour dans l'enquête",
"hide_block_settings": "Masquer les paramètres du bloc",
@@ -3326,6 +3327,7 @@
"variable_used_in_recall": "La variable \"{variable}\" est rappelée dans la question {questionIndex}.",
"variable_used_in_recall_ending_card": "La variable {variable} est rappelée dans la carte de fin.",
"variable_used_in_recall_welcome": "La variable \"{variable}\" est rappelée dans la carte de bienvenue.",
"variables_description": "Définis et calcule des valeurs tout au long de ton questionnaire.",
"verify_email_before_submission": "Vérifiez l'email avant la soumission",
"verify_email_before_submission_description": "Ne laissez répondre que les personnes ayant une véritable adresse e-mail.",
"visibility_and_recontact": "Visibilité et recontact",
@@ -3597,7 +3599,6 @@
"no_identified_impressions": "Aucune impression des contacts identifiés",
"no_responses_found": "Aucune réponse trouvée",
"nps_promoters_tooltip": "{percentage} % des répondants ont donné une note de 9 ou 10 (promoteurs NPS).",
"open_response_details": "Détails des réponses ouvertes",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",
"promoters": "Promoteurs",
+2 -1
View File
@@ -3021,6 +3021,7 @@
"hidden_field_used_in_recall": "A(z) „{hiddenField}” rejtett mező visszahívásra kerül a(z) {questionIndex}. kérdésben.",
"hidden_field_used_in_recall_ending_card": "A(z) „{hiddenField}” rejtett mező visszahívásra kerül a befejező kártyában",
"hidden_field_used_in_recall_welcome": "A(z) „{hiddenField}” rejtett mező visszahívásra kerül az üdvözlő kártyában.",
"hidden_fields_description": "Rejtett adatokat továbbíthat a felmérésbe anélkül, hogy azokat a válaszadók látnák.",
"hide_back_button": "A „Vissza” gomb elrejtése",
"hide_back_button_description": "Ne jelenjen meg a vissza gomb a kérdőívben",
"hide_block_settings": "Blokkbeállítások elrejtése",
@@ -3326,6 +3327,7 @@
"variable_used_in_recall": "A(z) „{variable}” változó visszahívásra kerül a(z) {questionIndex}. kérdésben.",
"variable_used_in_recall_ending_card": "A(z) {variable} változó visszahívásra kerül a befejező kártyában",
"variable_used_in_recall_welcome": "A(z) „{variable}” változó visszahívásra kerül az üdvözlő kártyában.",
"variables_description": "Értékeket határozhat meg és számíthat ki a felmérés során.",
"verify_email_before_submission": "E-mail-cím ellenőrzése a beküldés előtt",
"verify_email_before_submission_description": "Csak valódi e-mail-címmel rendelkező személyek válaszolhassanak.",
"visibility_and_recontact": "Láthatóság és újbóli kapcsolatfelvétel",
@@ -3597,7 +3599,6 @@
"no_identified_impressions": "Nincsenek azonosított partnerektől származó megtekintések",
"no_responses_found": "Nem találhatók válaszok",
"nps_promoters_tooltip": "A válaszadók {percentage}%-a 9-es vagy 10-es értékelést adott (NPS promoters).",
"open_response_details": "Nyitott válasz részletei",
"other_values_found": "Más értékek találhatók",
"overall": "Összesen",
"promoters": "Népszerűsítők",
+2 -1
View File
@@ -3021,6 +3021,7 @@
"hidden_field_used_in_recall": "隠し フィールド \"{hiddenField}\" が 質問 {questionIndex} で 呼び出され て います 。",
"hidden_field_used_in_recall_ending_card": "隠し フィールド \"{hiddenField}\" が エンディング カード で 呼び出され て います。",
"hidden_field_used_in_recall_welcome": "隠し フィールド \"{hiddenField}\" が ウェルカム カード で 呼び出され て います。",
"hidden_fields_description": "回答者に表示せずに、非表示データをアンケートに渡すことができます。",
"hide_back_button": "「戻る」ボタンを非表示",
"hide_back_button_description": "フォームに「戻る」ボタンを表示しない",
"hide_block_settings": "ブロック設定を非表示",
@@ -3326,6 +3327,7 @@
"variable_used_in_recall": "変数 \"{variable}\" が 質問 {questionIndex} で 呼び出され て います 。",
"variable_used_in_recall_ending_card": "変数 {variable} が エンディング カード で 呼び出され て います。",
"variable_used_in_recall_welcome": "変数 \"{variable}\" が ウェルカム カード で 呼び出され て います。",
"variables_description": "アンケート全体で値を定義し、計算できます。",
"verify_email_before_submission": "送信前にメールアドレスを認証",
"verify_email_before_submission_description": "有効なメールアドレスを持つ人のみが回答できるようにする",
"visibility_and_recontact": "表示と再接触",
@@ -3597,7 +3599,6 @@
"no_identified_impressions": "識別済みコンタクトからのインプレッションはありません",
"no_responses_found": "回答が見つかりません",
"nps_promoters_tooltip": "回答者の{percentage}%が9または10の評価をしました(NPSプロモーター)。",
"open_response_details": "自由回答の詳細",
"other_values_found": "他の値が見つかりました",
"overall": "全体",
"promoters": "推奨者",
+2 -1
View File
@@ -3021,6 +3021,7 @@
"hidden_field_used_in_recall": "Verborgen veld \"{hiddenField}\" wordt opgeroepen in vraag {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Verborgen veld \"{hiddenField}\" wordt opgeroepen in de eindkaart",
"hidden_field_used_in_recall_welcome": "Verborgen veld \"{hiddenField}\" wordt opgeroepen in de welkomstkaart.",
"hidden_fields_description": "Geef verborgen gegevens door aan je enquête zonder dat respondenten het zien.",
"hide_back_button": "Knop 'Terug' verbergen",
"hide_back_button_description": "Geef de terugknop niet weer in de enquête",
"hide_block_settings": "Blokinstellingen verbergen",
@@ -3326,6 +3327,7 @@
"variable_used_in_recall": "Variabele \"{variable}\" wordt opgeroepen in vraag {questionIndex}.",
"variable_used_in_recall_ending_card": "Variabele {variable} wordt opgeroepen in de eindkaart",
"variable_used_in_recall_welcome": "Variabele \"{variable}\" wordt opgeroepen in de welkomstkaart.",
"variables_description": "Definieer en bereken waarden tijdens je enquête.",
"verify_email_before_submission": "Verifieer uw e-mailadres voordat u het verzendt",
"verify_email_before_submission_description": "Laat alleen mensen met een echte e-mail reageren.",
"visibility_and_recontact": "Zichtbaarheid & opnieuw contact",
@@ -3597,7 +3599,6 @@
"no_identified_impressions": "Geen weergaven van geïdentificeerde contacten",
"no_responses_found": "Geen reacties gevonden",
"nps_promoters_tooltip": "{percentage}% van de respondenten gaf een beoordeling van 9 of 10 (NPS promoters).",
"open_response_details": "Details open antwoorden",
"other_values_found": "Andere waarden gevonden",
"overall": "Algemeen",
"promoters": "Promoters",
+2 -1
View File
@@ -3021,6 +3021,7 @@
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está sendo recordado na pergunta {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Encerramento.",
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Boas-Vindas.",
"hidden_fields_description": "Passe dados ocultos para sua pesquisa sem mostrá-los aos respondentes.",
"hide_back_button": "Ocultar botão 'Voltar'",
"hide_back_button_description": "Não exibir o botão de voltar na pesquisa",
"hide_block_settings": "Ocultar configurações do bloco",
@@ -3326,6 +3327,7 @@
"variable_used_in_recall": "Variável \"{variable}\" está sendo recordada na pergunta {questionIndex}.",
"variable_used_in_recall_ending_card": "Variável {variable} está sendo recordada no card de Encerramento",
"variable_used_in_recall_welcome": "Variável \"{variable}\" está sendo recordada no Card de Boas-Vindas.",
"variables_description": "Defina e calcule valores ao longo da sua pesquisa.",
"verify_email_before_submission": "Verifique o e-mail antes de enviar",
"verify_email_before_submission_description": "Deixe só quem tem um email real responder.",
"visibility_and_recontact": "Visibilidade e recontato",
@@ -3597,7 +3599,6 @@
"no_identified_impressions": "Nenhuma impressão de contatos identificados",
"no_responses_found": "Nenhuma resposta encontrada",
"nps_promoters_tooltip": "{percentage}% dos entrevistados deram uma nota de 9 ou 10 (promotores NPS).",
"open_response_details": "Detalhes das respostas abertas",
"other_values_found": "Outros valores encontrados",
"overall": "No geral",
"promoters": "Promotores",
+2 -1
View File
@@ -3021,6 +3021,7 @@
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está a ser recordado na pergunta {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está a ser recordado no Cartão de Conclusão",
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está a ser recordado no cartão de boas-vindas.",
"hidden_fields_description": "Passa dados ocultos para o teu inquérito sem os mostrar aos inquiridos.",
"hide_back_button": "Ocultar botão 'Retroceder'",
"hide_back_button_description": "Não mostrar o botão de retroceder no inquérito",
"hide_block_settings": "Ocultar definições do bloco",
@@ -3326,6 +3327,7 @@
"variable_used_in_recall": "Variável \"{variable}\" está a ser recordada na pergunta {questionIndex}.",
"variable_used_in_recall_ending_card": "Variável {variable} está a ser recordada no Cartão de Conclusão",
"variable_used_in_recall_welcome": "Variável \"{variable}\" está a ser recordada no cartão de boas-vindas.",
"variables_description": "Define e calcula valores ao longo do teu inquérito.",
"verify_email_before_submission": "Verificar email antes da submissão",
"verify_email_before_submission_description": "Permitir apenas que pessoas com um email real respondam.",
"visibility_and_recontact": "Visibilidade e Recontacto",
@@ -3597,7 +3599,6 @@
"no_identified_impressions": "Sem impressões de contactos identificados",
"no_responses_found": "Nenhuma resposta encontrada",
"nps_promoters_tooltip": "{percentage}% dos inquiridos deram uma classificação de 9 ou 10 (promotores NPS).",
"open_response_details": "Detalhes de respostas abertas",
"other_values_found": "Outros valores encontrados",
"overall": "Geral",
"promoters": "Promotores",
+2 -1
View File
@@ -3021,6 +3021,7 @@
"hidden_field_used_in_recall": "Câmpul ascuns \"{hiddenField}\" este reamintit în întrebarea {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Câmpul ascuns \"{hiddenField}\" este reamintit în Cardul de Încheiere.",
"hidden_field_used_in_recall_welcome": "Câmpul ascuns \"{hiddenField}\" este reamintit în cardul de bun venit.",
"hidden_fields_description": "Transmite date ascunse în sondajul tău fără a le afișa respondenților.",
"hide_back_button": "Ascunde butonul 'Înapoi'",
"hide_back_button_description": "Nu afișa butonul Înapoi în sondaj",
"hide_block_settings": "Ascunde setările blocului",
@@ -3326,6 +3327,7 @@
"variable_used_in_recall": "Variabila \"{variable}\" este reamintită în întrebarea {questionIndex}.",
"variable_used_in_recall_ending_card": "Variabila {variable} este reamintită în Cardul de Încheiere.",
"variable_used_in_recall_welcome": "Variabila \"{variable}\" este reamintită în cardul de bun venit.",
"variables_description": "Definește și calculează valori pe parcursul sondajului tău.",
"verify_email_before_submission": "Verifică emailul înainte de trimitere",
"verify_email_before_submission_description": "Permite doar persoanelor cu un email real să răspundă.",
"visibility_and_recontact": "Vizibilitate și recontactare",
@@ -3597,7 +3599,6 @@
"no_identified_impressions": "Nicio impresie de la contactele identificate",
"no_responses_found": "Nu s-au găsit răspunsuri",
"nps_promoters_tooltip": "{percentage}% dintre respondenți au acordat o evaluare de 9 sau 10 (promotori NPS).",
"open_response_details": "Detalii răspunsuri deschise",
"other_values_found": "Alte valori găsite",
"overall": "General",
"promoters": "Promotori",
+2 -1
View File
@@ -3021,6 +3021,7 @@
"hidden_field_used_in_recall": "Скрытое поле «{hiddenField}» используется в вопросе {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Скрытое поле «{hiddenField}» используется в финальной карточке",
"hidden_field_used_in_recall_welcome": "Скрытое поле «{hiddenField}» используется в приветственной карточке.",
"hidden_fields_description": "Передавайте скрытые данные в опрос, не показывая их респондентам.",
"hide_back_button": "Скрыть кнопку «Назад»",
"hide_back_button_description": "Не отображать кнопку «Назад» в опросе",
"hide_block_settings": "Скрыть настройки блока",
@@ -3326,6 +3327,7 @@
"variable_used_in_recall": "Переменная «{variable}» используется в вопросе {questionIndex}.",
"variable_used_in_recall_ending_card": "Переменная {variable} используется в финальной карточке",
"variable_used_in_recall_welcome": "Переменная «{variable}» используется в приветственной карточке.",
"variables_description": "Определяйте и вычисляйте значения на протяжении всего опроса.",
"verify_email_before_submission": "Проверять email перед отправкой",
"verify_email_before_submission_description": "Разрешить отвечать только пользователям с реальным email.",
"visibility_and_recontact": "Видимость и повторный контакт",
@@ -3597,7 +3599,6 @@
"no_identified_impressions": "Нет показов от идентифицированных контактов",
"no_responses_found": "Ответы не найдены",
"nps_promoters_tooltip": "{percentage}% респондентов дали оценку 9 или 10 (промоутеры NPS).",
"open_response_details": "Детали открытых ответов",
"other_values_found": "Найдены другие значения",
"overall": "В целом",
"promoters": "Сторонники",
+2 -1
View File
@@ -3021,6 +3021,7 @@
"hidden_field_used_in_recall": "Dolt fält \"{hiddenField}\" återkallas i fråga {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Dolt fält \"{hiddenField}\" återkallas i avslutningskortet",
"hidden_field_used_in_recall_welcome": "Dolt fält \"{hiddenField}\" återkallas i välkomstkortet.",
"hidden_fields_description": "Skicka dold data till din enkät utan att visa den för respondenter.",
"hide_back_button": "Dölj 'Tillbaka'-knapp",
"hide_back_button_description": "Visa inte tillbakaknappen i enkäten",
"hide_block_settings": "Dölj blockinställningar",
@@ -3326,6 +3327,7 @@
"variable_used_in_recall": "Variabel \"{variable}\" återkallas i fråga {questionIndex}.",
"variable_used_in_recall_ending_card": "Variabel {variable} återkallas i avslutningskortet",
"variable_used_in_recall_welcome": "Variabel \"{variable}\" återkallas i välkomstkortet.",
"variables_description": "Definiera och beräkna värden genom hela din enkät.",
"verify_email_before_submission": "Verifiera e-post före inskickning",
"verify_email_before_submission_description": "Låt endast personer med en riktig e-post svara.",
"visibility_and_recontact": "Synlighet och återkontakt",
@@ -3597,7 +3599,6 @@
"no_identified_impressions": "Inga visningar från identifierade kontakter",
"no_responses_found": "Inga svar hittades",
"nps_promoters_tooltip": "{percentage}% av respondenterna gav ett betyg på 9 eller 10 (NPS-ambassadörer).",
"open_response_details": "Detaljer för öppna svar",
"other_values_found": "Andra värden hittades",
"overall": "Övergripande",
"promoters": "Ambassadörer",
+2 -1
View File
@@ -3021,6 +3021,7 @@
"hidden_field_used_in_recall": "Gizli alan \"{hiddenField}\" soru {questionIndex}'te hatırlatılıyor.",
"hidden_field_used_in_recall_ending_card": "Gizli alan \"{hiddenField}\" Bitiş Kartında hatırlatılıyor",
"hidden_field_used_in_recall_welcome": "Gizli alan \"{hiddenField}\" Hoş Geldiniz kartında hatırlatılıyor.",
"hidden_fields_description": "Gizli verileri yanıtlayanlara göstermeden anketine aktar.",
"hide_back_button": "\"Geri\" düğmesini gizle",
"hide_back_button_description": "Ankette geri düğmesini gösterme",
"hide_block_settings": "Blok ayarlarını gizle",
@@ -3326,6 +3327,7 @@
"variable_used_in_recall": "“{variable}” değişkeni, {questionIndex}. soruda geri çağrılıyor.",
"variable_used_in_recall_ending_card": "{variable} değişkeni Bitiş Kartı'nda geri çağrılıyor.",
"variable_used_in_recall_welcome": "“{variable}” değişkeni Hoş Geldin Kartı'nda geri çağrılıyor.",
"variables_description": "Anketin boyunca değerleri tanımla ve hesapla.",
"verify_email_before_submission": "Göndermeden önce e-posta doğrula",
"verify_email_before_submission_description": "Sadece gerçek bir e-postaya sahip kişilerin yanıt vermesine izin ver.",
"visibility_and_recontact": "Görünürlük & Yeniden İletişim",
@@ -3597,7 +3599,6 @@
"no_identified_impressions": "Tanımlanmış kişilerden gösterim yok",
"no_responses_found": "Yanıt bulunamadı",
"nps_promoters_tooltip": "Yanıt verenlerin %{percentage}'si 9 veya 10 puan verdi (NPS tavsiye edenler).",
"open_response_details": "Açık yanıt detayları",
"other_values_found": "Diğer değerler bulundu",
"overall": "Genel",
"promoters": "Tavsiye edenler",
+2 -1
View File
@@ -3021,6 +3021,7 @@
"hidden_field_used_in_recall": "隐藏 字段 \"{hiddenField}\" 正在召回于问题 {questionIndex}。",
"hidden_field_used_in_recall_ending_card": "隐藏 字段 \"{hiddenField}\" 正在召回于结束 卡",
"hidden_field_used_in_recall_welcome": "隐藏 字段 \"{hiddenField}\" 正在召回于欢迎 卡 。",
"hidden_fields_description": "将隐藏数据传递到您的调查中,而不向受访者显示。",
"hide_back_button": "隐藏 \"返回\" 按钮",
"hide_back_button_description": "不 显示 调查 中 的 返回 按钮",
"hide_block_settings": "隐藏区块设置",
@@ -3326,6 +3327,7 @@
"variable_used_in_recall": "变量 \"{variable}\" 正在召回于问题 {questionIndex}。",
"variable_used_in_recall_ending_card": "变量 {variable} 正在召回于结束 卡片",
"variable_used_in_recall_welcome": "变量 \"{variable}\" 正在召回于欢迎 卡 。",
"variables_description": "在整个调查过程中定义和计算值。",
"verify_email_before_submission": "提交 之前 验证电子邮件",
"verify_email_before_submission_description": "仅允许 拥有 有效 电子邮件 的 人 回应。",
"visibility_and_recontact": "可见性与重新联系",
@@ -3597,7 +3599,6 @@
"no_identified_impressions": "没有已识别联系人的展示次数",
"no_responses_found": "未找到响应",
"nps_promoters_tooltip": "{percentage}% 的受访者给出了 9 或 10 分的评价(NPS 推荐者)。",
"open_response_details": "开放式回答详情",
"other_values_found": "找到其他值",
"overall": "整体",
"promoters": "推荐者",
+2 -1
View File
@@ -3021,6 +3021,7 @@
"hidden_field_used_in_recall": "隱藏欄位 \"{hiddenField}\" 於問題 {questionIndex} 中被召回。",
"hidden_field_used_in_recall_ending_card": "隱藏欄位 \"{hiddenField}\" 於結束卡中被召回。",
"hidden_field_used_in_recall_welcome": "隱藏欄位 \"{hiddenField}\" 於歡迎卡中被召回。",
"hidden_fields_description": "將隱藏資料傳入你的問卷中,而不會顯示給受訪者。",
"hide_back_button": "隱藏「Back」按鈕",
"hide_back_button_description": "不要在問卷中顯示返回按鈕",
"hide_block_settings": "隱藏區塊設定",
@@ -3326,6 +3327,7 @@
"variable_used_in_recall": "變數 \"{variable}\" 於問題 {questionIndex} 中被召回。",
"variable_used_in_recall_ending_card": "變數 {variable} 於 結束 卡 中被召回。",
"variable_used_in_recall_welcome": "變數 \"{variable}\" 於 歡迎 Card 中被召回。",
"variables_description": "在整個問卷中定義和計算數值。",
"verify_email_before_submission": "提交前驗證電子郵件",
"verify_email_before_submission_description": "僅允許擁有真實電子郵件的人員回應。",
"visibility_and_recontact": "可見性與重新聯絡",
@@ -3597,7 +3599,6 @@
"no_identified_impressions": "沒有來自已識別聯絡人的曝光次數",
"no_responses_found": "找不到回應",
"nps_promoters_tooltip": "{percentage}% 的受訪者給予 9 或 10 分評價(NPS 推薦者)。",
"open_response_details": "開放式回覆詳情",
"other_values_found": "找到其他值",
"overall": "整體",
"promoters": "推廣者",
@@ -3,8 +3,8 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { deleteResponse, getResponseWithQuotas } from "@/lib/response/service";
import { createTag, getTagsByWorkspaceId } from "@/lib/tag/service";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { createTag } from "@/lib/tag/service";
import { addTagToRespone, deleteTagOnResponse } from "@/lib/tagOnResponse/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -173,32 +173,6 @@ export const deleteResponseAction = authenticatedActionClient.inputSchema(ZDelet
})
);
const ZGetTagsByWorkspaceIdAction = z.object({
workspaceId: ZId,
});
export const getTagsByWorkspaceIdAction = authenticatedActionClient
.inputSchema(ZGetTagsByWorkspaceIdAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "workspaceTeam",
minPermission: "read",
workspaceId: parsedInput.workspaceId,
},
],
});
return await getTagsByWorkspaceId(parsedInput.workspaceId);
});
const ZGetResponseAction = z.object({
responseId: ZId,
});
@@ -222,5 +196,5 @@ export const getResponseAction = authenticatedActionClient
],
});
return await getResponseWithQuotas(parsedInput.responseId);
return await getResponse(parsedInput.responseId);
});
@@ -72,6 +72,7 @@ interface ElementsViewProps {
isStorageConfigured: boolean;
quotas: TSurveyQuota[];
isExternalUrlsAllowed: boolean;
moveHiddenFieldsToSettingsTab?: boolean;
}
export const ElementsView = ({
@@ -91,6 +92,7 @@ export const ElementsView = ({
isStorageConfigured = true,
quotas,
isExternalUrlsAllowed,
moveHiddenFieldsToSettingsTab = false,
}: ElementsViewProps) => {
const { t } = useTranslation();
const [logicDeletionWarning, setLogicDeletionWarning] = React.useState<{
@@ -919,23 +921,25 @@ export const ElementsView = ({
{!isCxMode && (
<>
<AddEndingCardButton localSurvey={localSurvey} addEndingCard={addEndingCard} />
<hr />
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setActiveElementId={setActiveElementId}
activeElementId={activeElementId}
quotas={quotas}
/>
<SurveyVariablesCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeElementId={activeElementId}
setActiveElementId={setActiveElementId}
quotas={quotas}
/>
{!moveHiddenFieldsToSettingsTab && (
<>
<hr />
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setActiveElementId={setActiveElementId}
activeElementId={activeElementId}
quotas={quotas}
/>
<SurveyVariablesCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeElementId={activeElementId}
setActiveElementId={setActiveElementId}
quotas={quotas}
/>
</>
)}
</>
)}
</div>
@@ -2,7 +2,7 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as Collapsible from "@radix-ui/react-collapsible";
import { EyeOff } from "lucide-react";
import { CheckIcon, EyeOff } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -25,6 +25,7 @@ interface HiddenFieldsCardProps {
activeElementId: string | null;
setActiveElementId: (elementId: string | null) => void;
quotas: TSurveyQuota[];
inSettings?: boolean;
}
export const HiddenFieldsCard = ({
@@ -33,6 +34,7 @@ export const HiddenFieldsCard = ({
setActiveElementId,
setLocalSurvey,
quotas,
inSettings = false,
}: HiddenFieldsCardProps) => {
const open = activeElementId == "hidden";
const [hiddenField, setHiddenField] = useState<string>("");
@@ -149,6 +151,105 @@ export const HiddenFieldsCard = ({
// Auto Animate
const [parent] = useAutoAnimate();
const content = (
<Collapsible.CollapsibleContent
className={inSettings ? "flex flex-col" : `flex flex-col px-4 ${open && "pb-6"}`}
ref={parent}>
{inSettings && <hr className="py-1 text-slate-600" />}
<div className={cn("flex flex-wrap gap-2", inSettings ? "p-3" : "")} ref={parent}>
{localSurvey.hiddenFields?.fieldIds && localSurvey.hiddenFields?.fieldIds?.length > 0 ? (
localSurvey.hiddenFields?.fieldIds?.map((fieldId) => {
return (
<Tag
key={fieldId}
onDelete={(fieldId) => handleDeleteHiddenField(fieldId)}
tagId={fieldId}
tagName={fieldId}
/>
);
})
) : (
<p className="mt-2 text-sm italic text-slate-500">
{t("workspace.surveys.edit.no_hidden_fields_yet_add_first_one_below")}
</p>
)}
</div>
<form
className={inSettings ? "mt-5 p-3 pt-0" : "mt-5"}
onSubmit={(e) => {
e.preventDefault();
const existingElementIds = elements.map((element) => element.id);
const existingEndingCardIds = localSurvey.endings.map((ending) => ending.id);
const existingHiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
const existingVariableNames = localSurvey.variables.map((v) => v.name);
const validateIdError = validateId(
hiddenField,
existingElementIds,
existingEndingCardIds,
existingHiddenFieldIds,
existingVariableNames
);
if (validateIdError) {
toast.error(getValidateIdErrorMessage(validateIdError, "hiddenField", t));
return;
}
updateSurvey({
fieldIds: [...(localSurvey.hiddenFields?.fieldIds || []), hiddenField],
enabled: true,
});
toast.success(t("workspace.surveys.edit.hidden_field_added_successfully"));
setHiddenField("");
}}>
<Label htmlFor="hiddenField">{t("common.hidden_field")}</Label>
<div className="mt-2 flex items-center gap-2">
<Input
autoFocus
id="hiddenField"
name="hiddenField"
value={hiddenField}
onChange={(e) => setHiddenField(e.target.value.trim())}
placeholder={t("workspace.surveys.edit.type_field_id") + "..."}
/>
<Button variant="secondary" type="submit" className="h-10 whitespace-nowrap">
{t("workspace.surveys.edit.add_hidden_field_id")}
</Button>
</div>
</form>
</Collapsible.CollapsibleContent>
);
if (inSettings) {
return (
<Collapsible.Root
open={open}
onOpenChange={setOpen}
className={cn(
open ? "" : "hover:bg-slate-50",
"w-full space-y-2 rounded-lg border border-slate-300 bg-white"
)}>
<Collapsible.CollapsibleTrigger asChild className="h-full w-full cursor-pointer">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
/>
</div>
<div>
<p className="font-semibold text-slate-800">{t("common.hidden_fields")}</p>
<p className="mt-1 text-sm text-slate-500">
{t("workspace.surveys.edit.hidden_fields_description")}
</p>
</div>
</div>
</Collapsible.CollapsibleTrigger>
{content}
</Collapsible.Root>
);
}
return (
<div className={cn(open ? "shadow-lg" : "shadow-md", "group z-10 flex flex-row rounded-lg bg-white")}>
<div
@@ -173,69 +274,7 @@ export const HiddenFieldsCard = ({
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-6"}`} ref={parent}>
<div className="flex flex-wrap gap-2" ref={parent}>
{localSurvey.hiddenFields?.fieldIds && localSurvey.hiddenFields?.fieldIds?.length > 0 ? (
localSurvey.hiddenFields?.fieldIds?.map((fieldId) => {
return (
<Tag
key={fieldId}
onDelete={(fieldId) => handleDeleteHiddenField(fieldId)}
tagId={fieldId}
tagName={fieldId}
/>
);
})
) : (
<p className="mt-2 text-sm italic text-slate-500">
{t("workspace.surveys.edit.no_hidden_fields_yet_add_first_one_below")}
</p>
)}
</div>
<form
className="mt-5"
onSubmit={(e) => {
e.preventDefault();
const existingElementIds = elements.map((element) => element.id);
const existingEndingCardIds = localSurvey.endings.map((ending) => ending.id);
const existingHiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
const existingVariableNames = localSurvey.variables.map((v) => v.name);
const validateIdError = validateId(
hiddenField,
existingElementIds,
existingEndingCardIds,
existingHiddenFieldIds,
existingVariableNames
);
if (validateIdError) {
toast.error(getValidateIdErrorMessage(validateIdError, "hiddenField", t));
return;
}
updateSurvey({
fieldIds: [...(localSurvey.hiddenFields?.fieldIds || []), hiddenField],
enabled: true,
});
toast.success(t("workspace.surveys.edit.hidden_field_added_successfully"));
setHiddenField("");
}}>
<Label htmlFor="hiddenField">{t("common.hidden_field")}</Label>
<div className="mt-2 flex items-center gap-2">
<Input
autoFocus
id="hiddenField"
name="hiddenField"
value={hiddenField}
onChange={(e) => setHiddenField(e.target.value.trim())}
placeholder={t("workspace.surveys.edit.type_field_id") + "..."}
/>
<Button variant="secondary" type="submit" className="h-10 whitespace-nowrap">
{t("workspace.surveys.edit.add_hidden_field_id")}
</Button>
</div>
</form>
</Collapsible.CollapsibleContent>
{content}
</Collapsible.Root>
</div>
);
@@ -8,10 +8,12 @@ import { TUserLocale } from "@formbricks/types/user";
import { TargetingCard } from "@/modules/ee/contacts/segments/components/targeting-card";
import { QuotasCard } from "@/modules/ee/quotas/components/quotas-card";
import { TTeamPermission } from "@/modules/ee/teams/workspace-teams/types/team";
import { HiddenFieldsCard } from "@/modules/survey/editor/components/hidden-fields-card";
import { HowToSendCard } from "@/modules/survey/editor/components/how-to-send-card";
import { RecontactOptionsCard } from "@/modules/survey/editor/components/recontact-options-card";
import { ResponseOptionsCard } from "@/modules/survey/editor/components/response-options-card";
import { SurveyPlacementCard } from "@/modules/survey/editor/components/survey-placement-card";
import { SurveyVariablesCard } from "@/modules/survey/editor/components/survey-variables-card";
import { TargetingLockedCard } from "@/modules/survey/editor/components/targeting-locked-card";
import { WhenToSendCard } from "@/modules/survey/editor/components/when-to-send-card";
@@ -32,6 +34,9 @@ interface SettingsViewProps {
locale: TUserLocale;
appSetupCompleted: boolean;
enterpriseLicenseRequestFormUrl: string;
moveHiddenFieldsToSettingsTab?: boolean;
activeElementId?: string | null;
setActiveElementId?: (elementId: string | null) => void;
}
export const SettingsView = ({
@@ -51,6 +56,9 @@ export const SettingsView = ({
locale,
appSetupCompleted,
enterpriseLicenseRequestFormUrl,
moveHiddenFieldsToSettingsTab = false,
activeElementId,
setActiveElementId,
}: SettingsViewProps) => {
const isAppSurvey = localSurvey.type === "app";
@@ -116,6 +124,27 @@ export const SettingsView = ({
<RecontactOptionsCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
{isAppSurvey && <SurveyPlacementCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />}
{moveHiddenFieldsToSettingsTab && setActiveElementId && (
<>
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setActiveElementId={setActiveElementId}
activeElementId={activeElementId ?? null}
quotas={quotas}
inSettings
/>
<SurveyVariablesCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeElementId={activeElementId ?? null}
setActiveElementId={setActiveElementId}
quotas={quotas}
inSettings
/>
</>
)}
</div>
);
};
@@ -50,6 +50,7 @@ interface SurveyEditorProps {
quotas: TSurveyQuota[];
isExternalUrlsAllowed: boolean;
publicDomain: string;
moveHiddenFieldsToSettingsTab?: boolean;
enterpriseLicenseRequestFormUrl: string;
}
@@ -79,6 +80,7 @@ export const SurveyEditor = ({
quotas,
isExternalUrlsAllowed,
publicDomain,
moveHiddenFieldsToSettingsTab = false,
enterpriseLicenseRequestFormUrl,
}: SurveyEditorProps) => {
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("elements");
@@ -221,6 +223,7 @@ export const SurveyEditor = ({
isStorageConfigured={isStorageConfigured}
quotas={quotas}
isExternalUrlsAllowed={isExternalUrlsAllowed}
moveHiddenFieldsToSettingsTab={moveHiddenFieldsToSettingsTab}
/>
)}
@@ -269,6 +272,9 @@ export const SurveyEditor = ({
locale={locale}
appSetupCompleted={localWorkspace.appSetupCompleted}
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
moveHiddenFieldsToSettingsTab={moveHiddenFieldsToSettingsTab}
activeElementId={activeElementId}
setActiveElementId={setActiveElementId}
/>
)}
@@ -2,7 +2,7 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as Collapsible from "@radix-ui/react-collapsible";
import { FileDigitIcon } from "lucide-react";
import { CheckIcon, FileDigitIcon } from "lucide-react";
import { type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyQuota } from "@formbricks/types/quota";
@@ -17,6 +17,7 @@ interface SurveyVariablesCardProps {
activeElementId: string | null;
setActiveElementId: (id: string | null) => void;
quotas: TSurveyQuota[];
inSettings?: boolean;
}
const variablesCardId = `fb-variables-${Date.now()}`;
@@ -27,6 +28,7 @@ export const SurveyVariablesCard = ({
activeElementId,
setActiveElementId,
quotas,
inSettings = false,
}: SurveyVariablesCardProps) => {
const open = activeElementId === variablesCardId;
const { t } = useTranslation();
@@ -41,6 +43,77 @@ export const SurveyVariablesCard = ({
}
};
const content = (
<Collapsible.CollapsibleContent
className={inSettings ? "flex flex-col" : `flex flex-col px-4 ${open && "pb-6"}`}
ref={parent}>
{inSettings && <hr className="py-1 text-slate-600" />}
<div className={cn("flex flex-col gap-2", inSettings ? "p-3" : "")} ref={parent}>
{localSurvey.variables.length > 0 ? (
localSurvey.variables.map((variable) => (
<SurveyVariablesCardItem
key={variable.id}
mode="edit"
variable={variable}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
quotas={quotas}
/>
))
) : (
<p className="mt-2 text-sm italic text-slate-500">
{t("workspace.surveys.edit.no_variables_yet_add_first_one_below")}
</p>
)}
</div>
<div className={inSettings ? "p-3 pt-0" : ""}>
<SurveyVariablesCardItem
mode="create"
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
quotas={quotas}
/>
</div>
{localSurvey.variables.length > 0 && (
<div className={cn("mt-6", inSettings ? "p-3 pt-0" : "")}>
<OptionIds type="variables" variables={localSurvey.variables} />
</div>
)}
</Collapsible.CollapsibleContent>
);
if (inSettings) {
return (
<Collapsible.Root
open={open}
onOpenChange={setOpenState}
className={cn(
open ? "" : "hover:bg-slate-50",
"w-full space-y-2 rounded-lg border border-slate-300 bg-white"
)}>
<Collapsible.CollapsibleTrigger asChild className="h-full w-full cursor-pointer">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
/>
</div>
<div>
<p className="font-semibold text-slate-800">{t("common.variables")}</p>
<p className="mt-1 text-sm text-slate-500">
{t("workspace.surveys.edit.variables_description")}
</p>
</div>
</div>
</Collapsible.CollapsibleTrigger>
{content}
</Collapsible.Root>
);
}
return (
<div className={cn(open ? "shadow-lg" : "shadow-md", "group z-10 flex flex-row rounded-lg bg-white")}>
<div
@@ -67,39 +140,7 @@ export const SurveyVariablesCard = ({
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-6"}`} ref={parent}>
<div className="flex flex-col gap-2" ref={parent}>
{localSurvey.variables.length > 0 ? (
localSurvey.variables.map((variable) => (
<SurveyVariablesCardItem
key={variable.id}
mode="edit"
variable={variable}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
quotas={quotas}
/>
))
) : (
<p className="mt-2 text-sm italic text-slate-500">
{t("workspace.surveys.edit.no_variables_yet_add_first_one_below")}
</p>
)}
</div>
<SurveyVariablesCardItem
mode="create"
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
quotas={quotas}
/>
{localSurvey.variables.length > 0 && (
<div className="mt-6">
<OptionIds type="variables" variables={localSurvey.variables} />
</div>
)}
</Collapsible.CollapsibleContent>
{content}
</Collapsible.Root>
</div>
);
+5 -1
View File
@@ -9,6 +9,7 @@ import {
UNSPLASH_ACCESS_KEY,
} from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getPostHogFeatureFlag } from "@/lib/posthog";
import { getTranslate } from "@/lingodotdev/server";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
@@ -92,10 +93,12 @@ export const SurveyEditorPage = async (props: {
]);
const quotas = isQuotasAllowed && survey ? await getQuotas(survey.id) : [];
const [workspaceLanguages, teamMemberDetails] = await Promise.all([
const [workspaceLanguages, teamMemberDetails, moveHiddenFieldsToSettingsTabFlag] = await Promise.all([
getWorkspaceLanguages(workspaceWithTeamIds.id),
getTeamMemberDetails(workspaceWithTeamIds.teamIds),
getPostHogFeatureFlag(session.user.id, "a-b_survey-editor_move-hidden-fields-to-settings"),
]);
const moveHiddenFieldsToSettingsTab = moveHiddenFieldsToSettingsTabFlag === "in-settings";
if (
!survey ||
@@ -139,6 +142,7 @@ export const SurveyEditorPage = async (props: {
isExternalUrlsAllowed={isExternalUrlsAllowed}
publicDomain={publicDomain}
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
moveHiddenFieldsToSettingsTab={moveHiddenFieldsToSettingsTab}
/>
);
};
@@ -0,0 +1,118 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper";
import { updateSurvey } from "@/modules/survey/editor/lib/survey";
import { getSurvey } from "@/modules/survey/lib/survey";
import { updateSurveyStatusAction } from "./actions";
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
inputSchema: vi.fn(() => ({
action: vi.fn((fn) => fn),
})),
},
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromSurveyId: vi.fn(),
getOrganizationIdFromWorkspaceId: vi.fn(),
getWorkspaceIdFromSurveyId: vi.fn(),
}));
vi.mock("@/lib/utils/single-use-surveys", () => ({
generateSurveySingleUseLinkParams: vi.fn(),
generateSurveySingleUseLinkParamsList: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
withAuditLogging: vi.fn((_eventName, _targetType, fn) => fn),
}));
vi.mock("@/modules/survey/editor/lib/survey", () => ({
updateSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/lib/survey", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/list/lib/survey", () => ({
copySurveyToOtherWorkspace: vi.fn(),
}));
const baseSurvey = {
id: "survey_1",
workspaceId: "workspace_1",
status: "inProgress",
};
const ctx = {
user: { id: "user_1" },
auditLoggingCtx: {},
};
describe("updateSurveyStatusAction", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getOrganizationIdFromSurveyId).mockResolvedValue("organization_1");
vi.mocked(getWorkspaceIdFromSurveyId).mockResolvedValue("workspace_1");
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(undefined);
vi.mocked(getSurvey).mockResolvedValue(baseSurvey as never);
vi.mocked(updateSurvey).mockResolvedValue({ ...baseSurvey, status: "completed" } as never);
});
test("updates a non-draft survey status with read-write access", async () => {
const result = await updateSurveyStatusAction({
ctx,
parsedInput: { surveyId: "survey_1", status: "completed" },
} as never);
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "organization_1",
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "workspaceTeam",
workspaceId: "workspace_1",
minPermission: "readWrite",
},
],
});
expect(updateSurvey).toHaveBeenCalledWith({ ...baseSurvey, status: "completed" });
expect(ctx.auditLoggingCtx).toEqual({
organizationId: "organization_1",
surveyId: "survey_1",
oldObject: baseSurvey,
newObject: { ...baseSurvey, status: "completed" },
});
expect(revalidatePath).toHaveBeenCalledWith("/workspaces/workspace_1/surveys");
expect(revalidatePath).toHaveBeenCalledWith("/workspaces/workspace_1/surveys/survey_1");
expect(result).toEqual({ ...baseSurvey, status: "completed" });
});
test("rejects draft survey status changes from the list", async () => {
vi.mocked(getSurvey).mockResolvedValue({ ...baseSurvey, status: "draft" } as never);
await expect(
updateSurveyStatusAction({
ctx: { user: { id: "user_1" }, auditLoggingCtx: {} },
parsedInput: { surveyId: "survey_1", status: "completed" },
} as never)
).rejects.toThrow(OperationNotAllowedError);
expect(updateSurvey).not.toHaveBeenCalled();
});
});
+53
View File
@@ -1,6 +1,8 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -14,6 +16,8 @@ import {
generateSurveySingleUseLinkParamsList,
} from "@/lib/utils/single-use-surveys";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { updateSurvey } from "@/modules/survey/editor/lib/survey";
import { getSurvey } from "@/modules/survey/lib/survey";
import { copySurveyToOtherWorkspace } from "@/modules/survey/list/lib/survey";
const ZCopySurveyToOtherWorkspaceAction = z.object({
@@ -81,6 +85,55 @@ export const copySurveyToOtherWorkspaceAction = authenticatedActionClient
})
);
const ZUpdateSurveyStatusAction = z.object({
surveyId: ZId,
status: z.enum(["inProgress", "paused", "completed"]),
});
export const updateSurveyStatusAction = authenticatedActionClient
.inputSchema(ZUpdateSurveyStatusAction)
.action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "workspaceTeam",
workspaceId,
minPermission: "readWrite",
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (survey.status === "draft") {
throw new OperationNotAllowedError("Draft surveys must be published from the editor.");
}
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = survey;
const updatedSurvey = await updateSurvey({ ...survey, status: parsedInput.status });
ctx.auditLoggingCtx.newObject = updatedSurvey;
revalidatePath(`/workspaces/${updatedSurvey.workspaceId}/surveys`);
revalidatePath(`/workspaces/${updatedSurvey.workspaceId}/surveys/${updatedSurvey.id}`);
return updatedSurvey;
})
);
const ZGenerateSingleUseIdAction = z
.object({
surveyId: z.cuid2(),
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { type ComponentProps, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TUserLocale } from "@formbricks/types/user";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
@@ -18,9 +18,17 @@ interface SurveyCardProps {
publicDomain: string;
isReadOnly: boolean;
deleteSurvey: (surveyId: string) => Promise<void>;
updateSurveyStatus: ComponentProps<typeof SurveyDropDownMenu>["updateSurveyStatus"];
locale: TUserLocale;
}
export const SurveyCard = ({ survey, publicDomain, isReadOnly, deleteSurvey, locale }: SurveyCardProps) => {
export const SurveyCard = ({
survey,
publicDomain,
isReadOnly,
deleteSurvey,
updateSurveyStatus,
locale,
}: Readonly<SurveyCardProps>) => {
const { t } = useTranslation();
const { workspace } = useWorkspace();
const workspaceBasePath = `/workspaces/${workspace?.id}`;
@@ -104,6 +112,7 @@ export const SurveyCard = ({ survey, publicDomain, isReadOnly, deleteSurvey, loc
disabled={isDraftAndReadOnly}
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
deleteSurvey={deleteSurvey}
updateSurveyStatus={updateSurveyStatus}
/>
</div>
</div>
@@ -9,18 +9,35 @@ import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import { TSurveyListItem } from "@/modules/survey/list/types/survey-overview";
import type { TSurveyListItem } from "@/modules/survey/list/types/survey-overview";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
type TSurveyStatusUpdate = Exclude<TSurveyListItem["status"], "draft">;
type TUpdateSurveyStatusResponse = {
data?: {
status: TSurveyListItem["status"];
publishOn: Date | null;
};
serverError?: string;
validationErrors?: unknown;
};
interface SurveyDropDownMenuProps {
survey: TSurveyListItem;
@@ -28,6 +45,10 @@ interface SurveyDropDownMenuProps {
disabled?: boolean;
isSurveyCreationDeletionDisabled?: boolean;
deleteSurvey: (surveyId: string) => Promise<void>;
updateSurveyStatus: (variables: {
surveyId: string;
status: TSurveyStatusUpdate;
}) => Promise<TUpdateSurveyStatusResponse>;
}
export const SurveyDropDownMenu = ({
@@ -36,7 +57,8 @@ export const SurveyDropDownMenu = ({
disabled,
isSurveyCreationDeletionDisabled,
deleteSurvey,
}: SurveyDropDownMenuProps) => {
updateSurveyStatus,
}: Readonly<SurveyDropDownMenuProps>) => {
const { workspace } = useWorkspace();
const { t } = useTranslation();
@@ -49,11 +71,26 @@ export const SurveyDropDownMenu = ({
const editHref = `/workspaces/${workspace?.id}/surveys/${survey.id}/edit`;
const surveyLink = useMemo(() => `${publicDomain}/s/${survey.id}`, [publicDomain, survey.id]);
const isScheduled = survey.status === "paused" && survey.publishOn !== null;
const isSingleUseEnabled = survey.singleUse?.enabled ?? false;
const canManageSurvey = !isSurveyCreationDeletionDisabled;
const canUpdateSurveyStatus = canManageSurvey && survey.status !== "draft";
const canPreviewOrCopyLink = survey.type === "link" && survey.status !== "draft";
const hasVisibleActions = canManageSurvey || canPreviewOrCopyLink;
const getSurveyStatusLabel = (status: TSurveyListItem["status"], isScheduledStatus = isScheduled) => {
switch (status) {
case "inProgress":
return t("common.in_progress");
case "completed":
return t("common.completed");
case "draft":
return t("common.draft");
case "paused":
return isScheduledStatus ? t("common.scheduled") : t("common.paused");
}
};
const handleDeleteSurvey = async (surveyId: string) => {
setLoading(true);
@@ -85,6 +122,40 @@ export const SurveyDropDownMenu = ({
setIsCautionDialogOpen(true);
};
const handleStatusChange = async (status: TSurveyStatusUpdate) => {
if (status === survey.status) {
return;
}
setIsDropDownOpen(false);
const toastId = toast.loading(t("common.saving"));
try {
const updateSurveyStatusResponse = await updateSurveyStatus({ surveyId: survey.id, status });
if (updateSurveyStatusResponse?.data) {
const { publishOn, status: resultingStatus } = updateSurveyStatusResponse.data;
const isResultScheduled = resultingStatus === "paused" && publishOn !== null;
const statusToToastMessage: Record<TSurveyStatusUpdate, string> = {
inProgress: t("common.survey_live"),
paused: isResultScheduled ? t("common.survey_scheduled") : t("common.survey_paused"),
completed: t("common.survey_completed"),
};
if (resultingStatus !== "draft") {
toast.success(statusToToastMessage[resultingStatus], { id: toastId });
} else {
toast.success(t("workspace.surveys.edit.changes_saved"), { id: toastId });
}
} else {
toast.error(getFormattedErrorMessage(updateSurveyStatusResponse), { id: toastId });
}
} catch (error) {
logger.error(error);
toast.error(t("common.something_went_wrong_please_try_again"), { id: toastId });
}
};
if (!hasVisibleActions) {
return null;
}
@@ -120,6 +191,39 @@ export const SurveyDropDownMenu = ({
</Link>
</DropdownMenuItem>
)}
{canUpdateSurveyStatus && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div className="flex min-w-36 flex-1 items-center gap-2">
<SurveyStatusIndicator status={survey.status} isScheduled={isScheduled} />
<span>{t("common.status")}</span>
<span className="ml-auto pl-4 text-xs font-normal text-slate-500">
{getSurveyStatusLabel(survey.status)}
</span>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={survey.status}
onValueChange={(value) => {
void handleStatusChange(value as TSurveyStatusUpdate);
}}>
<DropdownMenuRadioItem value="inProgress">
<SurveyStatusIndicator status="inProgress" />
{getSurveyStatusLabel("inProgress", false)}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="paused">
<SurveyStatusIndicator status="paused" isScheduled={isScheduled} />
{getSurveyStatusLabel("paused")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="completed">
<SurveyStatusIndicator status="completed" />
{getSurveyStatusLabel("completed", false)}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{canPreviewOrCopyLink && (
<DropdownMenuItem>
<button
@@ -11,6 +11,7 @@ import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
import { useDeleteSurvey } from "@/modules/survey/list/hooks/use-delete-survey";
import { useSurveys } from "@/modules/survey/list/hooks/use-surveys";
import { useUpdateSurveyStatus } from "@/modules/survey/list/hooks/use-update-survey-status";
import { initialFilters } from "@/modules/survey/list/lib/constants";
import {
hasActiveSurveyFilters,
@@ -44,7 +45,7 @@ export const SurveysList = ({
surveysPerPage,
currentWorkspaceChannel,
locale,
}: SurveysListProps) => {
}: Readonly<SurveysListProps>) => {
const { t } = useTranslation();
const [surveyFilters, setSurveyFilters] = useState<TSurveyOverviewFilters>(initialFilters);
const [isFilterInitialized, setIsFilterInitialized] = useState(false);
@@ -103,6 +104,7 @@ export const SurveysList = ({
});
const deleteSurveyMutation = useDeleteSurvey({ queryKey });
const updateSurveyStatusMutation = useUpdateSurveyStatus({ queryKey });
const hasAppliedFilters = hasActiveSurveyFilters(normalizedFilters);
const showInitialLoading = !isFilterInitialized || (isLoading && surveys.length === 0);
@@ -113,6 +115,10 @@ export const SurveysList = ({
await deleteSurveyMutation.mutateAsync({ surveyId });
};
const handleUpdateSurveyStatus = async (
variables: Parameters<typeof updateSurveyStatusMutation.mutateAsync>[0]
) => updateSurveyStatusMutation.mutateAsync(variables);
const createSurveyButton = (
<Button size="sm" asChild>
<Link href={`/workspaces/${workspace.id}/surveys/templates`}>
@@ -203,6 +209,7 @@ export const SurveysList = ({
survey={survey}
isReadOnly={isReadOnly}
deleteSurvey={handleDeleteSurvey}
updateSurveyStatus={handleUpdateSurveyStatus}
publicDomain={publicDomain}
locale={locale}
/>
@@ -0,0 +1,142 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { type ReactNode, createElement } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { updateSurveyStatusAction } from "@/modules/survey/list/actions";
import { surveyKeys } from "@/modules/survey/list/lib/query";
import type { TSurveyListPage } from "@/modules/survey/list/lib/v3-surveys-client";
import { useUpdateSurveyStatus } from "./use-update-survey-status";
vi.mock("@/modules/survey/list/actions", () => ({
updateSurveyStatusAction: vi.fn(),
}));
const queryKey = surveyKeys.list({
workspaceId: "workspace_1",
limit: 20,
filters: {
name: "",
status: [],
type: [],
sortBy: "relevance",
},
});
const queryData = {
pages: [
{
data: [
{
id: "survey_1",
name: "Survey 1",
workspaceId: "workspace_1",
type: "link",
status: "inProgress",
publishOn: null,
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
creator: { name: "Alice" },
singleUse: null,
},
],
meta: {
limit: 20,
nextCursor: null,
totalCount: 1,
},
},
],
pageParams: [null],
} satisfies { pages: TSurveyListPage[]; pageParams: (string | null)[] };
const createWrapper = (queryClient: QueryClient) => {
const Wrapper = ({ children }: Readonly<{ children: ReactNode }>) =>
createElement(QueryClientProvider, { client: queryClient }, children);
Wrapper.displayName = "UseUpdateSurveyStatusTestWrapper";
return Wrapper;
};
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
mutations: { retry: false },
queries: { retry: false },
},
});
describe("useUpdateSurveyStatus", () => {
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
});
afterEach(() => {
vi.clearAllMocks();
});
test("updates cached list data from the status action result", async () => {
const updatedAt = new Date("2026-04-16T10:00:00.000Z");
vi.mocked(updateSurveyStatusAction).mockResolvedValue({
data: {
id: "survey_1",
status: "completed",
publishOn: null,
updatedAt,
},
});
const queryClient = createQueryClient();
queryClient.setQueryData(queryKey, queryData);
const { result } = renderHook(() => useUpdateSurveyStatus({ queryKey }), {
wrapper: createWrapper(queryClient),
});
result.current.mutate({ surveyId: "survey_1", status: "completed" });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(queryClient.getQueryData<{ pages: TSurveyListPage[] }>(queryKey)?.pages[0]?.data[0]).toEqual(
expect.objectContaining({
status: "completed",
publishOn: null,
updatedAt,
})
);
});
test("rolls cached list data back when the action throws", async () => {
vi.mocked(updateSurveyStatusAction).mockRejectedValue(new Error("Unable to update"));
const queryClient = createQueryClient();
queryClient.setQueryData(queryKey, queryData);
const { result } = renderHook(() => useUpdateSurveyStatus({ queryKey }), {
wrapper: createWrapper(queryClient),
});
result.current.mutate({ surveyId: "survey_1", status: "completed" });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(queryClient.getQueryData(queryKey)).toEqual(queryData);
});
test("rolls cached list data back when the action returns no data", async () => {
vi.mocked(updateSurveyStatusAction).mockResolvedValue({ serverError: "Unable to update" });
const queryClient = createQueryClient();
queryClient.setQueryData(queryKey, queryData);
const { result } = renderHook(() => useUpdateSurveyStatus({ queryKey }), {
wrapper: createWrapper(queryClient),
});
result.current.mutate({ surveyId: "survey_1", status: "completed" });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(queryClient.getQueryData(queryKey)).toEqual(queryData);
});
});
@@ -0,0 +1,67 @@
"use client";
import { InfiniteData, useMutation, useQueryClient } from "@tanstack/react-query";
import type { TSurveyStatus } from "@formbricks/types/surveys/types";
import { updateSurveyStatusAction } from "@/modules/survey/list/actions";
import { surveyKeys, updateSurveyInInfiniteData } from "@/modules/survey/list/lib/query";
import type { TSurveyListPage } from "@/modules/survey/list/lib/v3-surveys-client";
type TUpdateSurveyStatusInput = {
surveyId: string;
status: Exclude<TSurveyStatus, "draft">;
};
export const useUpdateSurveyStatus = ({ queryKey }: { queryKey: ReturnType<typeof surveyKeys.list> }) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ surveyId, status }: TUpdateSurveyStatusInput) =>
updateSurveyStatusAction({ surveyId, status }),
onMutate: async ({ surveyId, status }) => {
await queryClient.cancelQueries({ queryKey });
const previousData = queryClient.getQueryData<InfiniteData<TSurveyListPage>>(queryKey);
queryClient.setQueryData<InfiniteData<TSurveyListPage> | undefined>(queryKey, (currentData) =>
updateSurveyInInfiniteData(currentData, {
id: surveyId,
status,
updatedAt: new Date(),
...(status === "paused" ? {} : { publishOn: null }),
})
);
return {
previousData,
};
},
onError: (_error, _variables, context) => {
if (context?.previousData) {
queryClient.setQueryData(queryKey, context.previousData);
}
},
onSuccess: (response, _variables, context) => {
if (!response?.data) {
if (context?.previousData) {
queryClient.setQueryData(queryKey, context.previousData);
}
return;
}
const { id, publishOn, status, updatedAt } = response.data;
queryClient.setQueryData<InfiniteData<TSurveyListPage> | undefined>(queryKey, (currentData) =>
updateSurveyInInfiniteData(currentData, {
id,
publishOn,
status,
updatedAt,
})
);
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: surveyKeys.lists() });
},
});
};
+27 -2
View File
@@ -1,7 +1,7 @@
import type { InfiniteData } from "@tanstack/react-query";
import { describe, expect, test } from "vitest";
import { flattenSurveyPages, removeSurveyFromInfiniteData } from "./query";
import { TSurveyListPage } from "./v3-surveys-client";
import { flattenSurveyPages, removeSurveyFromInfiniteData, updateSurveyInInfiniteData } from "./query";
import type { TSurveyListPage } from "./v3-surveys-client";
const surveyA = {
id: "survey_a",
@@ -9,6 +9,7 @@ const surveyA = {
workspaceId: "env_1",
type: "link" as const,
status: "draft" as const,
publishOn: null,
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
@@ -50,6 +51,30 @@ describe("flattenSurveyPages", () => {
});
});
describe("updateSurveyInInfiniteData", () => {
test("updates the matching survey across cached pages", () => {
const updatedAt = new Date("2026-04-16T10:00:00.000Z");
const nextData = updateSurveyInInfiniteData(baseData, {
id: "survey_b",
status: "completed",
publishOn: null,
updatedAt,
});
expect(nextData?.pages[0]?.data).toEqual([surveyA]);
expect(nextData?.pages[1]?.data[0]).toEqual({
...surveyB,
status: "completed",
publishOn: null,
updatedAt,
});
});
test("returns the original cache when the survey is not present", () => {
expect(updateSurveyInInfiniteData(baseData, { id: "missing_survey", status: "paused" })).toBe(baseData);
});
});
describe("removeSurveyFromInfiniteData", () => {
test("removes the survey from cached pages and decrements each page total", () => {
const nextData = removeSurveyFromInfiniteData(baseData, "survey_a");
+37 -2
View File
@@ -1,6 +1,6 @@
import type { InfiniteData } from "@tanstack/react-query";
import { TSurveyListItem, TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
import { TSurveyListPage } from "./v3-surveys-client";
import type { TSurveyListItem, TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
import type { TSurveyListPage } from "./v3-surveys-client";
type TSurveyListKeyInput = {
workspaceId: string;
@@ -55,3 +55,38 @@ export function removeSurveyFromInfiniteData(
})),
};
}
export function updateSurveyInInfiniteData(
data: InfiniteData<TSurveyListPage> | undefined,
updatedSurvey: Pick<TSurveyListItem, "id"> & Partial<TSurveyListItem>
): InfiniteData<TSurveyListPage> | undefined {
if (!data) {
return data;
}
let surveyWasUpdated = false;
const pages = data.pages.map((page) => ({
...page,
data: page.data.map((survey) => {
if (survey.id !== updatedSurvey.id) {
return survey;
}
surveyWasUpdated = true;
return {
...survey,
...updatedSurvey,
};
}),
}));
if (!surveyWasUpdated) {
return data;
}
return {
...data,
pages,
};
}
+1
View File
@@ -118,6 +118,7 @@
"xm-and-surveys/surveys/website-app-surveys/workspace-id-migration",
"xm-and-surveys/surveys/website-app-surveys/framework-guides",
"xm-and-surveys/surveys/website-app-surveys/google-tag-manager",
"xm-and-surveys/surveys/website-app-surveys/custom-css",
{
"group": "Features",
"icon": "wrench",
@@ -87,14 +87,14 @@ Action is of the following types:
![Action Require](/images/xm-and-surveys/surveys/general-features/conditional-logic/action-require.webp)
* **Jump to Question**: Skip to a specific question. The user will be redirected to the specified question based on the condition.
* **Jump to Block**: Skip to a specific block. The user will be redirected to the specified block based on the condition.
![Action Jump](/images/xm-and-surveys/surveys/general-features/conditional-logic/action-jump.webp)
* **Save Logic**: Click the `Save` button to save the logic block.
## Question Logic
## Block Logic
This logic is executed when the user answers the question. Logic can be as simple as showing a follow-up question based on the answer or as complex as calculating a score based on multiple answers.
This logic is executed when the user reaches the block. Logic can be as simple as showing a follow-up block based on earlier answers or as complex as calculating a score based on multiple answers.
![Question Logic](/images/xm-and-surveys/surveys/general-features/conditional-logic/question-logic.webp)
![Block Logic](/images/xm-and-surveys/surveys/general-features/conditional-logic/question-logic.webp)
@@ -0,0 +1,145 @@
---
title: "Custom CSS"
description: "Use scoped global CSS to customize Website & App Surveys without breaking your app styles."
icon: "palette"
---
Yes, custom CSS for Website & App Surveys is supported.
Use this when the Styling UI is not enough and you need tighter brand control.
<Note>
Start with the built-in [Styling Theme](/xm-and-surveys/core-features/styling-theme) first. Use custom CSS only for advanced overrides.
</Note>
## Problem
Website & App Surveys are rendered inside your product, so your app's global CSS can compete with survey styles.
Common examples:
- A global rule like `button { ... !important; }` changes survey buttons.
- Typography resets change survey text spacing and sizing.
- Utility-heavy global styles create unexpected visual differences between pages.
## Solution
Scope your overrides to the survey root (`#fbjs`) and use `!important` for the exact targets you want to control.
This gives you two important guarantees:
1. Your custom rules apply only to the survey.
2. Your rules reliably win against conflicting global styles.
## Add Scoped Overrides In Global CSS
<Steps>
<Step title="Open your global stylesheet">
Use the stylesheet that is loaded on pages where surveys are shown (for example `globals.css`).
</Step>
<Step title="Add scoped rules under #fbjs">
Keep all custom rules prefixed with `#fbjs`.
</Step>
<Step title="Use !important only where needed">
Add `!important` to properties that are still being overridden by your app-wide CSS.
</Step>
</Steps>
```css globals.css
/* 1) Theme-level variables */
#fbjs {
--fb-brand-color: #0ea5e9 !important;
--fb-brand-text-color: #ffffff !important;
--fb-heading-color: #0f172a !important;
--fb-subheading-color: #334155 !important;
--fb-survey-background-color: #ffffff !important;
--fb-border-radius: 12px !important;
}
/* 2) Targeted component overrides */
#fbjs .button-custom,
#fbjs button.button-custom {
border: 1px solid #0284c7 !important;
box-shadow: none !important;
}
#fbjs .label-headline,
#fbjs .label-headline * {
font-size: 1.125rem !important;
font-weight: 700 !important;
}
#fbjs .bg-input-bg,
#fbjs .border-input-border,
#fbjs .text-input-text {
background: #f8fafc !important;
border-color: #cbd5e1 !important;
color: #0f172a !important;
}
```
<Info>
Internal Tailwind utility classes are implementation details and may change over time. Prefer CSS variables and stable survey selectors scoped under `#fbjs`.
</Info>
## Best Practices
- Keep all survey overrides in one section or file for easier maintenance.
- Avoid global selectors without `#fbjs` (for example `button`, `input`, `p`) when styling surveys.
- Document why each `!important` exists so future cleanup is easy.
- After changes, hard refresh your page to clear cached SDK assets.
## Troubleshooting
**My app styles still win over survey styles**
- Increase selector specificity under `#fbjs`.
- Add `!important` only on the conflicting property.
- Check the browser inspector to confirm which rule is winning.
**Survey styles are affecting the rest of my app**
- This usually means a selector is missing `#fbjs`.
- Prefix every rule with `#fbjs` to keep styles isolated.
## Survey UI CSS Class Reference
The following classes are used by `packages/survey-ui` and are safe to target when scoped with `#fbjs`.
| CSS class | Element(s) it styles | Notes |
| --- | --- | --- |
| `.button-custom` | Survey action buttons (submit, CTA, navigation buttons with custom variant) | Applies `--fb-button-*` styling tokens. |
| `.label-headline` | Question headlines and headline HTML content | Used by `Label` variant `headline`. |
| `.label-description` | Question descriptions and helper copy | Used by `Label` variant `description`. |
| `.label-default` | Default label text content | Used by `Label` variant `default`. |
| `.label-card` | Upper labels (for example, required label text) | Used by `Label` variant `card`. |
| `.progress-track` | Progress bar track container | Uses `--fb-progress-track-*` tokens. |
| `.progress-indicator` | Progress bar fill indicator | Uses `--fb-progress-indicator-*` tokens. |
| `.rounded-input` | Input-like controls (text inputs, dropdown triggers, date inputs, rating/NPS options) | Controls input border radius token. |
| `.bg-input-bg` | Input-like control backgrounds | Maps to `--fb-input-bg-color`. |
| `.border-input-border` | Input-like control borders | Maps to `--fb-input-border-color`. |
| `.text-input` | Input-like text size | Maps to `--fb-input-font-size`. |
| `.text-input-text` | Input text and some input icons | Maps to `--fb-input-color`. |
| `.text-input-placeholder` | Placeholder and empty-state text | Maps to `--fb-input-placeholder-color`. |
| `.font-input` | Input-like font family | Maps to `--fb-input-font-family`. |
| `.font-input-weight` | Input-like font weight | Maps to `--fb-input-font-weight`. |
| `.w-input` | Input width | Maps to `--fb-input-width`. |
| `.min-h-input` | Input minimum height | Maps to `--fb-input-height`. |
| `.px-input-x` | Input horizontal padding | Maps to `--fb-input-padding-x`. |
| `.py-input-y` | Input vertical padding | Maps to `--fb-input-padding-y`. |
| `.shadow-input` | Input shadow | Maps to `--fb-input-shadow`. |
| `.rounded-option` | Select/multi-select/ranking/picture-select option containers | Controls option border radius token. |
| `.bg-option-bg` | Unselected option backgrounds | Maps to `--fb-option-bg-color`. |
| `.bg-option-selected-bg` | Selected option backgrounds | Used for selected states. |
| `.bg-option-hover-bg` | Option hover background | Used for hover states. |
| `.border-option-border` | Option borders and dropdown search divider | Maps to option border token. |
| `.text-option` | Option label font size | Maps to `--fb-option-font-size`. |
| `.text-option-label` | Option label text color | Maps to `--fb-option-label-color`. |
| `.font-option` | Option label font family | Maps to `--fb-option-font-family`. |
| `.font-option-weight` | Option label font weight | Maps to `--fb-option-font-weight`. |
| `.px-option-x` | Option horizontal padding | Maps to `--fb-option-padding-x`. |
| `.py-option-y` | Option vertical padding | Maps to `--fb-option-padding-y`. |
| `.rounded-button` | Button radius (base button component) | Maps to `--fb-button-border-radius`. |
| `.text-button` | Button text size | Maps to `--fb-button-font-size`. |
| `.font-button-weight` | Button font weight | Maps to `--fb-button-font-weight`. |
| `.border-brand` | Selected/active option borders | Uses survey brand color token. |