mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-08 02:43:06 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a37d7279f |
@@ -155,31 +155,3 @@ jobs:
|
||||
commit_sha: ${{ github.sha }}
|
||||
is_prerelease: ${{ github.event.release.prerelease }}
|
||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
|
||||
linear-release-complete:
|
||||
name: Mark Linear release as complete
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
needs:
|
||||
- docker-build-community
|
||||
- docker-build-cloud
|
||||
- helm-chart-release
|
||||
- move-stable-tag
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Complete Linear release
|
||||
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
|
||||
with:
|
||||
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
|
||||
command: complete
|
||||
version: ${{ github.event.release.tag_name }}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
name: Linear Release Sync
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linear-release:
|
||||
name: Sync release to Linear
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Sync Linear release
|
||||
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
|
||||
with:
|
||||
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
|
||||
+1
-2
@@ -18,7 +18,6 @@ import { createProjectAction } from "@/app/(app)/environments/[environmentId]/ac
|
||||
import { previewSurvey } from "@/app/lib/templates";
|
||||
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
||||
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
|
||||
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
|
||||
@@ -243,7 +242,7 @@ export const ProjectSettings = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsEnvironmentStateSurvey(previewSurvey(projectName || t("common.my_product"), t))}
|
||||
survey={previewSurvey(projectName || t("common.my_product"), t)}
|
||||
styling={previewStyling}
|
||||
isBrandingEnabled={false}
|
||||
languageCode="default"
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getStyling } from "@/lib/utils/styling";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -19,7 +18,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const styling = getStyling(project, toJsEnvironmentStateSurvey(survey));
|
||||
const styling = getStyling(project, survey);
|
||||
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
|
||||
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
select: {
|
||||
id: true,
|
||||
welcomeCard: true,
|
||||
// name intentionally omitted — internal label not needed by the SDK
|
||||
name: true,
|
||||
questions: true,
|
||||
blocks: true,
|
||||
variables: true,
|
||||
@@ -107,13 +107,13 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
styling: true,
|
||||
status: true,
|
||||
recaptcha: true,
|
||||
// Fetch only what's needed to compute the minimal segment shape.
|
||||
// Titles, descriptions, and filter conditions are evaluated server-side
|
||||
// and must not be sent to the browser.
|
||||
segment: {
|
||||
select: {
|
||||
id: true,
|
||||
filters: true,
|
||||
include: {
|
||||
surveys: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
recontactDays: true,
|
||||
@@ -147,28 +147,10 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
throw new ResourceNotFoundError("project", null);
|
||||
}
|
||||
|
||||
// Transform surveys using the shared utility, then replace the segment with
|
||||
// the minimal public shape (id + hasFilters). We null out segment before
|
||||
// calling transformPrismaSurvey because that function expects a surveys[]
|
||||
// relation on the segment object (used by the management API), which we
|
||||
// intentionally don't fetch here.
|
||||
const transformedSurveys = environmentData.surveys.map((survey) => {
|
||||
const minimalSegment = survey.segment
|
||||
? {
|
||||
id: survey.segment.id,
|
||||
hasFilters:
|
||||
Array.isArray(survey.segment.filters) && (survey.segment.filters as unknown[]).length > 0,
|
||||
}
|
||||
: null;
|
||||
|
||||
const { segment: _segment, ...surveyWithoutSegment } = survey;
|
||||
const transformed = transformPrismaSurvey<TJsEnvironmentStateSurvey>({
|
||||
...surveyWithoutSegment,
|
||||
segment: null,
|
||||
});
|
||||
|
||||
return { ...transformed, segment: minimalSegment };
|
||||
});
|
||||
// Transform surveys using existing utility
|
||||
const transformedSurveys = environmentData.surveys.map((survey) =>
|
||||
transformPrismaSurvey<TJsEnvironmentStateSurvey>(survey)
|
||||
);
|
||||
|
||||
return {
|
||||
environment: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getHasNoOrganizations } from "@/lib/instance/service";
|
||||
import { gethasNoOrganizations } from "@/lib/instance/service";
|
||||
import { createMembership } from "@/lib/membership/service";
|
||||
import { createOrganization } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
@@ -21,7 +21,7 @@ export const createOrganizationAction = authenticatedActionClient
|
||||
.inputSchema(ZCreateOrganizationAction)
|
||||
.action(
|
||||
withAuditLogging("created", "organization", async ({ ctx, parsedInput }) => {
|
||||
const hasNoOrganizations = await getHasNoOrganizations();
|
||||
const hasNoOrganizations = await gethasNoOrganizations();
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
|
||||
if (!hasNoOrganizations && !isMultiOrgEnabled) {
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getHasNoOrganizations, getIsFreshInstance } from "@/lib/instance/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
const Page = async () => {
|
||||
const [session, isFreshInstance, hasNoOrganizations] = await Promise.all([
|
||||
getServerSession(authOptions),
|
||||
getIsFreshInstance(),
|
||||
getHasNoOrganizations(),
|
||||
]);
|
||||
|
||||
if (isFreshInstance) {
|
||||
return redirect("/setup/intro");
|
||||
}
|
||||
|
||||
if (hasNoOrganizations) {
|
||||
if (session) {
|
||||
return redirect("/setup/organization/create");
|
||||
}
|
||||
|
||||
return redirect("/auth/login?callbackUrl=%2Fsetup%2Forganization%2Fcreate");
|
||||
}
|
||||
|
||||
return redirect("/");
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -19,7 +19,7 @@ export const getIsFreshInstance = reactCache(async (): Promise<boolean> => {
|
||||
});
|
||||
|
||||
// Function to check if there are any organizations in the database
|
||||
export const getHasNoOrganizations = reactCache(async (): Promise<boolean> => {
|
||||
export const gethasNoOrganizations = reactCache(async (): Promise<boolean> => {
|
||||
try {
|
||||
const organizationCount = await prisma.organization.count();
|
||||
return organizationCount === 0;
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
/**
|
||||
* Adapts a full management-side `TSurvey` into the minimal
|
||||
* `TJsEnvironmentStateSurvey` shape that the SDK widget / shared SDK utilities
|
||||
* expect. Only the segment shape needs reshaping — the rest of `TSurvey` is a
|
||||
* structural superset of the SDK survey type.
|
||||
*/
|
||||
export const toJsEnvironmentStateSurvey = (survey: TSurvey): TJsEnvironmentStateSurvey => {
|
||||
return {
|
||||
...survey,
|
||||
segment: survey.segment ? { id: survey.segment.id, hasFilters: survey.segment.filters.length > 0 } : null,
|
||||
} as unknown as TJsEnvironmentStateSurvey;
|
||||
};
|
||||
@@ -3,7 +3,6 @@ import { Prisma, Response } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getQuotas } from "./quotas";
|
||||
import { evaluateQuotas, handleQuotas } from "./utils";
|
||||
@@ -53,13 +52,7 @@ export const evaluateResponseQuotas = async (input: QuotaEvaluationInput): Promi
|
||||
return { shouldEndSurvey: false };
|
||||
}
|
||||
const isDefaultLanguage = survey.languages.find((lang) => lang.default)?.language.code === language;
|
||||
const result = evaluateQuotas(
|
||||
toJsEnvironmentStateSurvey(survey),
|
||||
data,
|
||||
variables,
|
||||
quotas,
|
||||
isDefaultLanguage ? "default" : language
|
||||
);
|
||||
const result = evaluateQuotas(survey, data, variables, quotas, isDefaultLanguage ? "default" : language);
|
||||
|
||||
const quotaFull = await handleQuotas(surveyId, responseId, result, responseFinished, prismaClient);
|
||||
|
||||
|
||||
@@ -1,29 +1,14 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getHasNoOrganizations, getIsFreshInstance } from "@/lib/instance/service";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getIsFreshInstance } from "@/lib/instance/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
export const FreshInstanceLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
const isFreshInstance = await getIsFreshInstance();
|
||||
|
||||
if (!isFreshInstance) {
|
||||
const hasNoOrganizations = await getHasNoOrganizations();
|
||||
|
||||
if (hasNoOrganizations) {
|
||||
if (session) {
|
||||
return redirect("/setup/organization/create");
|
||||
}
|
||||
|
||||
return redirect("/auth/login?callbackUrl=%2Fsetup%2Forganization%2Fcreate");
|
||||
}
|
||||
|
||||
if (session || !isFreshInstance) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (session) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getServerSession } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getHasNoOrganizations } from "@/lib/instance/service";
|
||||
import { gethasNoOrganizations } from "@/lib/instance/service";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -29,7 +29,7 @@ export const CreateOrganizationPage = async () => {
|
||||
return <ClientLogout />;
|
||||
}
|
||||
|
||||
const hasNoOrganizations = await getHasNoOrganizations();
|
||||
const hasNoOrganizations = await gethasNoOrganizations();
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
const userOrganizations = await getOrganizationsByUserId(session.user.id);
|
||||
|
||||
|
||||
@@ -72,7 +72,6 @@ interface ElementsViewProps {
|
||||
isStorageConfigured: boolean;
|
||||
quotas: TSurveyQuota[];
|
||||
isExternalUrlsAllowed: boolean;
|
||||
customisationsInSettings?: boolean;
|
||||
}
|
||||
|
||||
export const ElementsView = ({
|
||||
@@ -92,7 +91,6 @@ export const ElementsView = ({
|
||||
isStorageConfigured = true,
|
||||
quotas,
|
||||
isExternalUrlsAllowed,
|
||||
customisationsInSettings = false,
|
||||
}: ElementsViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [logicDeletionWarning, setLogicDeletionWarning] = React.useState<{
|
||||
@@ -923,25 +921,23 @@ export const ElementsView = ({
|
||||
{!isCxMode && (
|
||||
<>
|
||||
<AddEndingCardButton localSurvey={localSurvey} addEndingCard={addEndingCard} />
|
||||
{!customisationsInSettings && (
|
||||
<>
|
||||
<hr />
|
||||
<HiddenFieldsCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveElementId={setActiveElementId}
|
||||
activeElementId={activeElementId}
|
||||
quotas={quotas}
|
||||
/>
|
||||
<SurveyVariablesCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
activeElementId={activeElementId}
|
||||
setActiveElementId={setActiveElementId}
|
||||
quotas={quotas}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<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 { CheckIcon, EyeOff } from "lucide-react";
|
||||
import { EyeOff } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -25,7 +25,6 @@ interface HiddenFieldsCardProps {
|
||||
activeElementId: string | null;
|
||||
setActiveElementId: (elementId: string | null) => void;
|
||||
quotas: TSurveyQuota[];
|
||||
inSettings?: boolean;
|
||||
}
|
||||
|
||||
export const HiddenFieldsCard = ({
|
||||
@@ -34,7 +33,6 @@ export const HiddenFieldsCard = ({
|
||||
setActiveElementId,
|
||||
setLocalSurvey,
|
||||
quotas,
|
||||
inSettings = false,
|
||||
}: HiddenFieldsCardProps) => {
|
||||
const open = activeElementId == "hidden";
|
||||
const [hiddenField, setHiddenField] = useState<string>("");
|
||||
@@ -156,105 +154,6 @@ 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("environments.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("environments.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("environments.surveys.edit.type_field_id") + "..."}
|
||||
/>
|
||||
<Button variant="secondary" type="submit" className="h-10 whitespace-nowrap">
|
||||
{t("environments.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">
|
||||
Pass hidden data into your survey without showing it to respondents.
|
||||
</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
|
||||
@@ -279,7 +178,69 @@ export const HiddenFieldsCard = ({
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
{content}
|
||||
<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("environments.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("environments.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("environments.surveys.edit.type_field_id") + "..."}
|
||||
/>
|
||||
<Button variant="secondary" type="submit" className="h-10 whitespace-nowrap">
|
||||
{t("environments.surveys.edit.add_hidden_field_id")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,12 +7,10 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
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/project-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";
|
||||
|
||||
@@ -31,10 +29,6 @@ interface SettingsViewProps {
|
||||
isFormbricksCloud: boolean;
|
||||
isQuotasAllowed: boolean;
|
||||
quotas: TSurveyQuota[];
|
||||
// TODO: experiment cleanup — customisations_in_settings
|
||||
customisationsInSettings?: boolean;
|
||||
activeElementId?: string | null;
|
||||
setActiveElementId?: (elementId: string | null) => void;
|
||||
}
|
||||
|
||||
export const SettingsView = ({
|
||||
@@ -52,9 +46,6 @@ export const SettingsView = ({
|
||||
projectPermission,
|
||||
isFormbricksCloud,
|
||||
quotas,
|
||||
customisationsInSettings = false,
|
||||
activeElementId,
|
||||
setActiveElementId,
|
||||
}: SettingsViewProps) => {
|
||||
const isAppSurvey = localSurvey.type === "app";
|
||||
|
||||
@@ -116,27 +107,6 @@ export const SettingsView = ({
|
||||
environmentId={environment.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{customisationsInSettings && 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -51,7 +51,6 @@ interface SurveyEditorProps {
|
||||
quotas: TSurveyQuota[];
|
||||
isExternalUrlsAllowed: boolean;
|
||||
publicDomain: string;
|
||||
customisationsInSettings?: boolean;
|
||||
}
|
||||
|
||||
export const SurveyEditor = ({
|
||||
@@ -81,7 +80,6 @@ export const SurveyEditor = ({
|
||||
quotas,
|
||||
isExternalUrlsAllowed,
|
||||
publicDomain,
|
||||
customisationsInSettings = false,
|
||||
}: SurveyEditorProps) => {
|
||||
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("elements");
|
||||
const [activeElementId, setActiveElementId] = useState<string | null>(null);
|
||||
@@ -222,7 +220,6 @@ export const SurveyEditor = ({
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
quotas={quotas}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
customisationsInSettings={customisationsInSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -269,9 +266,6 @@ export const SurveyEditor = ({
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
quotas={quotas}
|
||||
customisationsInSettings={customisationsInSettings}
|
||||
activeElementId={activeElementId}
|
||||
setActiveElementId={setActiveElementId}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { CheckIcon, FileDigitIcon } from "lucide-react";
|
||||
import { FileDigitIcon } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
@@ -17,7 +17,6 @@ interface SurveyVariablesCardProps {
|
||||
activeElementId: string | null;
|
||||
setActiveElementId: (id: string | null) => void;
|
||||
quotas: TSurveyQuota[];
|
||||
inSettings?: boolean;
|
||||
}
|
||||
|
||||
const variablesCardId = `fb-variables-${Date.now()}`;
|
||||
@@ -28,7 +27,6 @@ export const SurveyVariablesCard = ({
|
||||
activeElementId,
|
||||
setActiveElementId,
|
||||
quotas,
|
||||
inSettings = false,
|
||||
}: SurveyVariablesCardProps) => {
|
||||
const open = activeElementId === variablesCardId;
|
||||
const { t } = useTranslation();
|
||||
@@ -43,75 +41,6 @@ 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("environments.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">Define and compute values throughout your survey.</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
|
||||
@@ -138,7 +67,39 @@ export const SurveyVariablesCard = ({
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
{content}
|
||||
<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("environments.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>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ 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";
|
||||
@@ -91,12 +90,10 @@ export const SurveyEditorPage = async (props: {
|
||||
]);
|
||||
|
||||
const quotas = isQuotasAllowed && survey ? await getQuotas(survey.id) : [];
|
||||
const [projectLanguages, teamMemberDetails, customisationsInSettingsFlag] = await Promise.all([
|
||||
const [projectLanguages, teamMemberDetails] = await Promise.all([
|
||||
getProjectLanguages(projectWithTeamIds.id),
|
||||
getTeamMemberDetails(projectWithTeamIds.teamIds),
|
||||
getPostHogFeatureFlag(session.user.id, "customisations_in_settings"),
|
||||
]);
|
||||
const customisationsInSettings = customisationsInSettingsFlag === "in-settings";
|
||||
|
||||
if (
|
||||
!survey ||
|
||||
@@ -141,7 +138,6 @@ export const SurveyEditorPage = async (props: {
|
||||
quotas={quotas}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
publicDomain={publicDomain}
|
||||
customisationsInSettings={customisationsInSettings}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { TProjectStyling } from "@formbricks/types/project";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { CustomScriptsInjector } from "@/modules/survey/link/components/custom-scripts-injector";
|
||||
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
|
||||
@@ -134,13 +133,11 @@ export const SurveyClientWrapper = ({
|
||||
}
|
||||
setResponseData({});
|
||||
};
|
||||
const jsSurvey = useMemo(() => toJsEnvironmentStateSurvey(survey), [survey]);
|
||||
|
||||
// Determine text direction based on language code for logo positioning only
|
||||
// which checks both language code and survey content. This is only for logo UI positioning.
|
||||
const logoDir = useMemo(() => {
|
||||
return isRTLLanguage(jsSurvey, languageCode) ? "rtl" : "auto";
|
||||
}, [languageCode, jsSurvey]);
|
||||
return isRTLLanguage(survey, languageCode) ? "rtl" : "auto";
|
||||
}, [languageCode, survey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -172,7 +169,7 @@ export const SurveyClientWrapper = ({
|
||||
appUrl={publicDomain}
|
||||
environmentId={survey.environmentId}
|
||||
isPreviewMode={isPreview}
|
||||
survey={jsSurvey}
|
||||
survey={survey}
|
||||
styling={styling}
|
||||
languageCode={languageCode}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { TProjectStyling } from "@formbricks/types/project";
|
||||
import { TSurvey, TSurveyLanguage, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
|
||||
import { ClientLogo } from "@/modules/ui/components/client-logo";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -273,7 +272,7 @@ export const PreviewSurvey = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsEnvironmentStateSurvey(survey)}
|
||||
survey={survey}
|
||||
isBrandingEnabled={project.inAppSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
languageCode={languageCode}
|
||||
@@ -304,7 +303,7 @@ export const PreviewSurvey = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsEnvironmentStateSurvey({ ...survey, type: "link" })}
|
||||
survey={{ ...survey, type: "link" }}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
languageCode={languageCode}
|
||||
responseCount={42}
|
||||
@@ -389,7 +388,7 @@ export const PreviewSurvey = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsEnvironmentStateSurvey(survey)}
|
||||
survey={survey}
|
||||
isBrandingEnabled={project.inAppSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
languageCode={languageCode}
|
||||
@@ -424,7 +423,7 @@ export const PreviewSurvey = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsEnvironmentStateSurvey({ ...survey, type: "link" })}
|
||||
survey={{ ...survey, type: "link" }}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
languageCode={languageCode}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Fragment, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyType } from "@formbricks/types/surveys/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
|
||||
import { ClientLogo } from "@/modules/ui/components/client-logo";
|
||||
import { MediaBackground } from "@/modules/ui/components/media-background";
|
||||
import { Modal } from "@/modules/ui/components/preview-survey/components/modal";
|
||||
@@ -179,7 +178,7 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsEnvironmentStateSurvey({ ...survey, type: "app" })}
|
||||
survey={{ ...survey, type: "app" }}
|
||||
isBrandingEnabled={project.inAppSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
onFileUpload={async (file) => file.name}
|
||||
@@ -206,7 +205,7 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsEnvironmentStateSurvey({ ...survey, type: "link" })}
|
||||
survey={{ ...survey, type: "link" }}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
onFileUpload={async (file) => file.name}
|
||||
|
||||
@@ -18,7 +18,7 @@ metadata:
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if and (not .Values.autoscaling.enabled) (not (kindIs "invalid" .Values.deployment.replicas)) }}
|
||||
{{- if .Values.deployment.replicas }}
|
||||
replicas: {{ .Values.deployment.replicas }}
|
||||
{{- end }}
|
||||
selector:
|
||||
|
||||
@@ -83,7 +83,7 @@ deployment:
|
||||
# Additional pod annotations
|
||||
additionalPodAnnotations: {}
|
||||
|
||||
# Number of replicas when autoscaling is disabled
|
||||
# Number of replicas
|
||||
replicas: 1
|
||||
|
||||
# Image pull secrets for private container registries
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
"xm-and-surveys/surveys/link-surveys/data-prefilling",
|
||||
"xm-and-surveys/surveys/link-surveys/embed-surveys",
|
||||
"xm-and-surveys/surveys/link-surveys/link-settings",
|
||||
"xm-and-surveys/surveys/link-surveys/pretty-url",
|
||||
"xm-and-surveys/surveys/link-surveys/personal-links",
|
||||
"xm-and-surveys/surveys/link-surveys/single-use-links",
|
||||
"xm-and-surveys/surveys/link-surveys/source-tracking",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: "Pretty URL"
|
||||
description: "Create a custom, memorable URL for your survey instead of sharing a long auto-generated link."
|
||||
icon: "link"
|
||||
---
|
||||
|
||||
<Note>
|
||||
**Self-Hosted Only**: Pretty URLs are available exclusively on self-hosted Formbricks instances. This feature is not available on Formbricks Cloud.
|
||||
</Note>
|
||||
|
||||
## What is a Pretty URL?
|
||||
|
||||
By default, every survey is accessible at a URL containing its auto-generated ID, e.g. `yourdomain.com/s/cm1abc123xyz`. A Pretty URL lets you replace that with a short, human-readable slug of your choice:
|
||||
|
||||
```
|
||||
yourdomain.com/p/customer-feedback
|
||||
```
|
||||
|
||||
When someone visits the pretty URL, they are automatically redirected to the actual survey. Query parameters such as `suId` and `lang` are forwarded as well.
|
||||
|
||||
## Setting Up a Pretty URL
|
||||
|
||||
<Steps>
|
||||
<Step title="Open the Share Modal">
|
||||
Navigate to your survey's **Summary** page and click the **Share survey** button in the top toolbar.
|
||||
</Step>
|
||||
|
||||
<Step title="Go to the Pretty URL tab">
|
||||
In the Share Modal, select the **Pretty URL** tab.
|
||||
</Step>
|
||||
|
||||
<Step title="Enter a slug">
|
||||
Type your desired slug in the input field. Slugs may only contain **lowercase letters, numbers, and hyphens** (e.g. `customer-feedback`, `q4-nps-2024`).
|
||||
|
||||
The full URL is shown in real time below the input so you can confirm how it will look.
|
||||
</Step>
|
||||
|
||||
<Step title="Save">
|
||||
Click **Save**. The slug is now live. Anyone visiting the pretty URL is immediately redirected to your survey.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Managing Pretty URLs
|
||||
|
||||
Once a slug is saved, the Pretty URL tab shows the active link with two actions:
|
||||
|
||||
- **Copy**: copies the full pretty URL to your clipboard.
|
||||
- **Remove**: deletes the slug (after a confirmation prompt). The survey remains accessible via its original `/s/[surveyId]` URL.
|
||||
|
||||
## Viewing All Pretty URLs in Your Organization
|
||||
|
||||
All surveys that have a pretty URL assigned are listed in one place:
|
||||
|
||||
1. Go to **Organization Settings → Domain**.
|
||||
2. Open the **Pretty URLs** section.
|
||||
|
||||
The table shows each survey's name, workspace, slug, and environment type (production / development).
|
||||
|
||||
## Slug Rules
|
||||
|
||||
| Rule | Detail |
|
||||
|------|--------|
|
||||
| Characters | Lowercase letters (a-z), digits (0-9), and hyphens (-) |
|
||||
| Uniqueness | Must be unique across your entire Formbricks instance |
|
||||
| Format example | `customer-feedback`, `onboarding-survey`, `q4-nps` |
|
||||
|
||||
## Query Parameter Forwarding
|
||||
|
||||
Pretty URLs forward all query parameters to the destination survey URL. For example:
|
||||
|
||||
```
|
||||
/p/customer-feedback?suId=contact123&lang=de
|
||||
```
|
||||
|
||||
redirects to:
|
||||
|
||||
```
|
||||
/s/[surveyId]?suId=contact123&lang=de
|
||||
```
|
||||
|
||||
This means features like [single-use links](/xm-and-surveys/surveys/link-surveys/single-use-links), [data prefilling](/xm-and-surveys/surveys/link-surveys/data-prefilling), and [multi-language surveys](/xm-and-surveys/surveys/general-features/multi-language-surveys) all work with pretty URLs.
|
||||
@@ -243,9 +243,9 @@ export const setup = async (
|
||||
filteredSurveys,
|
||||
});
|
||||
|
||||
const surveyIds = filteredSurveys.map((s) => s.id);
|
||||
const surveyNames = filteredSurveys.map((s) => s.name);
|
||||
logger.debug(
|
||||
`${surveyIds.length.toString()} surveys could be shown to current user on trigger: ${surveyIds.join(", ")}`
|
||||
`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`
|
||||
);
|
||||
} catch {
|
||||
logger.debug("Error during sync. Please try again.");
|
||||
@@ -303,9 +303,9 @@ export const setup = async (
|
||||
filteredSurveys,
|
||||
});
|
||||
|
||||
const surveyIds = filteredSurveys.map((s) => s.id);
|
||||
const surveyNames = filteredSurveys.map((s) => s.name);
|
||||
logger.debug(
|
||||
`${surveyIds.length.toString()} surveys could be shown to current user on trigger: ${surveyIds.join(", ")}`
|
||||
`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`
|
||||
);
|
||||
} catch (e) {
|
||||
await handleErrorOnFirstSetup(e as { code: string; responseMessage: string });
|
||||
|
||||
@@ -279,52 +279,6 @@ describe("utils.ts", () => {
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("anonymous user: excludes segment-targeted surveys (new shape: hasFilters=true)", () => {
|
||||
environment.data.surveys = [
|
||||
{
|
||||
...baseSurvey,
|
||||
id: mockSurveyId1,
|
||||
segment: { id: mockSegmentId1, hasFilters: true },
|
||||
displayOption: "respondMultiple",
|
||||
} as TEnvironmentStateSurvey,
|
||||
{
|
||||
...baseSurvey,
|
||||
id: mockSurveyId2,
|
||||
segment: { id: mockSegmentId2, hasFilters: false },
|
||||
displayOption: "respondMultiple",
|
||||
} as TEnvironmentStateSurvey,
|
||||
];
|
||||
|
||||
const result = filterSurveys(environment, user);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(mockSurveyId2);
|
||||
});
|
||||
|
||||
test("anonymous user: excludes segment-targeted surveys when cached payload uses legacy shape (filters array)", () => {
|
||||
// Simulates a localStorage payload written by an older SDK version that
|
||||
// still has `segment.filters` and no `hasFilters`. The defensive check
|
||||
// must fall back to the legacy shape so anonymous users don't receive
|
||||
// segment-targeted surveys.
|
||||
environment.data.surveys = [
|
||||
{
|
||||
...baseSurvey,
|
||||
id: mockSurveyId1,
|
||||
segment: { id: mockSegmentId1, filters: [{ type: "attribute", value: "plan" }] },
|
||||
displayOption: "respondMultiple",
|
||||
} as unknown as TEnvironmentStateSurvey,
|
||||
{
|
||||
...baseSurvey,
|
||||
id: mockSurveyId2,
|
||||
segment: { id: mockSegmentId2, filters: [] },
|
||||
displayOption: "respondMultiple",
|
||||
} as unknown as TEnvironmentStateSurvey,
|
||||
];
|
||||
|
||||
const result = filterSurveys(environment, user);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(mockSurveyId2);
|
||||
});
|
||||
|
||||
test("filters by segment if userId is set and user has segments", () => {
|
||||
user.data.userId = "user_abc";
|
||||
user.data.segments = [mockSegmentId1];
|
||||
|
||||
@@ -53,19 +53,6 @@ export const wrapThrowsAsync =
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect whether a survey's segment has filters. Handles both the current
|
||||
* minimal shape (`{ id, hasFilters }`) and the legacy shape with a `filters`
|
||||
* array — older SDKs cached the full segment in localStorage and may still be
|
||||
* read back here within the cache window after an SDK upgrade.
|
||||
*/
|
||||
export const surveyHasSegmentFilters = (survey: TEnvironmentStateSurvey): boolean => {
|
||||
const segment = survey.segment as { hasFilters?: boolean; filters?: unknown[] } | null | undefined;
|
||||
if (!segment) return false;
|
||||
if (typeof segment.hasFilters === "boolean") return segment.hasFilters;
|
||||
return Array.isArray(segment.filters) && segment.filters.length > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters surveys based on the displayOption, recontactDays, and segments
|
||||
* @param environmentSate - The environment state
|
||||
@@ -136,7 +123,8 @@ export const filterSurveys = (
|
||||
if (!userId) {
|
||||
// exclude surveys that have a segment with filters
|
||||
return filteredSurveys.filter((survey) => {
|
||||
return !surveyHasSegmentFilters(survey);
|
||||
const segmentFiltersLength = survey.segment?.filters.length ?? 0;
|
||||
return segmentFiltersLength === 0;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export const mockEnvironmentId = "n48a66c01dz05k1297vq06pu";
|
||||
|
||||
export const mockSurvey: TEnvironmentStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: "Test Survey",
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
timeToFinish: false,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import type * as CommonUtils from "@/lib/common/utils";
|
||||
import { filterSurveys, getLanguageCode, shouldDisplayBasedOnPercentage } from "@/lib/common/utils";
|
||||
import { mockSurvey } from "@/lib/survey/tests/__mocks__/widget.mock";
|
||||
import * as widget from "@/lib/survey/widget";
|
||||
@@ -35,18 +34,14 @@ vi.mock("@/lib/common/timeout-stack", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/common/utils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof CommonUtils>();
|
||||
return {
|
||||
...actual,
|
||||
filterSurveys: vi.fn(),
|
||||
getLanguageCode: vi.fn(),
|
||||
getStyling: vi.fn(),
|
||||
shouldDisplayBasedOnPercentage: vi.fn(),
|
||||
wrapThrowsAsync: vi.fn(),
|
||||
handleHiddenFields: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/common/utils", () => ({
|
||||
filterSurveys: vi.fn(),
|
||||
getLanguageCode: vi.fn(),
|
||||
getStyling: vi.fn(),
|
||||
shouldDisplayBasedOnPercentage: vi.fn(),
|
||||
wrapThrowsAsync: vi.fn(),
|
||||
handleHiddenFields: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUpdateQueue = {
|
||||
hasPendingWork: vi.fn().mockReturnValue(false),
|
||||
@@ -72,6 +67,7 @@ describe("widget-file", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
// @ts-expect-error -- cleaning up mock
|
||||
delete window.formbricksSurveys;
|
||||
|
||||
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
|
||||
@@ -93,7 +89,7 @@ describe("widget-file", () => {
|
||||
await widget.triggerSurvey(mockSurvey);
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
`Survey display of "${mockSurvey.id}" skipped based on displayPercentage.`
|
||||
`Survey display of "${mockSurvey.name}" skipped based on displayPercentage.`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -149,7 +145,7 @@ describe("widget-file", () => {
|
||||
await widget.renderWidget(mockSurvey);
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
`Delaying survey "${mockSurvey.id}" by ${mockSurvey.delay.toString()} seconds.`
|
||||
`Delaying survey "${mockSurvey.name}" by ${mockSurvey.delay.toString()} seconds.`
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(mockSurvey.delay * 1000);
|
||||
@@ -213,7 +209,7 @@ describe("widget-file", () => {
|
||||
await widget.renderWidget(mockSurveyNoDelay as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
`Survey "${mockSurvey.id}" is not available in specified language.`
|
||||
`Survey "${mockSurvey.name}" is not available in specified language.`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -460,7 +456,7 @@ describe("widget-file", () => {
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
segment: { id: "seg_1", hasFilters: true },
|
||||
segment: { id: "seg_1", filters: [{ type: "attribute", value: "plan" }] },
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
expect(mockUpdateQueue.waitForPendingWork).toHaveBeenCalled();
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
getStyling,
|
||||
handleHiddenFields,
|
||||
shouldDisplayBasedOnPercentage,
|
||||
surveyHasSegmentFilters,
|
||||
} from "@/lib/common/utils";
|
||||
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||
import { type TEnvironmentStateSurvey, type TUserState } from "@/types/config";
|
||||
@@ -33,7 +32,7 @@ export const triggerSurvey = async (
|
||||
if (survey.displayPercentage) {
|
||||
const shouldDisplaySurvey = shouldDisplayBasedOnPercentage(survey.displayPercentage);
|
||||
if (!shouldDisplaySurvey) {
|
||||
logger.debug(`Survey display of "${survey.id}" skipped based on displayPercentage.`);
|
||||
logger.debug(`Survey display of "${survey.name}" skipped based on displayPercentage.`);
|
||||
return; // skip displaying the survey
|
||||
}
|
||||
}
|
||||
@@ -68,7 +67,7 @@ export const renderWidget = async (
|
||||
logger.debug("Waiting for pending user identification before rendering survey");
|
||||
const identificationSucceeded = await updateQueue.waitForPendingWork();
|
||||
if (!identificationSucceeded) {
|
||||
const hasSegmentFilters = surveyHasSegmentFilters(survey);
|
||||
const hasSegmentFilters = Array.isArray(survey.segment?.filters) && survey.segment.filters.length > 0;
|
||||
|
||||
if (hasSegmentFilters) {
|
||||
logger.debug("User identification failed. Skipping survey with segment filters.");
|
||||
@@ -81,7 +80,7 @@ export const renderWidget = async (
|
||||
}
|
||||
|
||||
if (survey.delay) {
|
||||
logger.debug(`Delaying survey "${survey.id}" by ${survey.delay.toString()} seconds.`);
|
||||
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay.toString()} seconds.`);
|
||||
}
|
||||
|
||||
const { project } = config.get().environment.data;
|
||||
@@ -94,7 +93,7 @@ export const renderWidget = async (
|
||||
const displayLanguage = getLanguageCode(survey, language);
|
||||
//if survey is not available in selected language, survey wont be shown
|
||||
if (!displayLanguage) {
|
||||
logger.debug(`Survey "${survey.id}" is not available in specified language.`);
|
||||
logger.debug(`Survey "${survey.name}" is not available in specified language.`);
|
||||
setIsSurveyRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies -- required for Prisma types */
|
||||
import type { ActionClass, Language, Project, Survey, SurveyLanguage } from "@prisma/client";
|
||||
import type { ActionClass, Language, Project, Segment, Survey, SurveyLanguage } from "@prisma/client";
|
||||
|
||||
export type TEnvironmentStateSurvey = Pick<
|
||||
Survey,
|
||||
| "id"
|
||||
// name intentionally omitted — internal label, not needed by SDK
|
||||
| "name"
|
||||
| "welcomeCard"
|
||||
| "questions"
|
||||
| "variables"
|
||||
@@ -25,8 +25,7 @@ export type TEnvironmentStateSurvey = Pick<
|
||||
> & {
|
||||
languages: (SurveyLanguage & { language: Language })[];
|
||||
triggers: { actionClass: ActionClass }[];
|
||||
// Minimal segment shape — full filter logic is evaluated server-side and must not reach the browser
|
||||
segment?: { id: string; hasFilters: boolean };
|
||||
segment?: Segment;
|
||||
displayPercentage: number;
|
||||
type: "link" | "app";
|
||||
styling?: TSurveyStyling;
|
||||
|
||||
@@ -67,15 +67,12 @@ function OpenText({
|
||||
);
|
||||
};
|
||||
|
||||
const descriptionId = description ? `${inputId}-description` : undefined;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
descriptionId={descriptionId}
|
||||
required={required}
|
||||
requiredLabel={requiredLabel}
|
||||
htmlFor={inputId}
|
||||
@@ -93,7 +90,6 @@ function OpenText({
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
aria-required={required}
|
||||
aria-describedby={descriptionId}
|
||||
dir={dir}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
@@ -109,7 +105,6 @@ function OpenText({
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
aria-required={required}
|
||||
aria-describedby={descriptionId}
|
||||
dir={dir}
|
||||
disabled={disabled}
|
||||
errorMessage={errorMessage}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { cn, stripInlineStyles } from "@/lib/utils";
|
||||
interface ElementHeaderProps extends React.ComponentProps<"div"> {
|
||||
headline: string;
|
||||
description?: string;
|
||||
descriptionId?: string;
|
||||
required?: boolean;
|
||||
/** Custom label for the required indicator. Defaults to "Required" */
|
||||
requiredLabel?: string;
|
||||
@@ -45,7 +44,6 @@ const isValidHTML = (str: string): boolean => {
|
||||
function ElementHeader({
|
||||
headline,
|
||||
description,
|
||||
descriptionId,
|
||||
required = false,
|
||||
requiredLabel = "Required",
|
||||
htmlFor,
|
||||
@@ -93,7 +91,7 @@ function ElementHeader({
|
||||
|
||||
{/* Description/Subheader */}
|
||||
{description ? (
|
||||
<Label id={descriptionId} variant="description">
|
||||
<Label htmlFor={htmlFor} variant="description">
|
||||
{description}
|
||||
</Label>
|
||||
) : null}
|
||||
|
||||
@@ -63,11 +63,7 @@ export function CalElement({
|
||||
elementId={element.id}
|
||||
/>
|
||||
<CalEmbed key={element.id} element={element} onSuccessfulBooking={onSuccessfulBooking} />
|
||||
{errorMessage ? (
|
||||
<span className="text-red-500" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
{errorMessage}
|
||||
</span>
|
||||
) : null}
|
||||
{errorMessage ? <span className="text-red-500">{errorMessage}</span> : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -61,7 +61,7 @@ export function OpenTextElement({
|
||||
<form key={element.id} onSubmit={handleOnSubmit} className="w-full">
|
||||
<OpenText
|
||||
elementId={element.id}
|
||||
inputId={`${element.id}-input`}
|
||||
inputId={element.id}
|
||||
headline={getLocalizedValue(element.headline, languageCode)}
|
||||
description={element.subheader ? getLocalizedValue(element.subheader, languageCode) : undefined}
|
||||
placeholder={getLocalizedValue(element.placeholder, languageCode)}
|
||||
|
||||
@@ -75,11 +75,7 @@ export function ElementMedia({ imgUrl, videoUrl, altText = "Image", className }:
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={t("common.open_in_new_tab")}
|
||||
className={cn(
|
||||
"absolute right-2 bottom-2 flex items-center gap-2 rounded-md bg-slate-800/40 p-1.5",
|
||||
"text-white backdrop-blur-lg transition duration-300 ease-in-out",
|
||||
"opacity-0 group-hover/image:opacity-100 hover:bg-slate-800/65 focus:opacity-100"
|
||||
)}>
|
||||
className="absolute right-2 bottom-2 flex items-center gap-2 rounded-md bg-slate-800/40 p-1.5 text-white opacity-0 backdrop-blur-lg transition duration-300 ease-in-out group-hover/image:opacity-100 hover:bg-slate-800/65">
|
||||
{imgUrl ? <ImageDownIcon size={20} /> : <ExpandIcon size={20} />}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -88,6 +88,7 @@ export function LanguageSwitch({
|
||||
borderRadius: typeof borderRadius === "number" ? `${borderRadius}px` : borderRadius,
|
||||
}}
|
||||
onClick={toggleDropdown}
|
||||
tabIndex={-1}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={showLanguageDropdown}
|
||||
aria-label={t("common.language_switch")}
|
||||
|
||||
+5
-13
@@ -2,13 +2,12 @@ import { z } from "zod";
|
||||
import { ZActionClass } from "./action-classes";
|
||||
import { ZId } from "./common";
|
||||
import { ZProject } from "./project";
|
||||
import { ZJsEnvironmentStateSegment } from "./segment";
|
||||
import { ZUploadFileConfig } from "./storage";
|
||||
import { ZSurveyBase, surveyRefinement } from "./surveys/types";
|
||||
|
||||
export const ZJsEnvironmentStateSurvey = ZSurveyBase.pick({
|
||||
id: true,
|
||||
// name intentionally omitted — internal label, not needed by SDK
|
||||
name: true,
|
||||
welcomeCard: true,
|
||||
questions: true,
|
||||
blocks: true,
|
||||
@@ -20,7 +19,7 @@ export const ZJsEnvironmentStateSurvey = ZSurveyBase.pick({
|
||||
autoClose: true,
|
||||
styling: true,
|
||||
status: true,
|
||||
// segment intentionally omitted from pick — replaced with minimal shape below
|
||||
segment: true,
|
||||
recontactDays: true,
|
||||
displayLimit: true,
|
||||
displayOption: true,
|
||||
@@ -32,16 +31,9 @@ export const ZJsEnvironmentStateSurvey = ZSurveyBase.pick({
|
||||
isBackButtonHidden: true,
|
||||
isAutoProgressingEnabled: true,
|
||||
recaptcha: true,
|
||||
})
|
||||
.extend({
|
||||
// Only expose what the SDK needs: segment ID for membership check + whether any filters exist.
|
||||
// Full filter logic (titles, descriptions, conditions) is evaluated server-side and must not
|
||||
// be sent to the browser to avoid leaking sensitive targeting data.
|
||||
segment: ZJsEnvironmentStateSegment.nullable(),
|
||||
})
|
||||
.superRefine((survey, ctx) => {
|
||||
surveyRefinement(survey as z.infer<typeof ZSurveyBase>, ctx);
|
||||
});
|
||||
}).superRefine((survey, ctx) => {
|
||||
surveyRefinement(survey as z.infer<typeof ZSurveyBase>, ctx);
|
||||
});
|
||||
|
||||
export type TJsEnvironmentStateSurvey = z.infer<typeof ZJsEnvironmentStateSurvey>;
|
||||
|
||||
|
||||
@@ -349,13 +349,6 @@ export const ZSegment = z.object({
|
||||
surveys: z.array(z.string()),
|
||||
});
|
||||
|
||||
// Minimal segment shape for the public client API — strips sensitive targeting logic
|
||||
export const ZJsEnvironmentStateSegment = z.object({
|
||||
id: z.string(),
|
||||
hasFilters: z.boolean(),
|
||||
});
|
||||
export type TJsEnvironmentStateSegment = z.infer<typeof ZJsEnvironmentStateSegment>;
|
||||
|
||||
export const ZSegmentCreateInput = z.object({
|
||||
environmentId: z.string(),
|
||||
title: z.string(),
|
||||
|
||||
Reference in New Issue
Block a user