mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-20 03:07:53 -05:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a6b612ba7 | |||
| 80d3d88532 | |||
| 778a02d3b3 | |||
| c172e2a33c | |||
| 9a5780d510 | |||
| e333d8ba02 | |||
| 16463960ad |
+1
-1
@@ -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`);
|
||||
|
||||
+8
-39
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
-144
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
-9
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-4
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+1
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "推奨者",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Сторонники",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "推荐者",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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() });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||

|
||||
|
||||
* **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.
|
||||
|
||||

|
||||
|
||||
* **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.
|
||||
|
||||

|
||||

|
||||
@@ -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. |
|
||||
Reference in New Issue
Block a user