diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx deleted file mode 100644 index ac9006e1c1..0000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx +++ /dev/null @@ -1,199 +0,0 @@ -"use client"; - -import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink"; -import { getSurveyUrl } from "@/modules/analysis/utils"; -import { Badge } from "@/modules/ui/components/badge"; -import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/modules/ui/components/dialog"; -import { useTranslate } from "@tolgee/react"; -import { - BellRing, - BlocksIcon, - Code2Icon, - LinkIcon, - MailIcon, - SmartphoneIcon, - UserIcon, - UsersRound, -} from "lucide-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useState } from "react"; -import { TSegment } from "@formbricks/types/segment"; -import { TSurvey } from "@formbricks/types/surveys/types"; -import { TUser } from "@formbricks/types/user"; -import { EmbedView } from "./shareEmbedModal/EmbedView"; - -interface ShareEmbedSurveyProps { - survey: TSurvey; - publicDomain: string; - open: boolean; - modalView: "start" | "embed" | "panel"; - setOpen: React.Dispatch>; - user: TUser; - segments: TSegment[]; - isContactsEnabled: boolean; - isFormbricksCloud: boolean; -} - -export const ShareEmbedSurvey = ({ - survey, - publicDomain, - open, - modalView, - setOpen, - user, - segments, - isContactsEnabled, - isFormbricksCloud, -}: ShareEmbedSurveyProps) => { - const router = useRouter(); - const environmentId = survey.environmentId; - const isSingleUseLinkSurvey = survey.singleUse?.enabled ?? false; - const { email } = user; - const { t } = useTranslate(); - const tabs = useMemo( - () => - [ - { - id: "link", - label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`, - icon: LinkIcon, - }, - { id: "personal-links", label: t("environments.surveys.summary.personal_links"), icon: UserIcon }, - { id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon }, - { id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon }, - - { id: "app", label: t("environments.surveys.summary.embed_in_app"), icon: SmartphoneIcon }, - ].filter((tab) => !(survey.type === "link" && tab.id === "app")), - [t, isSingleUseLinkSurvey, survey.type] - ); - - const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[4].id); - const [showView, setShowView] = useState<"start" | "embed" | "panel" | "personal-links">("start"); - const [surveyUrl, setSurveyUrl] = useState(""); - - useEffect(() => { - const fetchSurveyUrl = async () => { - try { - const url = await getSurveyUrl(survey, publicDomain, "default"); - setSurveyUrl(url); - } catch (error) { - console.error("Failed to fetch survey URL:", error); - // Fallback to a default URL if fetching fails - setSurveyUrl(`${publicDomain}/s/${survey.id}`); - } - }; - fetchSurveyUrl(); - }, [survey, publicDomain]); - - useEffect(() => { - if (survey.type !== "link") { - setActiveId(tabs[4].id); - } - }, [survey.type, tabs]); - - useEffect(() => { - if (open) { - setShowView(modalView); - } else { - setShowView("start"); - } - }, [open, modalView]); - - const handleOpenChange = (open: boolean) => { - setActiveId(survey.type === "link" ? tabs[0].id : tabs[4].id); - setOpen(open); - if (!open) { - setShowView("start"); - } - router.refresh(); - }; - - return ( - - - {showView === "start" ? ( -
- {survey.type === "link" && ( -
- -

- {t("environments.surveys.summary.your_survey_is_public")} 🎉 -

-
- - -
- )} -
-

{t("environments.surveys.summary.whats_next")}

-
- - - - {t("environments.surveys.summary.configure_alerts")} - - - - {t("environments.surveys.summary.setup_integrations")} - - -
-
-
- ) : showView === "embed" ? ( - <> - {t("environments.surveys.summary.embed_survey")} - - - ) : showView === "panel" ? ( - <> - {t("environments.surveys.summary.send_to_panel")} - - ) : null} -
-
- ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx index 3de84da281..2c13f1cdb4 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx @@ -1,7 +1,7 @@ "use client"; -import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey"; import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage"; +import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal"; import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog"; @@ -32,10 +32,8 @@ interface SurveyAnalysisCTAProps { } interface ModalState { + start: boolean; share: boolean; - embed: boolean; - panel: boolean; - dropdown: boolean; } export const SurveyAnalysisCTA = ({ @@ -56,10 +54,8 @@ export const SurveyAnalysisCTA = ({ const [loading, setLoading] = useState(false); const [modalState, setModalState] = useState({ - share: searchParams.get("share") === "true", - embed: false, - panel: false, - dropdown: false, + start: searchParams.get("share") === "true", + share: false, }); const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]); @@ -69,7 +65,7 @@ export const SurveyAnalysisCTA = ({ useEffect(() => { setModalState((prev) => ({ ...prev, - share: searchParams.get("share") === "true", + start: searchParams.get("share") === "true", })); }, [searchParams]); @@ -81,7 +77,7 @@ export const SurveyAnalysisCTA = ({ params.delete("share"); } router.push(`${pathname}?${params.toString()}`); - setModalState((prev) => ({ ...prev, share: open })); + setModalState((prev) => ({ ...prev, start: open })); }; const duplicateSurveyAndRoute = async (surveyId: string) => { @@ -107,19 +103,6 @@ export const SurveyAnalysisCTA = ({ return `${surveyUrl}${separator}preview=true`; }; - const handleModalState = (modalView: keyof Omit) => { - return (open: boolean | ((prevState: boolean) => boolean)) => { - const newValue = typeof open === "function" ? open(modalState[modalView]) : open; - setModalState((prev) => ({ ...prev, [modalView]: newValue })); - }; - }; - - const shareEmbedViews = [ - { key: "share", modalView: "start" as const, setOpen: handleShareModalToggle }, - { key: "embed", modalView: "embed" as const, setOpen: handleModalState("embed") }, - { key: "panel", modalView: "panel" as const, setOpen: handleModalState("panel") }, - ]; - const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false); const iconActions = [ @@ -166,30 +149,30 @@ export const SurveyAnalysisCTA = ({ {user && ( - <> - {shareEmbedViews.map(({ key, modalView, setOpen }) => ( - - ))} - - + { + if (!open) { + handleShareModalToggle(false); + setModalState((prev) => ({ ...prev, share: false })); + } + }} + user={user} + modalView={modalState.start ? "start" : "share"} + segments={segments} + isContactsEnabled={isContactsEnabled} + isFormbricksCloud={isFormbricksCloud} + /> )} + {responseCount > 0 && ( ({ - useRouter: () => ({ - refresh: mockRouterRefresh, - }), -})); - vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (str: string) => str, @@ -112,9 +104,9 @@ vi.mock("@/modules/ui/components/badge", () => ({ Badge: vi.fn(({ text }) => {text}), })); -const mockEmbedViewComponent = vi.fn(); -vi.mock("./shareEmbedModal/EmbedView", () => ({ - EmbedView: (props: any) => mockEmbedViewComponent(props), +const mockShareViewComponent = vi.fn(); +vi.mock("./shareEmbedModal/share-view", () => ({ + ShareView: (props: any) => mockShareViewComponent(props), })); // Mock getSurveyUrl to return a predictable URL @@ -149,7 +141,7 @@ describe("ShareEmbedSurvey", () => { survey: mockSurveyWeb, publicDomain: "https://public-domain.com", open: true, - modalView: "start" as "start" | "embed" | "panel", + modalView: "start" as "start" | "share", setOpen: mockSetOpen, user: mockUser, segments: [], @@ -158,81 +150,70 @@ describe("ShareEmbedSurvey", () => { }; beforeEach(() => { - mockEmbedViewComponent.mockImplementation( + mockShareViewComponent.mockImplementation( ({ tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
-
{JSON.stringify(tabs)}
-
{activeId}
-
{survey.id}
-
{email}
-
{surveyUrl}
-
{publicDomain}
-
{locale}
+
{JSON.stringify(tabs)}
+
{activeId}
+
{survey.id}
+
{email}
+
{surveyUrl}
+
{publicDomain}
+
{locale}
) ); }); test("renders initial 'start' view correctly when open and modalView is 'start' for link survey", () => { - render(); + render(); expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument(); expect(screen.getByText("ShareSurveyLinkMock")).toBeInTheDocument(); expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument(); - expect(screen.getByText("environments.surveys.summary.embed_survey")).toBeInTheDocument(); + expect(screen.getByText("environments.surveys.summary.share_survey")).toBeInTheDocument(); expect(screen.getByText("environments.surveys.summary.configure_alerts")).toBeInTheDocument(); expect(screen.getByText("environments.surveys.summary.setup_integrations")).toBeInTheDocument(); - expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument(); + expect(screen.getByText("environments.surveys.summary.use_personal_links")).toBeInTheDocument(); expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new"); }); test("renders initial 'start' view correctly when open and modalView is 'start' for app survey", () => { - render(); + render(); // For app surveys, ShareSurveyLink should not be rendered expect(screen.queryByText("ShareSurveyLinkMock")).not.toBeInTheDocument(); expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument(); - expect(screen.getByText("environments.surveys.summary.embed_survey")).toBeInTheDocument(); + expect(screen.getByText("environments.surveys.summary.share_survey")).toBeInTheDocument(); expect(screen.getByText("environments.surveys.summary.configure_alerts")).toBeInTheDocument(); expect(screen.getByText("environments.surveys.summary.setup_integrations")).toBeInTheDocument(); - expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument(); + expect(screen.getByText("environments.surveys.summary.use_personal_links")).toBeInTheDocument(); expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new"); }); test("switches to 'embed' view when 'Embed survey' button is clicked", async () => { - render(); - const embedButton = screen.getByText("environments.surveys.summary.embed_survey"); + render(); + const embedButton = screen.getByText("environments.surveys.summary.share_survey"); await userEvent.click(embedButton); - expect(mockEmbedViewComponent).toHaveBeenCalled(); - expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument(); - }); - - test("switches to 'panel' view when 'Send to panel' button is clicked", async () => { - render(); - const panelButton = screen.getByText("environments.surveys.summary.send_to_panel"); - await userEvent.click(panelButton); - // Panel view currently just shows a title, no component is rendered - expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument(); + expect(mockShareViewComponent).toHaveBeenCalled(); + expect(screen.getByTestId("shareview-tabs")).toBeInTheDocument(); }); test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => { - render(); + render(); expect(capturedDialogOnOpenChange).toBeDefined(); // Simulate Dialog closing if (capturedDialogOnOpenChange) capturedDialogOnOpenChange(false); expect(mockSetOpen).toHaveBeenCalledWith(false); - expect(mockRouterRefresh).toHaveBeenCalledTimes(1); // Simulate Dialog opening - mockRouterRefresh.mockClear(); mockSetOpen.mockClear(); if (capturedDialogOnOpenChange) capturedDialogOnOpenChange(true); expect(mockSetOpen).toHaveBeenCalledWith(true); - expect(mockRouterRefresh).toHaveBeenCalledTimes(1); }); test("correctly configures for 'link' survey type in embed view", () => { - render(); - const embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as { + render(); + const embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as { tabs: { id: string; label: string; icon: LucideIcon }[]; activeId: string; }; @@ -243,8 +224,8 @@ describe("ShareEmbedSurvey", () => { }); test("correctly configures for 'web' survey type in embed view", () => { - render(); - const embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as { + render(); + const embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as { tabs: { id: string; label: string; icon: LucideIcon }[]; activeId: string; }; @@ -255,50 +236,50 @@ describe("ShareEmbedSurvey", () => { test("useEffect does not change activeId if survey.type changes from web to link (while in embed view)", () => { const { rerender } = render( - + ); - expect(vi.mocked(mockEmbedViewComponent).mock.calls[0][0].activeId).toBe("app"); + expect(vi.mocked(mockShareViewComponent).mock.calls[0][0].activeId).toBe("app"); - rerender(); - expect(vi.mocked(mockEmbedViewComponent).mock.calls[1][0].activeId).toBe("app"); // Current behavior + rerender(); + expect(vi.mocked(mockShareViewComponent).mock.calls[1][0].activeId).toBe("app"); // Current behavior }); test("initial showView is set by modalView prop when open is true", () => { - render(); - expect(mockEmbedViewComponent).toHaveBeenCalled(); - expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument(); + render(); + expect(mockShareViewComponent).toHaveBeenCalled(); + expect(screen.getByTestId("shareview-tabs")).toBeInTheDocument(); cleanup(); - render(); - // Panel view currently just shows a title - expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument(); + render(); + // Start view shows the share survey button + expect(screen.getByText("environments.surveys.summary.share_survey")).toBeInTheDocument(); }); test("useEffect sets showView to 'start' when open becomes false", () => { - const { rerender } = render(); - expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument(); // Starts in embed + const { rerender } = render(); + expect(screen.getByTestId("shareview-tabs")).toBeInTheDocument(); // Starts in embed - rerender(); + rerender(); // Dialog mock returns null when open is false, so EmbedViewMockContent is not found - expect(screen.queryByTestId("embedview-tabs")).not.toBeInTheDocument(); + expect(screen.queryByTestId("shareview-tabs")).not.toBeInTheDocument(); }); test("renders correct label for link tab based on singleUse survey property", () => { - render(); - let embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as { + render(); + let embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as { tabs: { id: string; label: string }[]; }; let linkTab = embedViewProps.tabs.find((tab) => tab.id === "link"); expect(linkTab?.label).toBe("environments.surveys.summary.share_the_link"); cleanup(); - vi.mocked(mockEmbedViewComponent).mockClear(); + vi.mocked(mockShareViewComponent).mockClear(); const mockSurveyLinkSingleUse: TSurvey = { ...mockSurveyLink, singleUse: { enabled: true, isEncrypted: true }, }; - render(); - embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as { + render(); + embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as { tabs: { id: string; label: string }[]; }; linkTab = embedViewProps.tabs.find((tab) => tab.id === "link"); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal.tsx new file mode 100644 index 0000000000..f960272e9a --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { getSurveyUrl } from "@/modules/analysis/utils"; +import { Dialog, DialogContent } from "@/modules/ui/components/dialog"; +import { useTranslate } from "@tolgee/react"; +import { Code2Icon, LinkIcon, MailIcon, SmartphoneIcon, UserIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { logger } from "@formbricks/logger"; +import { TSegment } from "@formbricks/types/segment"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TUser } from "@formbricks/types/user"; +import { ShareView } from "./shareEmbedModal/share-view"; +import { SuccessView } from "./shareEmbedModal/success-view"; + +type ModalView = "start" | "share"; + +enum ShareViewType { + LINK = "link", + PERSONAL_LINKS = "personal-links", + EMAIL = "email", + WEBPAGE = "webpage", + APP = "app", +} + +interface ShareSurveyModalProps { + survey: TSurvey; + publicDomain: string; + open: boolean; + modalView: ModalView; + setOpen: React.Dispatch>; + user: TUser; + segments: TSegment[]; + isContactsEnabled: boolean; + isFormbricksCloud: boolean; +} + +export const ShareSurveyModal = ({ + survey, + publicDomain, + open, + modalView, + setOpen, + user, + segments, + isContactsEnabled, + isFormbricksCloud, +}: ShareSurveyModalProps) => { + const environmentId = survey.environmentId; + const isSingleUseLinkSurvey = survey.singleUse?.enabled ?? false; + const { email } = user; + const { t } = useTranslate(); + const linkTabs: { id: ShareViewType; label: string; icon: React.ElementType }[] = useMemo( + () => [ + { + id: ShareViewType.LINK, + label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`, + icon: LinkIcon, + }, + { + id: ShareViewType.PERSONAL_LINKS, + label: t("environments.surveys.summary.personal_links"), + icon: UserIcon, + }, + { + id: ShareViewType.EMAIL, + label: t("environments.surveys.summary.embed_in_an_email"), + icon: MailIcon, + }, + { + id: ShareViewType.WEBPAGE, + label: t("environments.surveys.summary.embed_on_website"), + icon: Code2Icon, + }, + ], + [t, isSingleUseLinkSurvey] + ); + + const appTabs = [ + { + id: ShareViewType.APP, + label: t("environments.surveys.summary.embed_in_app"), + icon: SmartphoneIcon, + }, + ]; + + const [activeId, setActiveId] = useState(survey.type === "link" ? ShareViewType.LINK : ShareViewType.APP); + const [showView, setShowView] = useState(modalView); + const [surveyUrl, setSurveyUrl] = useState(""); + + useEffect(() => { + const fetchSurveyUrl = async () => { + try { + const url = await getSurveyUrl(survey, publicDomain, "default"); + setSurveyUrl(url); + } catch (error) { + logger.error("Failed to fetch survey URL:", error); + // Fallback to a default URL if fetching fails + setSurveyUrl(`${publicDomain}/s/${survey.id}`); + } + }; + fetchSurveyUrl(); + }, [survey, publicDomain]); + + useEffect(() => { + if (open) { + setShowView(modalView); + } + }, [open, modalView]); + + const handleOpenChange = (open: boolean) => { + setActiveId(survey.type === "link" ? ShareViewType.LINK : ShareViewType.APP); + setOpen(open); + if (!open) { + setShowView("start"); + } + }; + + const handleViewChange = (view: ModalView) => { + setShowView(view); + }; + + const handleEmbedViewWithTab = (tabId: ShareViewType) => { + setShowView("share"); + setActiveId(tabId); + }; + + return ( + + + {showView === "start" ? ( + + ) : ( + + )} + + + ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.test.tsx deleted file mode 100644 index 1bf3cce6aa..0000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.test.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { cleanup, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, test, vi } from "vitest"; -import { EmbedView } from "./EmbedView"; - -// Mock child components -vi.mock("./AppTab", () => ({ - AppTab: () =>
AppTab Content
, -})); -vi.mock("./EmailTab", () => ({ - EmailTab: (props: { surveyId: string; email: string }) => ( -
- EmailTab Content for {props.surveyId} with {props.email} -
- ), -})); -vi.mock("./LinkTab", () => ({ - LinkTab: (props: { survey: any; surveyUrl: string }) => ( -
- LinkTab Content for {props.survey.id} at {props.surveyUrl} -
- ), -})); -vi.mock("./WebsiteTab", () => ({ - WebsiteTab: (props: { surveyUrl: string; environmentId: string }) => ( -
- WebsiteTab Content for {props.surveyUrl} in {props.environmentId} -
- ), -})); - -vi.mock("./personal-links-tab", () => ({ - PersonalLinksTab: (props: { segments: any[]; surveyId: string; environmentId: string }) => ( -
- PersonalLinksTab Content for {props.surveyId} in {props.environmentId} -
- ), -})); - -vi.mock("@/modules/ui/components/upgrade-prompt", () => ({ - UpgradePrompt: (props: { title: string; description: string; buttons: any[] }) => ( -
- {props.title} - {props.description} -
- ), -})); - -// Mock @tolgee/react -vi.mock("@tolgee/react", () => ({ - useTranslate: () => ({ - t: (key: string) => key, - }), -})); - -// Mock lucide-react -vi.mock("lucide-react", () => ({ - ArrowLeftIcon: () =>
ArrowLeftIcon
, - MailIcon: () =>
MailIcon
, - LinkIcon: () =>
LinkIcon
, - GlobeIcon: () =>
GlobeIcon
, - SmartphoneIcon: () =>
SmartphoneIcon
, - AlertCircle: ({ className }: { className?: string }) => ( -
- AlertCircle -
- ), - AlertTriangle: ({ className }: { className?: string }) => ( -
- AlertTriangle -
- ), - Info: ({ className }: { className?: string }) => ( -
- Info -
- ), -})); - -const mockTabs = [ - { id: "email", label: "Email", icon: () =>
}, - { id: "webpage", label: "Web Page", icon: () =>
}, - { id: "link", label: "Link", icon: () =>
}, - { id: "app", label: "App", icon: () =>
}, -]; - -const mockSurveyLink = { id: "survey1", type: "link" }; -const mockSurveyWeb = { id: "survey2", type: "web" }; - -const defaultProps = { - tabs: mockTabs, - activeId: "email", - setActiveId: vi.fn(), - environmentId: "env1", - survey: mockSurveyLink, - email: "test@example.com", - surveyUrl: "http://example.com/survey1", - publicDomain: "http://example.com", - setSurveyUrl: vi.fn(), - locale: "en" as any, - segments: [], - isContactsEnabled: true, - isFormbricksCloud: false, -}; - -describe("EmbedView", () => { - afterEach(() => { - cleanup(); - vi.clearAllMocks(); - }); - - test("does not render desktop tabs for non-link survey type", () => { - render(); - // Desktop tabs container should not be present or not have lg:flex if it's a common parent - const desktopTabsButtons = screen.queryAllByRole("button", { name: /Email|Web Page|Link|App/i }); - // Check if any of these buttons are part of a container that is only visible on large screens - const desktopTabContainer = desktopTabsButtons[0]?.closest("div.lg\\:flex"); - expect(desktopTabContainer).toBeNull(); - }); - - test("calls setActiveId when a tab is clicked (desktop)", async () => { - render(); - const webpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[0]; // First one is desktop - await userEvent.click(webpageTabButton); - expect(defaultProps.setActiveId).toHaveBeenCalledWith("webpage"); - }); - - test("renders EmailTab when activeId is 'email'", () => { - render(); - expect(screen.getByTestId("email-tab")).toBeInTheDocument(); - expect( - screen.getByText(`EmailTab Content for ${defaultProps.survey.id} with ${defaultProps.email}`) - ).toBeInTheDocument(); - }); - - test("renders WebsiteTab when activeId is 'webpage'", () => { - render(); - expect(screen.getByTestId("website-tab")).toBeInTheDocument(); - expect( - screen.getByText(`WebsiteTab Content for ${defaultProps.surveyUrl} in ${defaultProps.environmentId}`) - ).toBeInTheDocument(); - }); - - test("renders LinkTab when activeId is 'link'", () => { - render(); - expect(screen.getByTestId("link-tab")).toBeInTheDocument(); - expect( - screen.getByText(`LinkTab Content for ${defaultProps.survey.id} at ${defaultProps.surveyUrl}`) - ).toBeInTheDocument(); - }); - - test("renders AppTab when activeId is 'app'", () => { - render(); - expect(screen.getByTestId("app-tab")).toBeInTheDocument(); - }); - - test("calls setActiveId when a responsive tab is clicked", async () => { - render(); - // Get the responsive tab button (second instance of the button with this name) - const responsiveWebpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[1]; - await userEvent.click(responsiveWebpageTabButton); - expect(defaultProps.setActiveId).toHaveBeenCalledWith("webpage"); - }); - - test("applies active styles to the active tab (desktop)", () => { - render(); - const emailTabButton = screen.getAllByRole("button", { name: "Email" })[0]; - expect(emailTabButton).toHaveClass("border-slate-200 bg-slate-100 font-semibold text-slate-900"); - - const webpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[0]; - expect(webpageTabButton).toHaveClass("border-transparent text-slate-500 hover:text-slate-700"); - }); - - test("applies active styles to the active tab (responsive)", () => { - render(); - const responsiveEmailTabButton = screen.getAllByRole("button", { name: "Email" })[1]; - expect(responsiveEmailTabButton).toHaveClass("bg-white text-slate-900 shadow-sm"); - - const responsiveWebpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[1]; - expect(responsiveWebpageTabButton).toHaveClass("border-transparent text-slate-700 hover:text-slate-900"); - }); -}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.tsx deleted file mode 100644 index e93a711fa5..0000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.tsx +++ /dev/null @@ -1,125 +0,0 @@ -"use client"; - -import { cn } from "@/lib/cn"; -import { Button } from "@/modules/ui/components/button"; -import { TSegment } from "@formbricks/types/segment"; -import { TUserLocale } from "@formbricks/types/user"; -import { AppTab } from "./AppTab"; -import { EmailTab } from "./EmailTab"; -import { LinkTab } from "./LinkTab"; -import { WebsiteTab } from "./WebsiteTab"; -import { PersonalLinksTab } from "./personal-links-tab"; - -interface EmbedViewProps { - tabs: Array<{ id: string; label: string; icon: any }>; - activeId: string; - setActiveId: React.Dispatch>; - environmentId: string; - survey: any; - email: string; - surveyUrl: string; - publicDomain: string; - setSurveyUrl: React.Dispatch>; - locale: TUserLocale; - segments: TSegment[]; - isContactsEnabled: boolean; - isFormbricksCloud: boolean; -} - -export const EmbedView = ({ - tabs, - activeId, - setActiveId, - environmentId, - survey, - email, - surveyUrl, - publicDomain, - setSurveyUrl, - locale, - segments, - isContactsEnabled, - isFormbricksCloud, -}: EmbedViewProps) => { - const renderActiveTab = () => { - switch (activeId) { - case "email": - return ; - case "webpage": - return ; - case "link": - return ( - - ); - case "app": - return ; - case "personal-links": - return ( - - ); - default: - return null; - } - }; - - return ( -
-
- {survey.type === "link" && ( - - )} -
- {renderActiveTab()} -
- {tabs.slice(0, 2).map((tab) => ( - - ))} -
-
-
-
- ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.test.tsx new file mode 100644 index 0000000000..1d7f764f1d --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.test.tsx @@ -0,0 +1,376 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { ShareView } from "./share-view"; + +// Mock child components +vi.mock("./AppTab", () => ({ + AppTab: () =>
AppTab Content
, +})); +vi.mock("./EmailTab", () => ({ + EmailTab: (props: { surveyId: string; email: string }) => ( +
+ EmailTab Content for {props.surveyId} with {props.email} +
+ ), +})); +vi.mock("./LinkTab", () => ({ + LinkTab: (props: { survey: any; surveyUrl: string }) => ( +
+ LinkTab Content for {props.survey.id} at {props.surveyUrl} +
+ ), +})); +vi.mock("./WebsiteTab", () => ({ + WebsiteTab: (props: { surveyUrl: string; environmentId: string }) => ( +
+ WebsiteTab Content for {props.surveyUrl} in {props.environmentId} +
+ ), +})); + +vi.mock("./personal-links-tab", () => ({ + PersonalLinksTab: (props: { segments: any[]; surveyId: string; environmentId: string }) => ( +
+ PersonalLinksTab Content for {props.surveyId} in {props.environmentId} +
+ ), +})); + +vi.mock("@/modules/ui/components/upgrade-prompt", () => ({ + UpgradePrompt: (props: { title: string; description: string; buttons: any[] }) => ( +
+ {props.title} - {props.description} +
+ ), +})); + +// Mock lucide-react +vi.mock("lucide-react", () => ({ + ArrowLeftIcon: () =>
ArrowLeftIcon
, + MailIcon: () =>
MailIcon
, + LinkIcon: () =>
LinkIcon
, + GlobeIcon: () =>
GlobeIcon
, + SmartphoneIcon: () =>
SmartphoneIcon
, + AlertCircle: ({ className }: { className?: string }) => ( +
+ AlertCircle +
+ ), + AlertTriangle: ({ className }: { className?: string }) => ( +
+ AlertTriangle +
+ ), + Info: ({ className }: { className?: string }) => ( +
+ Info +
+ ), +})); + +// Mock sidebar components +vi.mock("@/modules/ui/components/sidebar", () => ({ + SidebarProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, + Sidebar: ({ children }: { children: React.ReactNode }) =>
{children}
, + SidebarContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + SidebarGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + SidebarGroupContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + SidebarGroupLabel: ({ children }: { children: React.ReactNode }) =>
{children}
, + SidebarMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + SidebarMenuItem: ({ children }: { children: React.ReactNode }) =>
{children}
, + SidebarMenuButton: ({ + children, + onClick, + tooltip, + className, + }: { + children: React.ReactNode; + onClick: () => void; + tooltip: string; + className?: string; + }) => ( + + ), +})); + +// Mock tooltip and typography components +vi.mock("@/modules/ui/components/tooltip", () => ({ + TooltipRenderer: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("@/modules/ui/components/typography", () => ({ + Small: ({ children }: { children: React.ReactNode }) => {children}, +})); + +// Mock button component +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ + children, + onClick, + className, + variant, + }: { + children: React.ReactNode; + onClick: () => void; + className?: string; + variant?: string; + }) => ( + + ), +})); + +// Mock cn utility +vi.mock("@/lib/cn", () => ({ + cn: (...args: any[]) => args.filter(Boolean).join(" "), +})); + +const mockTabs = [ + { id: "email", label: "Email", icon: () =>
}, + { id: "webpage", label: "Web Page", icon: () =>
}, + { id: "link", label: "Link", icon: () =>
}, + { id: "app", label: "App", icon: () =>
}, +]; + +// Create proper mock survey objects +const createMockSurvey = (type: "link" | "app", id = "survey1"): TSurvey => ({ + id, + createdAt: new Date(), + updatedAt: new Date(), + name: `Test Survey ${id}`, + type, + environmentId: "env1", + createdBy: "user123", + status: "inProgress", + displayOption: "displayOnce", + autoClose: null, + triggers: [], + recontactDays: null, + displayLimit: null, + welcomeCard: { + enabled: false, + headline: { default: "" }, + html: { default: "" }, + fileUrl: undefined, + buttonLabel: { default: "" }, + timeToFinish: false, + showResponseCount: false, + }, + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Test Question" }, + subheader: { default: "" }, + required: true, + inputType: "text", + placeholder: { default: "" }, + longAnswer: false, + logic: [], + charLimit: { enabled: false }, + buttonLabel: { default: "" }, + backButtonLabel: { default: "" }, + }, + ], + endings: [ + { + id: "end1", + type: "endScreen", + headline: { default: "Thank you!" }, + subheader: { default: "" }, + buttonLabel: { default: "" }, + buttonLink: undefined, + }, + ], + hiddenFields: { enabled: false, fieldIds: [] }, + variables: [], + followUps: [], + delay: 0, + autoComplete: null, + runOnDate: null, + closeOnDate: null, + projectOverwrites: null, + styling: null, + showLanguageSwitch: null, + surveyClosedMessage: null, + segment: null, + singleUse: null, + isVerifyEmailEnabled: false, + recaptcha: null, + isSingleResponsePerEmailEnabled: false, + isBackButtonHidden: false, + pin: null, + resultShareKey: null, + displayPercentage: null, + languages: [ + { + enabled: true, + default: true, + language: { + id: "lang1", + createdAt: new Date(), + updatedAt: new Date(), + code: "en", + alias: "English", + projectId: "project1", + }, + }, + ], +}); + +const mockSurveyLink = createMockSurvey("link", "survey1"); +const mockSurveyApp = createMockSurvey("app", "survey2"); + +const defaultProps = { + tabs: mockTabs, + activeId: "email", + setActiveId: vi.fn(), + environmentId: "env1", + survey: mockSurveyLink, + email: "test@example.com", + surveyUrl: "http://example.com/survey1", + publicDomain: "http://example.com", + setSurveyUrl: vi.fn(), + locale: "en" as any, + segments: [], + isContactsEnabled: true, + isFormbricksCloud: false, +}; + +describe("ShareView", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("does not render desktop tabs for non-link survey type", () => { + render(); + + // For non-link survey types, desktop sidebar should not be rendered + // Check that SidebarProvider is not rendered by looking for sidebar-specific elements + const sidebarLabel = screen.queryByText("Share via"); + expect(sidebarLabel).toBeNull(); + }); + + test("renders desktop tabs for link survey type", () => { + render(); + + // For link survey types, desktop sidebar should be rendered + const sidebarLabel = screen.getByText("Share via"); + expect(sidebarLabel).toBeInTheDocument(); + }); + + test("calls setActiveId when a tab is clicked (desktop)", async () => { + render(); + + const webpageTabButton = screen.getByLabelText("Web Page"); + await userEvent.click(webpageTabButton); + expect(defaultProps.setActiveId).toHaveBeenCalledWith("webpage"); + }); + + test("renders EmailTab when activeId is 'email'", () => { + render(); + expect(screen.getByTestId("email-tab")).toBeInTheDocument(); + expect( + screen.getByText(`EmailTab Content for ${defaultProps.survey.id} with ${defaultProps.email}`) + ).toBeInTheDocument(); + }); + + test("renders WebsiteTab when activeId is 'webpage'", () => { + render(); + expect(screen.getByTestId("website-tab")).toBeInTheDocument(); + expect( + screen.getByText(`WebsiteTab Content for ${defaultProps.surveyUrl} in ${defaultProps.environmentId}`) + ).toBeInTheDocument(); + }); + + test("renders LinkTab when activeId is 'link'", () => { + render(); + expect(screen.getByTestId("link-tab")).toBeInTheDocument(); + expect( + screen.getByText(`LinkTab Content for ${defaultProps.survey.id} at ${defaultProps.surveyUrl}`) + ).toBeInTheDocument(); + }); + + test("renders AppTab when activeId is 'app'", () => { + render(); + expect(screen.getByTestId("app-tab")).toBeInTheDocument(); + }); + + test("renders PersonalLinksTab when activeId is 'personal-links'", () => { + render(); + expect(screen.getByTestId("personal-links-tab")).toBeInTheDocument(); + expect( + screen.getByText( + `PersonalLinksTab Content for ${defaultProps.survey.id} in ${defaultProps.environmentId}` + ) + ).toBeInTheDocument(); + }); + + test("calls setActiveId when a responsive tab is clicked", async () => { + render(); + + // Get responsive buttons - these are Button components containing icons + const responsiveButtons = screen.getAllByTestId("webpage-tab-icon"); + // The responsive button should be the one inside the md:hidden container + const responsiveButton = responsiveButtons + .find((icon) => { + const button = icon.closest("button"); + return button && button.getAttribute("data-variant") === "ghost"; + }) + ?.closest("button"); + + if (responsiveButton) { + await userEvent.click(responsiveButton); + expect(defaultProps.setActiveId).toHaveBeenCalledWith("webpage"); + } + }); + + test("applies active styles to the active tab (desktop)", () => { + render(); + + const emailTabButton = screen.getByLabelText("Email"); + expect(emailTabButton).toHaveClass("bg-slate-100"); + expect(emailTabButton).toHaveClass("font-medium"); + expect(emailTabButton).toHaveClass("text-slate-900"); + + const webpageTabButton = screen.getByLabelText("Web Page"); + expect(webpageTabButton).not.toHaveClass("bg-slate-100"); + expect(webpageTabButton).not.toHaveClass("font-medium"); + }); + + test("applies active styles to the active tab (responsive)", () => { + render(); + + // Get responsive buttons - these are Button components with ghost variant + const responsiveButtons = screen.getAllByTestId("email-tab-icon"); + const responsiveEmailButton = responsiveButtons + .find((icon) => { + const button = icon.closest("button"); + return button && button.getAttribute("data-variant") === "ghost"; + }) + ?.closest("button"); + + if (responsiveEmailButton) { + // Check that the button has the active classes + expect(responsiveEmailButton).toHaveClass("bg-white text-slate-900 shadow-sm hover:bg-white"); + } + + const responsiveWebpageButtons = screen.getAllByTestId("webpage-tab-icon"); + const responsiveWebpageButton = responsiveWebpageButtons + .find((icon) => { + const button = icon.closest("button"); + return button && button.getAttribute("data-variant") === "ghost"; + }) + ?.closest("button"); + + if (responsiveWebpageButton) { + expect(responsiveWebpageButton).toHaveClass("border-transparent text-slate-700 hover:text-slate-900"); + } + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.tsx new file mode 100644 index 0000000000..955e42c08b --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { cn } from "@/lib/cn"; +import { Button } from "@/modules/ui/components/button"; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, +} from "@/modules/ui/components/sidebar"; +import { TooltipRenderer } from "@/modules/ui/components/tooltip"; +import { Small } from "@/modules/ui/components/typography"; +import { useEffect, useState } from "react"; +import { TSegment } from "@formbricks/types/segment"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TUserLocale } from "@formbricks/types/user"; +import { AppTab } from "./AppTab"; +import { EmailTab } from "./EmailTab"; +import { LinkTab } from "./LinkTab"; +import { WebsiteTab } from "./WebsiteTab"; +import { PersonalLinksTab } from "./personal-links-tab"; + +interface ShareViewProps { + tabs: Array<{ id: string; label: string; icon: React.ElementType }>; + activeId: string; + setActiveId: React.Dispatch>; + environmentId: string; + survey: TSurvey; + email: string; + surveyUrl: string; + publicDomain: string; + setSurveyUrl: React.Dispatch>; + locale: TUserLocale; + segments: TSegment[]; + isContactsEnabled: boolean; + isFormbricksCloud: boolean; +} + +export const ShareView = ({ + tabs, + activeId, + setActiveId, + environmentId, + survey, + email, + surveyUrl, + publicDomain, + setSurveyUrl, + locale, + segments, + isContactsEnabled, + isFormbricksCloud, +}: ShareViewProps) => { + const [isLargeScreen, setIsLargeScreen] = useState(true); + + useEffect(() => { + const checkScreenSize = () => { + setIsLargeScreen(window.innerWidth >= 1024); + }; + + checkScreenSize(); + + window.addEventListener("resize", checkScreenSize); + + return () => window.removeEventListener("resize", checkScreenSize); + }, []); + + const renderActiveTab = () => { + switch (activeId) { + case "email": + return ; + case "webpage": + return ; + case "link": + return ( + + ); + case "app": + return ; + case "personal-links": + return ( + + ); + default: + return null; + } + }; + + return ( +
+
+ {survey.type === "link" && ( + + + + + + Share via + + + + {tabs.map((tab) => ( + + setActiveId(tab.id)} + className={cn( + "flex w-full justify-start rounded-md p-2 text-slate-600 hover:bg-slate-100 hover:text-slate-900", + tab.id === activeId + ? "bg-slate-100 font-medium text-slate-900" + : "text-slate-700" + )} + tooltip={tab.label} + isActive={tab.id === activeId}> + + {tab.label} + + + ))} + + + + + + + )} +
+ {renderActiveTab()} +
+ {tabs.map((tab) => ( + + + + ))} +
+
+
+
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/success-view.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/success-view.tsx new file mode 100644 index 0000000000..3ad8858369 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/success-view.tsx @@ -0,0 +1,83 @@ +import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink"; +import { Badge } from "@/modules/ui/components/badge"; +import { useTranslate } from "@tolgee/react"; +import { BellRing, BlocksIcon, Share2Icon, UserIcon } from "lucide-react"; +import Link from "next/link"; +import React from "react"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TUser } from "@formbricks/types/user"; + +interface SuccessViewProps { + survey: TSurvey; + surveyUrl: string; + publicDomain: string; + setSurveyUrl: (url: string) => void; + user: TUser; + tabs: { id: string; label: string; icon: React.ElementType }[]; + handleViewChange: (view: string) => void; + handleEmbedViewWithTab: (tabId: string) => void; +} + +export const SuccessView: React.FC = ({ + survey, + surveyUrl, + publicDomain, + setSurveyUrl, + user, + tabs, + handleViewChange, + handleEmbedViewWithTab, +}) => { + const { t } = useTranslate(); + const environmentId = survey.environmentId; + return ( +
+ {survey.type === "link" && ( +
+

+ {t("environments.surveys.summary.your_survey_is_public")} 🎉 +

+ +
+ )} +
+

{t("environments.surveys.summary.whats_next")}

+
+ + + + + {t("environments.surveys.summary.configure_alerts")} + + + + {t("environments.surveys.summary.setup_integrations")} + +
+
+
+ ); +}; diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 19b86bf417..cbabd7113c 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -1811,6 +1811,7 @@ "unknown_question_type": "Unbekannter Fragetyp", "unpublish_from_web": "Aus dem Web entfernen", "unsupported_video_tag_warning": "Dein Browser unterstützt das Video-Tag nicht.", + "use_personal_links": "Nutze persönliche Links", "view_embed_code": "Einbettungscode anzeigen", "view_embed_code_for_email": "Einbettungscode für E-Mail anzeigen", "view_site": "Seite ansehen", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index b6770f1faa..1443e8db63 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -1811,6 +1811,7 @@ "unknown_question_type": "Unknown Question Type", "unpublish_from_web": "Unpublish from web", "unsupported_video_tag_warning": "Your browser does not support the video tag.", + "use_personal_links": "Use personal links", "view_embed_code": "View embed code", "view_embed_code_for_email": "View embed code for email", "view_site": "View site", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index d04d88423d..758cc13af3 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -1811,6 +1811,7 @@ "unknown_question_type": "Type de question inconnu", "unpublish_from_web": "Désactiver la publication sur le web", "unsupported_video_tag_warning": "Votre navigateur ne prend pas en charge la balise vidéo.", + "use_personal_links": "Utilisez des liens personnels", "view_embed_code": "Voir le code d'intégration", "view_embed_code_for_email": "Voir le code d'intégration pour l'email", "view_site": "Voir le site", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 7dca442ed8..0c75e0c7b1 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -1811,6 +1811,7 @@ "unknown_question_type": "Tipo de pergunta desconhecido", "unpublish_from_web": "Despublicar da web", "unsupported_video_tag_warning": "Seu navegador não suporta a tag de vídeo.", + "use_personal_links": "Use links pessoais", "view_embed_code": "Ver código incorporado", "view_embed_code_for_email": "Ver código incorporado para e-mail", "view_site": "Ver site", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index a717e73684..440e5767ec 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -1811,6 +1811,7 @@ "unknown_question_type": "Tipo de Pergunta Desconhecido", "unpublish_from_web": "Despublicar da web", "unsupported_video_tag_warning": "O seu navegador não suporta a tag de vídeo.", + "use_personal_links": "Utilize links pessoais", "view_embed_code": "Ver código de incorporação", "view_embed_code_for_email": "Ver código de incorporação para email", "view_site": "Ver site", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index d4902323d0..a34af060a0 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -1811,6 +1811,7 @@ "unknown_question_type": "未知的問題類型", "unpublish_from_web": "從網站取消發布", "unsupported_video_tag_warning": "您的瀏覽器不支援 video 標籤。", + "use_personal_links": "使用 個人 連結", "view_embed_code": "檢視嵌入程式碼", "view_embed_code_for_email": "檢視電子郵件的嵌入程式碼", "view_site": "檢視網站", diff --git a/apps/web/modules/analysis/components/ShareSurveyLink/components/SurveyLinkDisplay.tsx b/apps/web/modules/analysis/components/ShareSurveyLink/components/SurveyLinkDisplay.tsx index a7d248c49e..6a759901b6 100644 --- a/apps/web/modules/analysis/components/ShareSurveyLink/components/SurveyLinkDisplay.tsx +++ b/apps/web/modules/analysis/components/ShareSurveyLink/components/SurveyLinkDisplay.tsx @@ -11,7 +11,7 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => { @@ -19,7 +19,8 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => { //loading state
+ className="h-9 w-full min-w-96 animate-pulse rounded-lg bg-slate-100 px-3 py-1 text-slate-800 caret-transparent" + /> )} ); diff --git a/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx b/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx index 1e7855a5e8..378dbcd3a5 100644 --- a/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx +++ b/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx @@ -59,9 +59,9 @@ export const ShareSurveyLink = ({ return (
+ className={`flex max-w-full flex-col items-center justify-center gap-2 ${survey.singleUse?.enabled ? "flex-col" : "lg:flex-row"}`}> -
+
) as any; + Trigger.displayName = "SheetTrigger"; + + const Portal = vi.fn(({ children }) =>
{children}
) as any; + Portal.displayName = "SheetPortal"; + + const Overlay = vi.fn(({ className, ...props }) => ( +
+ )) as any; + Overlay.displayName = "SheetOverlay"; + + const Content = vi.fn(({ className, children, ...props }) => ( +
+ {children} +
+ )) as any; + Content.displayName = "SheetContent"; + + const Close = vi.fn(({ className, children }) => ( + + )) as any; + Close.displayName = "SheetClose"; + + const Title = vi.fn(({ className, children, ...props }) => ( +

+ {children} +

+ )) as any; + Title.displayName = "SheetTitle"; + + const Description = vi.fn(({ className, children, ...props }) => ( +

+ {children} +

+ )) as any; + Description.displayName = "SheetDescription"; + + return { + Root, + Trigger, + Portal, + Overlay, + Content, + Close, + Title, + Description, + }; +}); + +// Mock Lucide React +vi.mock("lucide-react", () => ({ + XIcon: ({ className }: { className?: string }) => ( +
+ X Icon +
+ ), +})); + +describe("Sheet Components", () => { + afterEach(() => { + cleanup(); + }); + + test("Sheet renders correctly", () => { + render( + +
Sheet Content
+
+ ); + + expect(screen.getByTestId("sheet-root")).toBeInTheDocument(); + expect(screen.getByText("Sheet Content")).toBeInTheDocument(); + }); + + test("SheetTrigger renders correctly", () => { + render( + + Open Sheet + + ); + + expect(screen.getByTestId("sheet-trigger")).toBeInTheDocument(); + expect(screen.getByText("Open Sheet")).toBeInTheDocument(); + }); + + test("SheetClose renders correctly", () => { + render( + + Close Sheet + + ); + + expect(screen.getByTestId("sheet-close")).toBeInTheDocument(); + expect(screen.getByText("Close Sheet")).toBeInTheDocument(); + }); + + test("SheetPortal renders correctly", () => { + render( + +
Portal Content
+
+ ); + + expect(screen.getByTestId("sheet-portal")).toBeInTheDocument(); + expect(screen.getByText("Portal Content")).toBeInTheDocument(); + }); + + test("SheetOverlay renders with correct classes", () => { + render(); + + const overlay = screen.getByTestId("sheet-overlay"); + expect(overlay).toBeInTheDocument(); + expect(overlay).toHaveClass("test-class"); + expect(overlay).toHaveClass("fixed"); + expect(overlay).toHaveClass("inset-0"); + expect(overlay).toHaveClass("z-50"); + expect(overlay).toHaveClass("bg-black/80"); + }); + + test("SheetContent renders with default variant (right)", () => { + render( + +
Test Content
+
+ ); + + expect(screen.getByTestId("sheet-portal")).toBeInTheDocument(); + expect(screen.getByTestId("sheet-overlay")).toBeInTheDocument(); + expect(screen.getByTestId("sheet-content")).toBeInTheDocument(); + expect(screen.getByTestId("sheet-close")).toBeInTheDocument(); + expect(screen.getByTestId("x-icon")).toBeInTheDocument(); + expect(screen.getByText("Test Content")).toBeInTheDocument(); + expect(screen.getByText("Close")).toBeInTheDocument(); + }); + + test("SheetContent applies correct variant classes", () => { + const { rerender } = render( + +
Top Content
+
+ ); + + let content = screen.getByTestId("sheet-content"); + expect(content).toHaveClass("inset-x-0"); + expect(content).toHaveClass("top-0"); + expect(content).toHaveClass("border-b"); + expect(content).toHaveClass("data-[state=closed]:slide-out-to-top"); + expect(content).toHaveClass("data-[state=open]:slide-in-from-top"); + + rerender( + +
Bottom Content
+
+ ); + + content = screen.getByTestId("sheet-content"); + expect(content).toHaveClass("inset-x-0"); + expect(content).toHaveClass("bottom-0"); + expect(content).toHaveClass("border-t"); + expect(content).toHaveClass("data-[state=closed]:slide-out-to-bottom"); + expect(content).toHaveClass("data-[state=open]:slide-in-from-bottom"); + + rerender( + +
Left Content
+
+ ); + + content = screen.getByTestId("sheet-content"); + expect(content).toHaveClass("inset-y-0"); + expect(content).toHaveClass("left-0"); + expect(content).toHaveClass("h-full"); + expect(content).toHaveClass("w-3/4"); + expect(content).toHaveClass("border-r"); + expect(content).toHaveClass("data-[state=closed]:slide-out-to-left"); + expect(content).toHaveClass("data-[state=open]:slide-in-from-left"); + expect(content).toHaveClass("sm:max-w-sm"); + + rerender( + +
Right Content
+
+ ); + + content = screen.getByTestId("sheet-content"); + expect(content).toHaveClass("inset-y-0"); + expect(content).toHaveClass("right-0"); + expect(content).toHaveClass("h-full"); + expect(content).toHaveClass("w-3/4"); + expect(content).toHaveClass("border-l"); + expect(content).toHaveClass("data-[state=closed]:slide-out-to-right"); + expect(content).toHaveClass("data-[state=open]:slide-in-from-right"); + expect(content).toHaveClass("sm:max-w-sm"); + }); + + test("SheetContent applies custom className", () => { + render( + +
Custom Content
+
+ ); + + const content = screen.getByTestId("sheet-content"); + expect(content).toHaveClass("custom-class"); + }); + + test("SheetContent has correct base classes", () => { + render( + +
Base Content
+
+ ); + + const content = screen.getByTestId("sheet-content"); + expect(content).toHaveClass("fixed"); + expect(content).toHaveClass("z-50"); + expect(content).toHaveClass("gap-4"); + expect(content).toHaveClass("bg-background"); + expect(content).toHaveClass("p-6"); + expect(content).toHaveClass("shadow-lg"); + expect(content).toHaveClass("transition"); + expect(content).toHaveClass("ease-in-out"); + expect(content).toHaveClass("data-[state=closed]:duration-300"); + expect(content).toHaveClass("data-[state=open]:duration-500"); + }); + + test("SheetContent close button has correct styling", () => { + render( + +
Content
+
+ ); + + const closeButton = screen.getByTestId("sheet-close"); + expect(closeButton).toHaveClass("ring-offset-background"); + expect(closeButton).toHaveClass("focus:ring-ring"); + expect(closeButton).toHaveClass("data-[state=open]:bg-secondary"); + expect(closeButton).toHaveClass("absolute"); + expect(closeButton).toHaveClass("right-4"); + expect(closeButton).toHaveClass("top-4"); + expect(closeButton).toHaveClass("rounded-sm"); + expect(closeButton).toHaveClass("opacity-70"); + expect(closeButton).toHaveClass("transition-opacity"); + expect(closeButton).toHaveClass("hover:opacity-100"); + }); + + test("SheetContent close button icon has correct styling", () => { + render( + +
Content
+
+ ); + + const icon = screen.getByTestId("x-icon"); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveClass("h-4"); + expect(icon).toHaveClass("w-4"); + }); + + test("SheetHeader renders correctly", () => { + render( + +
Header Content
+
+ ); + + const header = screen.getByText("Header Content").parentElement; + expect(header).toBeInTheDocument(); + expect(header).toHaveClass("test-class"); + expect(header).toHaveClass("flex"); + expect(header).toHaveClass("flex-col"); + expect(header).toHaveClass("space-y-2"); + expect(header).toHaveClass("text-center"); + expect(header).toHaveClass("sm:text-left"); + }); + + test("SheetFooter renders correctly", () => { + render( + + + + ); + + const footer = screen.getByText("OK").parentElement; + expect(footer).toBeInTheDocument(); + expect(footer).toHaveClass("test-class"); + expect(footer).toHaveClass("flex"); + expect(footer).toHaveClass("flex-col-reverse"); + expect(footer).toHaveClass("sm:flex-row"); + expect(footer).toHaveClass("sm:justify-end"); + expect(footer).toHaveClass("sm:space-x-2"); + }); + + test("SheetTitle renders correctly", () => { + render(Sheet Title); + + const title = screen.getByTestId("sheet-title"); + expect(title).toBeInTheDocument(); + expect(title).toHaveClass("test-class"); + expect(title).toHaveClass("text-foreground"); + expect(title).toHaveClass("text-lg"); + expect(title).toHaveClass("font-semibold"); + expect(screen.getByText("Sheet Title")).toBeInTheDocument(); + }); + + test("SheetDescription renders correctly", () => { + render(Sheet Description); + + const description = screen.getByTestId("sheet-description"); + expect(description).toBeInTheDocument(); + expect(description).toHaveClass("test-class"); + expect(description).toHaveClass("text-muted-foreground"); + expect(description).toHaveClass("text-sm"); + expect(screen.getByText("Sheet Description")).toBeInTheDocument(); + }); + + test("SheetContent forwards props correctly", () => { + render( + +
Custom Content
+
+ ); + + const content = screen.getByTestId("custom-sheet"); + expect(content).toHaveAttribute("aria-label", "Custom Sheet"); + }); + + test("SheetTitle forwards props correctly", () => { + render(Custom Title); + + const title = screen.getByTestId("custom-title"); + expect(title).toHaveAttribute("data-testid", "custom-title"); + }); + + test("SheetDescription forwards props correctly", () => { + render(Custom Description); + + const description = screen.getByTestId("custom-description"); + expect(description).toHaveAttribute("data-testid", "custom-description"); + }); + + test("SheetHeader forwards props correctly", () => { + render( + +
Header
+
+ ); + + const header = screen.getByText("Header").parentElement; + expect(header).toHaveAttribute("data-testid", "custom-header"); + }); + + test("SheetFooter forwards props correctly", () => { + render( + + + + ); + + const footer = screen.getByText("Footer").parentElement; + expect(footer).toHaveAttribute("data-testid", "custom-footer"); + }); + + test("SheetHeader handles dangerouslySetInnerHTML", () => { + const htmlContent = "Dangerous HTML"; + render(); + + const header = document.querySelector(".flex.flex-col.space-y-2"); + expect(header).toBeInTheDocument(); + expect(header?.innerHTML).toContain(htmlContent); + }); + + test("SheetFooter handles dangerouslySetInnerHTML", () => { + const htmlContent = "Dangerous Footer HTML"; + render(); + + const footer = document.querySelector(".flex.flex-col-reverse"); + expect(footer).toBeInTheDocument(); + expect(footer?.innerHTML).toContain(htmlContent); + }); + + test("All components export correctly", () => { + expect(Sheet).toBeDefined(); + expect(SheetTrigger).toBeDefined(); + expect(SheetClose).toBeDefined(); + expect(SheetPortal).toBeDefined(); + expect(SheetOverlay).toBeDefined(); + expect(SheetContent).toBeDefined(); + expect(SheetHeader).toBeDefined(); + expect(SheetFooter).toBeDefined(); + expect(SheetTitle).toBeDefined(); + expect(SheetDescription).toBeDefined(); + }); + + test("Components have correct displayName", () => { + expect(SheetOverlay.displayName).toBe(SheetPrimitive.Overlay.displayName); + expect(SheetContent.displayName).toBe(SheetPrimitive.Content.displayName); + expect(SheetTitle.displayName).toBe(SheetPrimitive.Title.displayName); + expect(SheetDescription.displayName).toBe(SheetPrimitive.Description.displayName); + expect(SheetHeader.displayName).toBe("SheetHeader"); + expect(SheetFooter.displayName).toBe("SheetFooter"); + }); + + test("Close button has accessibility attributes", () => { + render( + +
Content
+
+ ); + + const closeButton = screen.getByTestId("sheet-close"); + expect(closeButton).toHaveClass("focus:outline-none"); + expect(closeButton).toHaveClass("focus:ring-2"); + expect(closeButton).toHaveClass("focus:ring-offset-2"); + expect(closeButton).toHaveClass("disabled:pointer-events-none"); + + // Check for screen reader text + expect(screen.getByText("Close")).toBeInTheDocument(); + expect(screen.getByText("Close")).toHaveClass("sr-only"); + }); + + test("SheetContent ref forwarding works", () => { + const ref = vi.fn(); + render( + +
Content
+
+ ); + + expect(ref).toHaveBeenCalled(); + }); + + test("SheetTitle ref forwarding works", () => { + const ref = vi.fn(); + render(Title); + + expect(ref).toHaveBeenCalled(); + }); + + test("SheetDescription ref forwarding works", () => { + const ref = vi.fn(); + render(Description); + + expect(ref).toHaveBeenCalled(); + }); + + test("SheetOverlay ref forwarding works", () => { + const ref = vi.fn(); + render(); + + expect(ref).toHaveBeenCalled(); + }); + + test("Full sheet example renders correctly", () => { + render( + + + Open Sheet + + + + Sheet Title + Sheet Description + +
Sheet Body Content
+ + + + +
+
+ ); + + expect(screen.getByTestId("sheet-root")).toBeInTheDocument(); + expect(screen.getByTestId("sheet-trigger")).toBeInTheDocument(); + expect(screen.getByTestId("sheet-portal")).toBeInTheDocument(); + expect(screen.getByTestId("sheet-overlay")).toBeInTheDocument(); + expect(screen.getByTestId("sheet-content")).toBeInTheDocument(); + expect(screen.getByTestId("sheet-close")).toBeInTheDocument(); + expect(screen.getByTestId("sheet-title")).toBeInTheDocument(); + expect(screen.getByTestId("sheet-description")).toBeInTheDocument(); + expect(screen.getByText("Open Sheet")).toBeInTheDocument(); + expect(screen.getByText("Sheet Title")).toBeInTheDocument(); + expect(screen.getByText("Sheet Description")).toBeInTheDocument(); + expect(screen.getByText("Sheet Body Content")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.getByText("Submit")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/ui/components/sheet/index.tsx b/apps/web/modules/ui/components/sheet/index.tsx new file mode 100644 index 0000000000..387a2816c1 --- /dev/null +++ b/apps/web/modules/ui/components/sheet/index.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { cn } from "@/modules/ui/lib/utils"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import { type VariantProps, cva } from "class-variance-authority"; +import { XIcon } from "lucide-react"; +import * as React from "react"; + +const Sheet = SheetPrimitive.Root; + +const SheetTrigger = SheetPrimitive.Trigger; + +const SheetClose = SheetPrimitive.Close; + +const SheetPortal = SheetPrimitive.Portal; + +const SheetOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } +); + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef, SheetContentProps>( + ({ side = "right", className, children, ...props }, ref) => ( + + + + + + Close + + {children} + + + ) +); +SheetContent.displayName = SheetPrimitive.Content.displayName; + +const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +SheetHeader.displayName = "SheetHeader"; + +const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +SheetFooter.displayName = "SheetFooter"; + +const SheetTitle = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetTitle.displayName = SheetPrimitive.Title.displayName; + +const SheetDescription = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetDescription.displayName = SheetPrimitive.Description.displayName; + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/apps/web/modules/ui/components/sidebar/index.test.tsx b/apps/web/modules/ui/components/sidebar/index.test.tsx new file mode 100644 index 0000000000..0c6223007f --- /dev/null +++ b/apps/web/modules/ui/components/sidebar/index.test.tsx @@ -0,0 +1,586 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import * as React from "react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInput, + SidebarInset, + SidebarMenu, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSkeleton, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarProvider, + SidebarRail, + SidebarSeparator, + SidebarTrigger, + useSidebar, +} from "./index"; + +// Mock the useIsMobile hook - this is already mocked in vitestSetup.ts +vi.mock("@/modules/ui/hooks/use-mobile", () => ({ + useIsMobile: vi.fn().mockReturnValue(false), +})); + +// Mock Button component +vi.mock("@/modules/ui/components/button", () => { + const MockButton = React.forwardRef(({ children, onClick, ...props }, ref) => ( + + )); + MockButton.displayName = "MockButton"; + + return { + Button: MockButton, + }; +}); + +// Mock Input component +vi.mock("@/modules/ui/components/input", () => { + const MockInput = React.forwardRef((props, ref) => ); + MockInput.displayName = "MockInput"; + + return { + Input: MockInput, + }; +}); + +// Mock Separator component +vi.mock("@/modules/ui/components/separator", () => { + const MockSeparator = React.forwardRef((props, ref) => ( +
+ )); + MockSeparator.displayName = "MockSeparator"; + + return { + Separator: MockSeparator, + }; +}); + +// Mock Sheet components +vi.mock("@/modules/ui/components/sheet", () => ({ + Sheet: ({ children, open, onOpenChange }: any) => ( +
onOpenChange?.(!open)}> + {children} +
+ ), + SheetContent: ({ children, side, ...props }: any) => ( +
+ {children} +
+ ), + SheetHeader: ({ children }: any) =>
{children}
, + SheetTitle: ({ children }: any) =>
{children}
, + SheetDescription: ({ children }: any) =>
{children}
, +})); + +// Mock Skeleton component +vi.mock("@/modules/ui/components/skeleton", () => ({ + Skeleton: ({ className, style, ...props }: any) => ( +
+ ), +})); + +// Mock Tooltip components +vi.mock("@/modules/ui/components/tooltip", () => ({ + Tooltip: ({ children }: any) =>
{children}
, + TooltipContent: ({ children, hidden, ...props }: any) => ( +
+ {children} +
+ ), + TooltipProvider: ({ children }: any) =>
{children}
, + TooltipTrigger: ({ children }: any) =>
{children}
, +})); + +// Mock Slot from @radix-ui/react-slot +vi.mock("@radix-ui/react-slot", () => { + const MockSlot = React.forwardRef(({ children, ...props }, ref) => ( +
+ {children} +
+ )); + MockSlot.displayName = "MockSlot"; + + return { + Slot: MockSlot, + }; +}); + +// Mock Lucide icons +vi.mock("lucide-react", () => ({ + Columns2Icon: () =>
, +})); + +// Mock cn utility +vi.mock("@/modules/ui/lib/utils", () => ({ + cn: (...args: any[]) => args.filter(Boolean).flat().join(" "), +})); + +// Test component that uses useSidebar hook +const TestComponent = () => { + const sidebar = useSidebar(); + return ( +
+
{sidebar?.state || "unknown"}
+
{sidebar?.open?.toString() || "unknown"}
+
{sidebar?.isMobile?.toString() || "unknown"}
+
{sidebar?.openMobile?.toString() || "unknown"}
+ + + +
+ ); +}; + +describe("Sidebar Components", () => { + beforeEach(() => { + // Reset document.cookie + Object.defineProperty(document, "cookie", { + writable: true, + value: "", + }); + + // Mock addEventListener and removeEventListener + global.addEventListener = vi.fn(); + global.removeEventListener = vi.fn(); + + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + describe("Core Functionality", () => { + test("useSidebar hook throws error when used outside provider", () => { + const TestComponentWithoutProvider = () => { + useSidebar(); + return
Test
; + }; + + expect(() => render()).toThrow( + "useSidebar must be used within a SidebarProvider." + ); + }); + + test("SidebarProvider manages state and provides context correctly", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + + // Test with default state + const { rerender } = render( + + + + ); + + expect(screen.getByTestId("sidebar-state")).toHaveTextContent("expanded"); + expect(screen.getByTestId("sidebar-open")).toHaveTextContent("true"); + + // Test toggle functionality + await user.click(screen.getByTestId("toggle-button")); + expect(document.cookie).toContain("sidebar_state=false"); + + // Test with controlled state + rerender( + + + + ); + + expect(screen.getByTestId("sidebar-open")).toHaveTextContent("false"); + await user.click(screen.getByTestId("set-open-button")); + expect(onOpenChange).toHaveBeenCalledWith(true); + + // Test mobile functionality + await user.click(screen.getByTestId("set-open-mobile-button")); + expect(screen.getByTestId("sidebar-open-mobile")).toHaveTextContent("true"); + }); + + test("SidebarProvider handles keyboard shortcuts and cleanup", () => { + const preventDefault = vi.fn(); + + const { unmount } = render( + + + + ); + + // Test keyboard shortcut registration + expect(global.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function)); + + // Test keyboard shortcut handling + const [[, eventHandler]] = vi.mocked(global.addEventListener).mock.calls; + + // Valid shortcut + (eventHandler as (event: any) => void)({ + key: "b", + ctrlKey: true, + preventDefault, + }); + expect(preventDefault).toHaveBeenCalled(); + + // Invalid shortcut + preventDefault.mockClear(); + (eventHandler as (event: any) => void)({ + key: "a", + ctrlKey: true, + preventDefault, + }); + expect(preventDefault).not.toHaveBeenCalled(); + + // Test cleanup + unmount(); + expect(global.removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function)); + }); + }); + + describe("Interactive Components", () => { + test("SidebarTrigger and SidebarRail toggle sidebar functionality", async () => { + const user = userEvent.setup(); + const customOnClick = vi.fn(); + + render( + + + + + + ); + + const trigger = screen.getByTestId("columns2-icon").closest("button"); + expect(trigger).not.toBeNull(); + await user.click(trigger as HTMLButtonElement); + expect(customOnClick).toHaveBeenCalled(); + expect(screen.getByTestId("sidebar-state")).toHaveTextContent("collapsed"); + + // Test SidebarRail + const rail = screen.getByLabelText("Toggle Sidebar"); + expect(rail).toHaveAttribute("aria-label", "Toggle Sidebar"); + await user.click(rail); + expect(screen.getByTestId("sidebar-state")).toHaveTextContent("expanded"); + }); + + test("Sidebar renders with different configurations", () => { + const { rerender } = render( + + +
Sidebar Content
+
+
+ ); + + expect(screen.getByText("Sidebar Content")).toBeInTheDocument(); + + // Test different variants + rerender( + + +
Sidebar Content
+
+
+ ); + + expect(screen.getByText("Sidebar Content")).toBeInTheDocument(); + }); + }); + + describe("Layout Components", () => { + test("basic layout components render correctly with custom classes", () => { + const layoutComponents = [ + { Component: SidebarInset, content: "Main Content", selector: "main" }, + { Component: SidebarInput, content: null, selector: "input", props: { placeholder: "Search..." } }, + { Component: SidebarHeader, content: "Header Content", selector: '[data-sidebar="header"]' }, + { Component: SidebarFooter, content: "Footer Content", selector: '[data-sidebar="footer"]' }, + { Component: SidebarSeparator, content: null, selector: '[role="separator"]' }, + { Component: SidebarContent, content: "Content", selector: '[data-sidebar="content"]' }, + ]; + + layoutComponents.forEach(({ Component, content, selector, props = {} }) => { + const testProps = { className: "custom-class", ...props }; + + render( + + {content &&
{content}
}
+
+ ); + + if (content) { + expect(screen.getByText(content)).toBeInTheDocument(); + const element = screen.getByText(content).closest(selector); + expect(element).toHaveClass("custom-class"); + } else if (selector === "input") { + expect(screen.getByRole("textbox")).toHaveClass("custom-class"); + } else { + expect(screen.getByRole("separator")).toHaveClass("custom-class"); + } + + cleanup(); + }); + }); + }); + + describe("Group Components", () => { + test("sidebar group components render and handle interactions", async () => { + const user = userEvent.setup(); + + render( + + + Group Label + +
Action
+
+ +
Group Content
+
+
+
+ ); + + // Test all components render + expect(screen.getByText("Group Label")).toBeInTheDocument(); + expect(screen.getByText("Group Content")).toBeInTheDocument(); + + // Test action button + const actionButton = screen.getByRole("button"); + expect(actionButton).toBeInTheDocument(); + await user.click(actionButton); + + // Test custom classes + expect(screen.getByText("Group Label")).toHaveClass("label-class"); + expect(screen.getByText("Group Content").closest('[data-sidebar="group-content"]')).toHaveClass( + "content-class" + ); + expect(actionButton).toHaveClass("action-class"); + }); + + test("sidebar group components handle asChild prop", () => { + render( + + +

Group Label

+
+ + + +
+ ); + + expect(screen.getByText("Group Label")).toBeInTheDocument(); + expect(screen.getByText("Action")).toBeInTheDocument(); + }); + }); + + describe("Menu Components", () => { + test("basic menu components render with custom classes", () => { + render( + + + +
Menu Item
+
+
+ 5 +
+ ); + + expect(screen.getByText("Menu Item")).toBeInTheDocument(); + expect(screen.getByText("5")).toBeInTheDocument(); + + const menu = screen.getByText("Menu Item").closest("ul"); + const menuItem = screen.getByText("Menu Item").closest("li"); + + expect(menu).toHaveClass("menu-class"); + expect(menuItem).toHaveClass("item-class"); + expect(screen.getByText("5")).toHaveClass("badge-class"); + }); + + test("SidebarMenuButton handles all variants and interactions", async () => { + const { rerender } = render( + + +
Menu Button
+
+
+ ); + + const button = screen.getByText("Menu Button").closest("button"); + expect(button).toHaveAttribute("data-active", "true"); + expect(button).toHaveAttribute("data-size", "sm"); + expect(button).toHaveClass("button-class"); + expect(screen.getByTestId("tooltip")).toBeInTheDocument(); + + // Test tooltip object + rerender( + + +
Menu Button
+
+
+ ); + + expect(screen.getByTestId("tooltip-content")).toBeInTheDocument(); + + // Test asChild + rerender( + + + Menu Button + + + ); + + expect(screen.getByText("Menu Button")).toBeInTheDocument(); + }); + + test("SidebarMenuAction handles showOnHover and asChild", () => { + const { rerender } = render( + + +
Action
+
+
+ ); + + expect(screen.getByText("Action")).toBeInTheDocument(); + + rerender( + + + + + + ); + + expect(screen.getByText("Action")).toBeInTheDocument(); + }); + + test("SidebarMenuSkeleton renders with icon option", () => { + const { rerender } = render( + + + + ); + + expect(screen.getByTestId("skeleton")).toBeInTheDocument(); + + const skeleton = screen.getAllByTestId("skeleton")[0].parentElement; + expect(skeleton).toHaveClass("skeleton-class"); + + rerender( + + + + ); + + expect(screen.getAllByTestId("skeleton")).toHaveLength(2); + }); + }); + + describe("Sub Menu Components", () => { + test("sub menu components render and handle all props", () => { + const { rerender } = render( + + + + +
Sub Button
+
+
+
+
+ ); + + expect(screen.getByText("Sub Button")).toBeInTheDocument(); + + const subMenu = screen.getByText("Sub Button").closest("ul"); + const subButton = screen.getByText("Sub Button").closest("a"); + + expect(subMenu).toHaveClass("sub-menu-class"); + expect(subButton).toHaveAttribute("data-active", "true"); + expect(subButton).toHaveAttribute("data-size", "sm"); + expect(subButton).toHaveClass("sub-button-class"); + + // Test asChild + rerender( + + + + + + ); + + expect(screen.getByText("Sub Button")).toBeInTheDocument(); + }); + }); + + describe("Provider Configuration", () => { + test("SidebarProvider handles custom props and styling", () => { + render( + + + + ); + + expect(screen.getByTestId("sidebar-state")).toHaveTextContent("collapsed"); + expect(screen.getByTestId("sidebar-open")).toHaveTextContent("false"); + + const wrapper = screen.getByText("collapsed").closest(".group\\/sidebar-wrapper"); + expect(wrapper).toHaveClass("custom-class"); + }); + + test("function callback handling for setOpen", async () => { + const user = userEvent.setup(); + + const TestComponentWithCallback = () => { + const { setOpen } = useSidebar(); + return ( + + ); + }; + + render( + + + + + ); + + expect(screen.getByTestId("sidebar-open")).toHaveTextContent("true"); + await user.click(screen.getByTestId("function-callback-button")); + expect(screen.getByTestId("sidebar-open")).toHaveTextContent("false"); + }); + }); +}); diff --git a/apps/web/modules/ui/components/sidebar/index.tsx b/apps/web/modules/ui/components/sidebar/index.tsx new file mode 100644 index 0000000000..d2bee1f12c --- /dev/null +++ b/apps/web/modules/ui/components/sidebar/index.tsx @@ -0,0 +1,691 @@ +"use client"; + +import { Button } from "@/modules/ui/components/button"; +import { Input } from "@/modules/ui/components/input"; +import { Separator } from "@/modules/ui/components/separator"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/modules/ui/components/sheet"; +import { Skeleton } from "@/modules/ui/components/skeleton"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; +import { useIsMobile } from "@/modules/ui/hooks/use-mobile"; +import { cn } from "@/modules/ui/lib/utils"; +import { Slot } from "@radix-ui/react-slot"; +import { VariantProps, cva } from "class-variance-authority"; +import { Columns2Icon } from "lucide-react"; +import * as React from "react"; + +const SIDEBAR_COOKIE_NAME = "sidebar_state"; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = "16rem"; +const SIDEBAR_WIDTH_MOBILE = "18rem"; +const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_KEYBOARD_SHORTCUT = "b"; + +type SidebarContextProps = { + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + + return context; +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + } +>( + ( + { defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, + ref + ) => { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }, + [setOpenProp, open] + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed"; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ); + + return ( + + +
+ {children} +
+
+
+ ); + } +); +SidebarProvider.displayName = "SidebarProvider"; + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; + } +>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === "none") { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ); + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ); +}); +Sidebar.displayName = "Sidebar"; + +const SidebarTrigger = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( + + ); +}); +SidebarTrigger.displayName = "SidebarTrigger"; + +const SidebarRail = React.forwardRef>( + ({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( +