Compare commits

..

6 Commits

Author SHA1 Message Date
Matti Nannt 05d7d1165a fix: strip client-provided timestamps in client response API (ENG-828)
The client-facing POST /api/v1/client and /api/v2/client response
endpoints accepted arbitrary createdAt/updatedAt values, allowing any
respondent to backdate or future-date their submission and poison
time-series analytics.

Strip both fields before the Prisma insert in the client code paths.
The management API retains the ability to pass timestamps, as it is
authenticated and used for legitimate server-side data imports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:32:06 +02:00
Dhruwang Jariwala eea7df81b4 fix: seed default contact attribute keys on workspace creation (ENG-929) (#8036)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:34:33 +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
38 changed files with 500 additions and 481 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" />
@@ -104,7 +104,11 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
const prismaData = buildPrismaResponseData(
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
contact,
ttc
);
const prismaClient = tx ?? prisma;
@@ -49,18 +49,7 @@ const buildPrismaResponseData = (
contact: { id: string; attributes: TContactAttributes } | null,
ttc: Record<string, number>
): Prisma.ResponseCreateInput => {
const {
surveyId,
displayId,
finished,
data,
language,
meta,
singleUseId,
variables,
createdAt,
updatedAt,
} = responseInput;
const { surveyId, displayId, finished, data, language, meta, singleUseId, variables } = responseInput;
return {
survey: {
@@ -84,8 +73,6 @@ const buildPrismaResponseData = (
singleUseId,
...(variables && { variables }),
ttc: ttc,
createdAt,
updatedAt,
};
};
+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}
/>
);
};
@@ -118,6 +118,26 @@ describe("workspace lib", () => {
expectNoFrdSideEffects();
});
test("seeds the default contact attribute keys when creating a workspace", async () => {
const createdWorkspace = { ...baseWorkspace, id: "p-defaults" };
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
await createWorkspace("org1", { name: "Workspace defaults" });
const createArgs = vi.mocked(prisma.workspace.create).mock.calls[0][0];
const attributeCreate = (createArgs.data as any).contactAttributeKeys.create as Array<{
key: string;
type: string;
isUnique?: boolean;
}>;
expect(attributeCreate.map((a) => a.key).sort()).toEqual(
["email", "firstName", "language", "lastName", "userId"].sort()
);
expect(attributeCreate.every((a) => a.type === "default")).toBe(true);
const uniqueKeys = attributeCreate.filter((a) => a.isUnique).map((a) => a.key);
expect(uniqueKeys.sort()).toEqual(["email", "userId"].sort());
});
test("creates workspace without teams and does not auto-link any FRD", async () => {
const createdWorkspace = { ...baseWorkspace, id: "p3" };
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
@@ -9,6 +9,41 @@ import { TWorkspace, TWorkspaceUpdateInput, ZWorkspaceUpdateInput } from "@formb
import { validateInputs } from "@/lib/utils/validate";
import { deleteFilesByWorkspaceId } from "@/modules/storage/service";
const DEFAULT_CONTACT_ATTRIBUTE_KEYS: Prisma.ContactAttributeKeyCreateWithoutWorkspaceInput[] = [
{
key: "userId",
name: "User Id",
description: "The user id of a contact",
type: "default",
isUnique: true,
},
{
key: "email",
name: "Email",
description: "The email of a contact",
type: "default",
isUnique: true,
},
{
key: "firstName",
name: "First Name",
description: "Your contact's first name",
type: "default",
},
{
key: "lastName",
name: "Last Name",
description: "Your contact's last name",
type: "default",
},
{
key: "language",
name: "Language",
description: "The language preference of a contact",
type: "default",
},
];
const selectWorkspace = {
id: true,
createdAt: true,
@@ -76,6 +111,9 @@ export const createWorkspace = async (
...data,
name: workspaceInput.name,
organizationId,
contactAttributeKeys: {
create: DEFAULT_CONTACT_ATTRIBUTE_KEYS,
},
},
select: selectWorkspace,
});
+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. |