Compare commits

...

4 Commits

Author SHA1 Message Date
Matti Nannt
f08fabfb13 fix(backport): github release action fix (#6425) 2025-08-15 13:26:09 +02:00
Dhruwang Jariwala
ee8af9dd74 chore: metadata tweaks backport (#6421) 2025-08-15 13:02:28 +02:00
Dhruwang Jariwala
1091b40bd1 fix(backport): cross button hover (#6416) 2025-08-14 14:30:05 +02:00
Anshuman Pandey
87a2d727ed fix: disables tabs when single use is enabled [Backport] (#6412) 2025-08-14 04:07:35 -07:00
12 changed files with 87 additions and 67 deletions

View File

@@ -37,7 +37,7 @@ on:
permissions: permissions:
id-token: write id-token: write
contents: write contents: read
jobs: jobs:
helmfile-deploy: helmfile-deploy:

View File

@@ -7,12 +7,13 @@ on:
permissions: permissions:
contents: read contents: read
env:
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
jobs: jobs:
docker-build: docker-build:
name: Build & release docker image name: Build & release docker image
permissions:
contents: read
packages: write
id-token: write
uses: ./.github/workflows/release-docker-github.yml uses: ./.github/workflows/release-docker-github.yml
secrets: inherit secrets: inherit
with: with:
@@ -20,6 +21,9 @@ jobs:
helm-chart-release: helm-chart-release:
name: Release Helm Chart name: Release Helm Chart
permissions:
contents: read
packages: write
uses: ./.github/workflows/release-helm-chart.yml uses: ./.github/workflows/release-helm-chart.yml
secrets: inherit secrets: inherit
needs: needs:
@@ -29,6 +33,9 @@ jobs:
deploy-formbricks-cloud: deploy-formbricks-cloud:
name: Deploy Helm Chart to Formbricks Cloud name: Deploy Helm Chart to Formbricks Cloud
permissions:
contents: read
id-token: write
secrets: inherit secrets: inherit
uses: ./.github/workflows/deploy-formbricks-cloud.yml uses: ./.github/workflows/deploy-formbricks-cloud.yml
needs: needs:
@@ -36,7 +43,7 @@ jobs:
- helm-chart-release - helm-chart-release
with: with:
VERSION: v${{ needs.docker-build.outputs.VERSION }} VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: ${{ env.ENVIRONMENT }} ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
upload-sentry-sourcemaps: upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps name: Upload Sentry Sourcemaps
@@ -64,4 +71,4 @@ jobs:
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }} docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
release_version: v${{ needs.docker-build.outputs.VERSION }} release_version: v${{ needs.docker-build.outputs.VERSION }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }} sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
environment: ${{ env.ENVIRONMENT }} environment: ${{ github.event.release.prerelease && 'staging' || 'production' }}

View File

@@ -29,7 +29,7 @@ import {
SquareStack, SquareStack,
UserIcon, UserIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { TSegment } from "@formbricks/types/segment"; import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
@@ -77,6 +77,7 @@ export const ShareSurveyModal = ({
description: string; description: string;
componentType: React.ComponentType<unknown>; componentType: React.ComponentType<unknown>;
componentProps: unknown; componentProps: unknown;
disabled?: boolean;
}[] = useMemo( }[] = useMemo(
() => [ () => [
{ {
@@ -111,6 +112,7 @@ export const ShareSurveyModal = ({
isContactsEnabled, isContactsEnabled,
isFormbricksCloud, isFormbricksCloud,
}, },
disabled: survey.singleUse?.enabled,
}, },
{ {
id: ShareViaType.WEBSITE_EMBED, id: ShareViaType.WEBSITE_EMBED,
@@ -121,6 +123,7 @@ export const ShareSurveyModal = ({
description: t("environments.surveys.share.embed_on_website.description"), description: t("environments.surveys.share.embed_on_website.description"),
componentType: WebsiteEmbedTab, componentType: WebsiteEmbedTab,
componentProps: { surveyUrl }, componentProps: { surveyUrl },
disabled: survey.singleUse?.enabled,
}, },
{ {
id: ShareViaType.EMAIL, id: ShareViaType.EMAIL,
@@ -131,6 +134,7 @@ export const ShareSurveyModal = ({
description: t("environments.surveys.share.send_email.description"), description: t("environments.surveys.share.send_email.description"),
componentType: EmailTab, componentType: EmailTab,
componentProps: { surveyId: survey.id, email }, componentProps: { surveyId: survey.id, email },
disabled: survey.singleUse?.enabled,
}, },
{ {
id: ShareViaType.SOCIAL_MEDIA, id: ShareViaType.SOCIAL_MEDIA,
@@ -141,6 +145,7 @@ export const ShareSurveyModal = ({
description: t("environments.surveys.share.social_media.description"), description: t("environments.surveys.share.social_media.description"),
componentType: SocialMediaTab, componentType: SocialMediaTab,
componentProps: { surveyUrl, surveyTitle: survey.name }, componentProps: { surveyUrl, surveyTitle: survey.name },
disabled: survey.singleUse?.enabled,
}, },
{ {
id: ShareViaType.QR_CODE, id: ShareViaType.QR_CODE,
@@ -151,6 +156,7 @@ export const ShareSurveyModal = ({
description: t("environments.surveys.summary.qr_code_description"), description: t("environments.surveys.summary.qr_code_description"),
componentType: QRCodeTab, componentType: QRCodeTab,
componentProps: { surveyUrl }, componentProps: { surveyUrl },
disabled: survey.singleUse?.enabled,
}, },
{ {
id: ShareViaType.DYNAMIC_POPUP, id: ShareViaType.DYNAMIC_POPUP,
@@ -177,9 +183,9 @@ export const ShareSurveyModal = ({
t, t,
survey, survey,
publicDomain, publicDomain,
setSurveyUrl,
user.locale, user.locale,
surveyUrl, surveyUrl,
isReadOnly,
environmentId, environmentId,
segments, segments,
isContactsEnabled, isContactsEnabled,
@@ -188,9 +194,14 @@ export const ShareSurveyModal = ({
] ]
); );
const [activeId, setActiveId] = useState<ShareViaType | ShareSettingsType>( const getDefaultActiveId = useCallback(() => {
survey.type === "link" ? ShareViaType.ANON_LINKS : ShareViaType.APP if (survey.type !== "link") {
); return ShareViaType.APP;
}
return ShareViaType.ANON_LINKS;
}, [survey.type]);
const [activeId, setActiveId] = useState<ShareViaType | ShareSettingsType>(getDefaultActiveId());
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -198,11 +209,19 @@ export const ShareSurveyModal = ({
} }
}, [open, modalView]); }, [open, modalView]);
// Ensure active tab is not disabled - if it is, switch to default
useEffect(() => {
const activeTab = linkTabs.find((tab) => tab.id === activeId);
if (activeTab?.disabled) {
setActiveId(getDefaultActiveId());
}
}, [activeId, linkTabs, getDefaultActiveId]);
const handleOpenChange = (open: boolean) => { const handleOpenChange = (open: boolean) => {
setOpen(open); setOpen(open);
if (!open) { if (!open) {
setShowView("start"); setShowView("start");
setActiveId(ShareViaType.ANON_LINKS); setActiveId(getDefaultActiveId());
} }
}; };

View File

@@ -150,13 +150,13 @@ export const LinkSettingsTab = ({ isReadOnly, locale }: LinkSettingsTabProps) =>
name: "title", name: "title",
label: t("environments.surveys.share.link_settings.link_title"), label: t("environments.surveys.share.link_settings.link_title"),
description: t("environments.surveys.share.link_settings.link_title_description"), description: t("environments.surveys.share.link_settings.link_title_description"),
placeholder: t("environments.surveys.share.link_settings.link_title_placeholder"), placeholder: survey.name,
}, },
{ {
name: "description", name: "description",
label: t("environments.surveys.share.link_settings.link_description"), label: t("environments.surveys.share.link_settings.link_description"),
description: t("environments.surveys.share.link_settings.link_description_description"), description: t("environments.surveys.share.link_settings.link_description_description"),
placeholder: t("environments.surveys.share.link_settings.link_description_placeholder"), placeholder: "Please complete this survey.",
}, },
]; ];

View File

@@ -34,6 +34,7 @@ interface ShareViewProps {
componentProps: any; componentProps: any;
title: string; title: string;
description?: string; description?: string;
disabled?: boolean;
}>; }>;
activeId: ShareViaType | ShareSettingsType; activeId: ShareViaType | ShareSettingsType;
setActiveId: React.Dispatch<React.SetStateAction<ShareViaType | ShareSettingsType>>; setActiveId: React.Dispatch<React.SetStateAction<ShareViaType | ShareSettingsType>>;
@@ -109,12 +110,13 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
onClick={() => setActiveId(tab.id)} onClick={() => setActiveId(tab.id)}
className={cn( className={cn(
"flex w-full justify-start rounded-md p-2 text-slate-600 hover:bg-slate-100 hover:text-slate-900", "flex w-full justify-start rounded-md p-2 text-slate-600 hover:bg-slate-100 hover:text-slate-900",
tab.id === activeId tab.id === activeId && !tab.disabled
? "bg-slate-100 font-medium text-slate-900" ? "bg-slate-100 font-medium text-slate-900"
: "text-slate-700" : "text-slate-700"
)} )}
tooltip={tab.label} tooltip={tab.label}
isActive={tab.id === activeId}> isActive={tab.id === activeId}
disabled={tab.disabled}>
<tab.icon className="h-4 w-4 text-slate-700" /> <tab.icon className="h-4 w-4 text-slate-700" />
<span>{tab.label}</span> <span>{tab.label}</span>
</SidebarMenuButton> </SidebarMenuButton>
@@ -136,9 +138,10 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => setActiveId(tab.id)} onClick={() => setActiveId(tab.id)}
disabled={tab.disabled}
className={cn( className={cn(
"rounded-md px-4 py-2", "rounded-md px-4 py-2",
tab.id === activeId tab.id === activeId && !tab.disabled
? "bg-white text-slate-900 shadow-sm hover:bg-white" ? "bg-white text-slate-900 shadow-sm hover:bg-white"
: "border-transparent text-slate-700 hover:text-slate-900" : "border-transparent text-slate-700 hover:text-slate-900"
)}> )}>

View File

@@ -92,7 +92,7 @@ describe("contact-survey page", () => {
params: Promise.resolve({ jwt: "token" }), params: Promise.resolve({ jwt: "token" }),
searchParams: Promise.resolve({}), searchParams: Promise.resolve({}),
}); });
expect(meta).toEqual({ title: "Survey", description: "Complete this survey" }); expect(meta).toEqual({ title: "Survey", description: "Please complete this survey." });
}); });
test("generateMetadata returns default when verify throws", async () => { test("generateMetadata returns default when verify throws", async () => {
@@ -103,7 +103,7 @@ describe("contact-survey page", () => {
params: Promise.resolve({ jwt: "token" }), params: Promise.resolve({ jwt: "token" }),
searchParams: Promise.resolve({}), searchParams: Promise.resolve({}),
}); });
expect(meta).toEqual({ title: "Survey", description: "Complete this survey" }); expect(meta).toEqual({ title: "Survey", description: "Please complete this survey." });
}); });
test("generateMetadata returns basic metadata when token valid", async () => { test("generateMetadata returns basic metadata when token valid", async () => {

View File

@@ -31,7 +31,7 @@ export const generateMetadata = async (props: ContactSurveyPageProps): Promise<M
if (!result.ok) { if (!result.ok) {
return { return {
title: "Survey", title: "Survey",
description: "Complete this survey", description: "Please complete this survey.",
}; };
} }
const { surveyId } = result.data; const { surveyId } = result.data;
@@ -40,7 +40,7 @@ export const generateMetadata = async (props: ContactSurveyPageProps): Promise<M
// If the token is invalid, we'll return generic metadata // If the token is invalid, we'll return generic metadata
return { return {
title: "Survey", title: "Survey",
description: "Complete this survey", description: "Please complete this survey.",
}; };
} }
}; };

View File

@@ -77,7 +77,7 @@ describe("Metadata Utils", () => {
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId); expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(result).toEqual({ expect(result).toEqual({
title: "Survey", title: "Survey",
description: "Complete this survey", description: "Please complete this survey.",
survey: null, survey: null,
ogImage: undefined, ogImage: undefined,
}); });
@@ -108,10 +108,9 @@ describe("Metadata Utils", () => {
const result = await getBasicSurveyMetadata(mockSurveyId); const result = await getBasicSurveyMetadata(mockSurveyId);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId); expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getProjectByEnvironmentId).toHaveBeenCalledWith(mockEnvironmentId);
expect(result).toEqual({ expect(result).toEqual({
title: "Welcome Headline | Test Project", title: "Welcome Headline",
description: "Complete this survey", description: "Please complete this survey.",
survey: mockSurvey, survey: mockSurvey,
ogImage: undefined, ogImage: undefined,
}); });
@@ -129,13 +128,12 @@ describe("Metadata Utils", () => {
} as TSurvey; } as TSurvey;
vi.mocked(getSurvey).mockResolvedValue(mockSurvey); vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue({ name: "Test Project" } as any);
const result = await getBasicSurveyMetadata(mockSurveyId); const result = await getBasicSurveyMetadata(mockSurveyId);
expect(result).toEqual({ expect(result).toEqual({
title: "Test Survey | Test Project", title: "Test Survey",
description: "Complete this survey", description: "Please complete this survey.",
survey: mockSurvey, survey: mockSurvey,
ogImage: undefined, ogImage: undefined,
}); });

View File

@@ -1,8 +1,8 @@
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { COLOR_DEFAULTS } from "@/lib/styling/constants"; import { COLOR_DEFAULTS } from "@/lib/styling/constants";
import { getSurvey } from "@/modules/survey/lib/survey"; import { getSurvey } from "@/modules/survey/lib/survey";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { Metadata } from "next"; import { Metadata } from "next";
type TBasicSurveyMetadata = { type TBasicSurveyMetadata = {
@@ -12,22 +12,16 @@ type TBasicSurveyMetadata = {
ogImage?: string; ogImage?: string;
}; };
/** export const getNameForURL = (value: string) => encodeURIComponent(value);
* Utility function to encode name for URL usage
*/
export const getNameForURL = (url: string) => url.replace(/ /g, "%20");
/** export const getBrandColorForURL = (value: string) => encodeURIComponent(value);
* Utility function to encode brand color for URL usage
*/
export const getBrandColorForURL = (url: string) => url.replace(/#/g, "%23");
/** /**
* Get basic survey metadata (title and description) based on link metadata, welcome card or survey name * Get basic survey metadata (title and description) based on link metadata, welcome card or survey name
*/ */
export const getBasicSurveyMetadata = async ( export const getBasicSurveyMetadata = async (
surveyId: string, surveyId: string,
languageCode?: string languageCode = "default"
): Promise<TBasicSurveyMetadata> => { ): Promise<TBasicSurveyMetadata> => {
const survey = await getSurvey(surveyId); const survey = await getSurvey(surveyId);
@@ -35,7 +29,7 @@ export const getBasicSurveyMetadata = async (
if (!survey) { if (!survey) {
return { return {
title: "Survey", title: "Survey",
description: "Complete this survey", description: "Please complete this survey.",
survey: null, survey: null,
ogImage: undefined, ogImage: undefined,
}; };
@@ -43,38 +37,33 @@ export const getBasicSurveyMetadata = async (
const metadata = survey.metadata; const metadata = survey.metadata;
const welcomeCard = survey.welcomeCard; const welcomeCard = survey.welcomeCard;
const useDefaultLanguageCode =
languageCode === "default" ||
survey.languages.find((lang) => lang.language.code === languageCode)?.default;
// Determine language code to use for metadata // Determine language code to use for metadata
const langCode = languageCode || "default"; const langCode = useDefaultLanguageCode ? "default" : languageCode;
// Set title - priority: custom link metadata > welcome card > survey name // Set title - priority: custom link metadata > welcome card > survey name
let title = "Survey"; const titleFromMetadata = metadata?.title ? getLocalizedValue(metadata.title, langCode) || "" : undefined;
if (metadata.title?.[langCode]) { const titleFromWelcome =
title = metadata.title[langCode]; welcomeCard?.enabled && welcomeCard.headline
} else if (welcomeCard.enabled && welcomeCard.headline?.default) { ? getLocalizedValue(welcomeCard.headline, langCode) || ""
title = welcomeCard.headline.default; : undefined;
} else { let title = titleFromMetadata || titleFromWelcome || survey.name;
title = survey.name;
}
// Set description - priority: custom link metadata > welcome card > default // Set description - priority: custom link metadata > welcome card > default
let description = "Complete this survey"; const descriptionFromMetadata = metadata?.description
if (metadata.description?.[langCode]) { ? getLocalizedValue(metadata.description, langCode) || ""
description = metadata.description[langCode]; : undefined;
} let description = descriptionFromMetadata || "Please complete this survey.";
// Get OG image from link metadata if available // Get OG image from link metadata if available
const { ogImage } = metadata; const { ogImage } = metadata;
// Add product name in title if it's Formbricks cloud and not using custom metadata if (!titleFromMetadata) {
if (!metadata.title?.[langCode]) {
if (IS_FORMBRICKS_CLOUD) { if (IS_FORMBRICKS_CLOUD) {
title = `${title} | Formbricks`; title = `${title} | Formbricks`;
} else {
const project = await getProjectByEnvironmentId(survey.environmentId);
if (project) {
title = `${title} | ${project.name}`;
}
} }
} }
@@ -89,10 +78,13 @@ export const getBasicSurveyMetadata = async (
/** /**
* Generate Open Graph metadata for survey * Generate Open Graph metadata for survey
*/ */
export const getSurveyOpenGraphMetadata = (surveyId: string, surveyName: string): Metadata => { export const getSurveyOpenGraphMetadata = (
const brandColor = getBrandColorForURL(COLOR_DEFAULTS.brandColor); // Default color surveyId: string,
surveyName: string,
surveyBrandColor?: string
): Metadata => {
const encodedName = getNameForURL(surveyName); const encodedName = getNameForURL(surveyName);
const brandColor = getBrandColorForURL(surveyBrandColor ?? COLOR_DEFAULTS.brandColor);
const ogImgURL = `/api/v1/client/og?brandColor=${brandColor}&name=${encodedName}`; const ogImgURL = `/api/v1/client/og?brandColor=${brandColor}&name=${encodedName}`;
return { return {

View File

@@ -20,7 +20,7 @@ vi.mock("./lib/metadata-utils", () => ({
describe("getMetadataForLinkSurvey", () => { describe("getMetadataForLinkSurvey", () => {
const mockSurveyId = "survey-123"; const mockSurveyId = "survey-123";
const mockSurveyName = "Test Survey"; const mockSurveyName = "Test Survey";
const mockDescription = "Complete this survey"; const mockDescription = "Please complete this survey.";
const mockOgImageUrl = "https://example.com/custom-image.png"; const mockOgImageUrl = "https://example.com/custom-image.png";
beforeEach(() => { beforeEach(() => {
@@ -60,7 +60,7 @@ describe("getMetadataForLinkSurvey", () => {
expect(getSurveyMetadata).toHaveBeenCalledWith(mockSurveyId); expect(getSurveyMetadata).toHaveBeenCalledWith(mockSurveyId);
expect(getBasicSurveyMetadata).toHaveBeenCalledWith(mockSurveyId, undefined); expect(getBasicSurveyMetadata).toHaveBeenCalledWith(mockSurveyId, undefined);
expect(getSurveyOpenGraphMetadata).toHaveBeenCalledWith(mockSurveyId, mockSurveyName); expect(getSurveyOpenGraphMetadata).toHaveBeenCalledWith(mockSurveyId, mockSurveyName, undefined);
expect(result).toEqual({ expect(result).toEqual({
title: mockSurveyName, title: mockSurveyName,

View File

@@ -15,9 +15,10 @@ export const getMetadataForLinkSurvey = async (
// Get enhanced metadata that includes custom link metadata // Get enhanced metadata that includes custom link metadata
const { title, description, ogImage } = await getBasicSurveyMetadata(surveyId, languageCode); const { title, description, ogImage } = await getBasicSurveyMetadata(surveyId, languageCode);
const surveyBrandColor = survey.styling?.brandColor?.light;
// Use the shared function for creating the base metadata but override with custom data // Use the shared function for creating the base metadata but override with custom data
const baseMetadata = getSurveyOpenGraphMetadata(survey.id, title); const baseMetadata = getSurveyOpenGraphMetadata(survey.id, title, surveyBrandColor);
// Override with the custom image URL // Override with the custom image URL
if (baseMetadata.openGraph) { if (baseMetadata.openGraph) {

View File

@@ -765,7 +765,7 @@ export function Survey({
<LanguageSwitch <LanguageSwitch
surveyLanguages={localSurvey.languages} surveyLanguages={localSurvey.languages}
setSelectedLanguageCode={setselectedLanguage} setSelectedLanguageCode={setselectedLanguage}
hoverColor={styling.inputColor?.light ?? "#000000"} hoverColor={styling.inputColor?.light ?? "#f8fafc"}
borderRadius={styling.roundness ?? 8} borderRadius={styling.roundness ?? 8}
/> />
)} )}
@@ -776,7 +776,7 @@ export function Survey({
{isCloseButtonVisible && ( {isCloseButtonVisible && (
<SurveyCloseButton <SurveyCloseButton
onClose={onClose} onClose={onClose}
hoverColor={styling.inputColor?.light ?? "#000000"} hoverColor={styling.inputColor?.light ?? "#f8fafc"}
borderRadius={styling.roundness ?? 8} borderRadius={styling.roundness ?? 8}
/> />
)} )}