feat: revamp sharing modal shell (#6190)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
Piyush Gupta
2025-07-11 09:47:43 +05:30
committed by GitHub
parent d6ecafbc23
commit 17d60eb1e7
29 changed files with 3322 additions and 626 deletions

View File

@@ -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<React.SetStateAction<boolean>>;
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide">
{showView === "start" ? (
<div className="flex h-full max-w-full flex-col overflow-hidden">
{survey.type === "link" && (
<div className="flex h-2/5 w-full flex-col items-center justify-center space-y-6 p-8 text-center">
<DialogTitle>
<p className="pt-2 text-xl font-semibold text-slate-800">
{t("environments.surveys.summary.your_survey_is_public")} 🎉
</p>
</DialogTitle>
<DialogDescription className="hidden" />
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>
</div>
)}
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-b-lg bg-slate-50 px-8">
<p className="text-sm text-slate-500">{t("environments.surveys.summary.whats_next")}</p>
<div className="grid grid-cols-4 gap-2">
<button
type="button"
onClick={() => setShowView("embed")}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
<Code2Icon className="h-6 w-6 text-slate-700" />
{t("environments.surveys.summary.embed_survey")}
</button>
<Link
href={`/environments/${environmentId}/settings/notifications`}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
<BellRing className="h-6 w-6 text-slate-700" />
{t("environments.surveys.summary.configure_alerts")}
</Link>
<Link
href={`/environments/${environmentId}/integrations`}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
<BlocksIcon className="h-6 w-6 text-slate-700" />
{t("environments.surveys.summary.setup_integrations")}
</Link>
<button
type="button"
onClick={() => setShowView("panel")}
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
<UsersRound className="h-6 w-6 text-slate-700" />
{t("environments.surveys.summary.send_to_panel")}
<Badge
size="tiny"
type="success"
className="absolute right-3 top-3"
text={t("common.new")}
/>
</button>
</div>
</div>
</div>
) : showView === "embed" ? (
<>
<DialogTitle className="sr-only">{t("environments.surveys.summary.embed_survey")}</DialogTitle>
<EmbedView
tabs={survey.type === "link" ? tabs : [tabs[4]]}
activeId={activeId}
environmentId={environmentId}
setActiveId={setActiveId}
survey={survey}
email={email}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
</>
) : showView === "panel" ? (
<>
<DialogTitle className="sr-only">{t("environments.surveys.summary.send_to_panel")}</DialogTitle>
</>
) : null}
</DialogContent>
</Dialog>
);
};

View File

@@ -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<ModalState>({
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<ModalState, "dropdown">) => {
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 = ({
<Button
className="h-10"
onClick={() => {
setModalState((prev) => ({ ...prev, embed: true }));
setModalState((prev) => ({ ...prev, share: true }));
}}>
{t("environments.surveys.summary.share_survey")}
</Button>
{user && (
<>
{shareEmbedViews.map(({ key, modalView, setOpen }) => (
<ShareEmbedSurvey
key={key}
survey={survey}
publicDomain={publicDomain}
open={modalState[key as keyof ModalState]}
setOpen={setOpen}
user={user}
modalView={modalView}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
))}
<SuccessMessage environment={environment} survey={survey} />
</>
<ShareSurveyModal
survey={survey}
publicDomain={publicDomain}
open={modalState.start || modalState.share}
setOpen={(open) => {
if (!open) {
handleShareModalToggle(false);
setModalState((prev) => ({ ...prev, share: false }));
}
}}
user={user}
modalView={modalState.start ? "start" : "share"}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
)}
<SuccessMessage environment={environment} survey={survey} />
{responseCount > 0 && (
<EditPublicSurveyAlertDialog

View File

@@ -1,4 +1,4 @@
import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey";
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LucideIcon } from "lucide-react";
@@ -90,14 +90,6 @@ const mockUser = {
locale: "en-US",
} as unknown as TUser;
// Mocks
const mockRouterRefresh = vi.fn();
vi.mock("next/navigation", () => ({
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 }) => <span data-testid="badge-mock">{text}</span>),
}));
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 }) => (
<div>
<div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div>
<div data-testid="embedview-activeid">{activeId}</div>
<div data-testid="embedview-survey-id">{survey.id}</div>
<div data-testid="embedview-email">{email}</div>
<div data-testid="embedview-surveyUrl">{surveyUrl}</div>
<div data-testid="embedview-publicDomain">{publicDomain}</div>
<div data-testid="embedview-locale">{locale}</div>
<div data-testid="shareview-tabs">{JSON.stringify(tabs)}</div>
<div data-testid="shareview-activeid">{activeId}</div>
<div data-testid="shareview-survey-id">{survey.id}</div>
<div data-testid="shareview-email">{email}</div>
<div data-testid="shareview-surveyUrl">{surveyUrl}</div>
<div data-testid="shareview-publicDomain">{publicDomain}</div>
<div data-testid="shareview-locale">{locale}</div>
</div>
)
);
});
test("renders initial 'start' view correctly when open and modalView is 'start' for link survey", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} />);
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyLink} />);
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(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} />);
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyWeb} />);
// 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(<ShareEmbedSurvey {...defaultProps} />);
const embedButton = screen.getByText("environments.surveys.summary.embed_survey");
render(<ShareSurveyModal {...defaultProps} />);
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(<ShareEmbedSurvey {...defaultProps} />);
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(<ShareEmbedSurvey {...defaultProps} open={true} survey={mockSurveyWeb} />);
render(<ShareSurveyModal {...defaultProps} open={true} survey={mockSurveyWeb} />);
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(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
const embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyLink} modalView="share" />);
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(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} modalView="embed" />);
const embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyWeb} modalView="share" />);
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(
<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} modalView="embed" />
<ShareSurveyModal {...defaultProps} survey={mockSurveyWeb} modalView="share" />
);
expect(vi.mocked(mockEmbedViewComponent).mock.calls[0][0].activeId).toBe("app");
expect(vi.mocked(mockShareViewComponent).mock.calls[0][0].activeId).toBe("app");
rerender(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
expect(vi.mocked(mockEmbedViewComponent).mock.calls[1][0].activeId).toBe("app"); // Current behavior
rerender(<ShareSurveyModal {...defaultProps} survey={mockSurveyLink} modalView="share" />);
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(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument();
render(<ShareSurveyModal {...defaultProps} open={true} modalView="share" />);
expect(mockShareViewComponent).toHaveBeenCalled();
expect(screen.getByTestId("shareview-tabs")).toBeInTheDocument();
cleanup();
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="panel" />);
// Panel view currently just shows a title
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
render(<ShareSurveyModal {...defaultProps} open={true} modalView="start" />);
// 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(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument(); // Starts in embed
const { rerender } = render(<ShareSurveyModal {...defaultProps} open={true} modalView="share" />);
expect(screen.getByTestId("shareview-tabs")).toBeInTheDocument(); // Starts in embed
rerender(<ShareEmbedSurvey {...defaultProps} open={false} modalView="embed" />);
rerender(<ShareSurveyModal {...defaultProps} open={false} modalView="share" />);
// 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(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
let embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyLink} modalView="share" />);
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(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLinkSingleUse} modalView="embed" />);
embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyLinkSingleUse} modalView="share" />);
embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string }[];
};
linkTab = embedViewProps.tabs.find((tab) => tab.id === "link");

View File

@@ -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<React.SetStateAction<boolean>>;
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>(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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide">
{showView === "start" ? (
<SuccessView
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
user={user}
tabs={linkTabs}
handleViewChange={handleViewChange}
handleEmbedViewWithTab={handleEmbedViewWithTab}
/>
) : (
<ShareView
tabs={survey.type === "link" ? linkTabs : appTabs}
activeId={activeId}
environmentId={environmentId}
setActiveId={setActiveId}
survey={survey}
email={email}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
)}
</DialogContent>
</Dialog>
);
};

View File

@@ -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: () => <div data-testid="app-tab">AppTab Content</div>,
}));
vi.mock("./EmailTab", () => ({
EmailTab: (props: { surveyId: string; email: string }) => (
<div data-testid="email-tab">
EmailTab Content for {props.surveyId} with {props.email}
</div>
),
}));
vi.mock("./LinkTab", () => ({
LinkTab: (props: { survey: any; surveyUrl: string }) => (
<div data-testid="link-tab">
LinkTab Content for {props.survey.id} at {props.surveyUrl}
</div>
),
}));
vi.mock("./WebsiteTab", () => ({
WebsiteTab: (props: { surveyUrl: string; environmentId: string }) => (
<div data-testid="website-tab">
WebsiteTab Content for {props.surveyUrl} in {props.environmentId}
</div>
),
}));
vi.mock("./personal-links-tab", () => ({
PersonalLinksTab: (props: { segments: any[]; surveyId: string; environmentId: string }) => (
<div data-testid="personal-links-tab">
PersonalLinksTab Content for {props.surveyId} in {props.environmentId}
</div>
),
}));
vi.mock("@/modules/ui/components/upgrade-prompt", () => ({
UpgradePrompt: (props: { title: string; description: string; buttons: any[] }) => (
<div data-testid="upgrade-prompt">
{props.title} - {props.description}
</div>
),
}));
// Mock @tolgee/react
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
ArrowLeftIcon: () => <div data-testid="arrow-left-icon">ArrowLeftIcon</div>,
MailIcon: () => <div data-testid="mail-icon">MailIcon</div>,
LinkIcon: () => <div data-testid="link-icon">LinkIcon</div>,
GlobeIcon: () => <div data-testid="globe-icon">GlobeIcon</div>,
SmartphoneIcon: () => <div data-testid="smartphone-icon">SmartphoneIcon</div>,
AlertCircle: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-circle">
AlertCircle
</div>
),
AlertTriangle: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-triangle">
AlertTriangle
</div>
),
Info: ({ className }: { className?: string }) => (
<div className={className} data-testid="info">
Info
</div>
),
}));
const mockTabs = [
{ id: "email", label: "Email", icon: () => <div data-testid="email-tab-icon" /> },
{ id: "webpage", label: "Web Page", icon: () => <div data-testid="webpage-tab-icon" /> },
{ id: "link", label: "Link", icon: () => <div data-testid="link-tab-icon" /> },
{ id: "app", label: "App", icon: () => <div data-testid="app-tab-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(<EmbedView {...defaultProps} survey={mockSurveyWeb} />);
// 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(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
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(<EmbedView {...defaultProps} activeId="email" />);
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(<EmbedView {...defaultProps} activeId="webpage" />);
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(<EmbedView {...defaultProps} activeId="link" />);
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(<EmbedView {...defaultProps} activeId="app" />);
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
});
test("calls setActiveId when a responsive tab is clicked", async () => {
render(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
// 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(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
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(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
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");
});
});

View File

@@ -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<React.SetStateAction<string>>;
environmentId: string;
survey: any;
email: string;
surveyUrl: string;
publicDomain: string;
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
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 <EmailTab surveyId={survey.id} email={email} />;
case "webpage":
return <WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />;
case "link":
return (
<LinkTab
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
);
case "app":
return <AppTab />;
case "personal-links":
return (
<PersonalLinksTab
segments={segments}
surveyId={survey.id}
environmentId={environmentId}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
);
default:
return null;
}
};
return (
<div className="h-full overflow-hidden">
<div className="grid h-full grid-cols-4">
{survey.type === "link" && (
<div className={cn("col-span-1 hidden flex-col gap-3 border-r border-slate-200 p-4 lg:flex")}>
{tabs.map((tab) => (
<Button
variant="ghost"
key={tab.id}
onClick={() => setActiveId(tab.id)}
autoFocus={tab.id === activeId}
className={cn(
"flex justify-start rounded-md border px-4 py-2 text-slate-600",
// "focus:ring-0 focus:ring-offset-0", // enable these classes to remove the focus rings on buttons
tab.id === activeId
? "border-slate-200 bg-slate-100 font-semibold text-slate-900"
: "border-transparent text-slate-500 hover:text-slate-700"
)}
aria-current={tab.id === activeId ? "page" : undefined}>
<tab.icon />
{tab.label}
</Button>
))}
</div>
)}
<div
className={`col-span-4 h-full overflow-y-auto bg-slate-50 px-4 py-6 ${survey.type === "link" ? "lg:col-span-3" : ""} lg:p-6`}>
{renderActiveTab()}
<div className="mt-2 rounded-md p-3 text-center lg:hidden">
{tabs.slice(0, 2).map((tab) => (
<Button
variant="ghost"
key={tab.id}
onClick={() => setActiveId(tab.id)}
className={cn(
"rounded-md px-4 py-2",
tab.id === activeId
? "bg-white text-slate-900 shadow-sm"
: "border-transparent text-slate-700 hover:text-slate-900"
)}>
{tab.label}
</Button>
))}
</div>
</div>
</div>
</div>
);
};

View File

@@ -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: () => <div data-testid="app-tab">AppTab Content</div>,
}));
vi.mock("./EmailTab", () => ({
EmailTab: (props: { surveyId: string; email: string }) => (
<div data-testid="email-tab">
EmailTab Content for {props.surveyId} with {props.email}
</div>
),
}));
vi.mock("./LinkTab", () => ({
LinkTab: (props: { survey: any; surveyUrl: string }) => (
<div data-testid="link-tab">
LinkTab Content for {props.survey.id} at {props.surveyUrl}
</div>
),
}));
vi.mock("./WebsiteTab", () => ({
WebsiteTab: (props: { surveyUrl: string; environmentId: string }) => (
<div data-testid="website-tab">
WebsiteTab Content for {props.surveyUrl} in {props.environmentId}
</div>
),
}));
vi.mock("./personal-links-tab", () => ({
PersonalLinksTab: (props: { segments: any[]; surveyId: string; environmentId: string }) => (
<div data-testid="personal-links-tab">
PersonalLinksTab Content for {props.surveyId} in {props.environmentId}
</div>
),
}));
vi.mock("@/modules/ui/components/upgrade-prompt", () => ({
UpgradePrompt: (props: { title: string; description: string; buttons: any[] }) => (
<div data-testid="upgrade-prompt">
{props.title} - {props.description}
</div>
),
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
ArrowLeftIcon: () => <div data-testid="arrow-left-icon">ArrowLeftIcon</div>,
MailIcon: () => <div data-testid="mail-icon">MailIcon</div>,
LinkIcon: () => <div data-testid="link-icon">LinkIcon</div>,
GlobeIcon: () => <div data-testid="globe-icon">GlobeIcon</div>,
SmartphoneIcon: () => <div data-testid="smartphone-icon">SmartphoneIcon</div>,
AlertCircle: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-circle">
AlertCircle
</div>
),
AlertTriangle: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-triangle">
AlertTriangle
</div>
),
Info: ({ className }: { className?: string }) => (
<div className={className} data-testid="info">
Info
</div>
),
}));
// Mock sidebar components
vi.mock("@/modules/ui/components/sidebar", () => ({
SidebarProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Sidebar: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SidebarContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SidebarGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SidebarGroupContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SidebarGroupLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SidebarMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SidebarMenuItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SidebarMenuButton: ({
children,
onClick,
tooltip,
className,
}: {
children: React.ReactNode;
onClick: () => void;
tooltip: string;
className?: string;
}) => (
<button type="button" onClick={onClick} className={className} aria-label={tooltip}>
{children}
</button>
),
}));
// Mock tooltip and typography components
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock("@/modules/ui/components/typography", () => ({
Small: ({ children }: { children: React.ReactNode }) => <small>{children}</small>,
}));
// Mock button component
vi.mock("@/modules/ui/components/button", () => ({
Button: ({
children,
onClick,
className,
variant,
}: {
children: React.ReactNode;
onClick: () => void;
className?: string;
variant?: string;
}) => (
<button type="button" onClick={onClick} className={className} data-variant={variant}>
{children}
</button>
),
}));
// Mock cn utility
vi.mock("@/lib/cn", () => ({
cn: (...args: any[]) => args.filter(Boolean).join(" "),
}));
const mockTabs = [
{ id: "email", label: "Email", icon: () => <div data-testid="email-tab-icon" /> },
{ id: "webpage", label: "Web Page", icon: () => <div data-testid="webpage-tab-icon" /> },
{ id: "link", label: "Link", icon: () => <div data-testid="link-tab-icon" /> },
{ id: "app", label: "App", icon: () => <div data-testid="app-tab-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(<ShareView {...defaultProps} survey={mockSurveyApp} />);
// 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(<ShareView {...defaultProps} survey={mockSurveyLink} />);
// 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(<ShareView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
const webpageTabButton = screen.getByLabelText("Web Page");
await userEvent.click(webpageTabButton);
expect(defaultProps.setActiveId).toHaveBeenCalledWith("webpage");
});
test("renders EmailTab when activeId is 'email'", () => {
render(<ShareView {...defaultProps} activeId="email" />);
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(<ShareView {...defaultProps} activeId="webpage" />);
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(<ShareView {...defaultProps} activeId="link" />);
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(<ShareView {...defaultProps} activeId="app" />);
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
});
test("renders PersonalLinksTab when activeId is 'personal-links'", () => {
render(<ShareView {...defaultProps} activeId="personal-links" />);
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(<ShareView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
// 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(<ShareView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
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(<ShareView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
// 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");
}
});
});

View File

@@ -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<React.SetStateAction<string>>;
environmentId: string;
survey: TSurvey;
email: string;
surveyUrl: string;
publicDomain: string;
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
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 <EmailTab surveyId={survey.id} email={email} />;
case "webpage":
return <WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />;
case "link":
return (
<LinkTab
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
);
case "app":
return <AppTab />;
case "personal-links":
return (
<PersonalLinksTab
segments={segments}
surveyId={survey.id}
environmentId={environmentId}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
);
default:
return null;
}
};
return (
<div className="h-full">
<div className={`flex h-full ${survey.type === "link" ? "lg:grid lg:grid-cols-4" : ""}`}>
{survey.type === "link" && (
<SidebarProvider
open={isLargeScreen}
className="flex min-h-0 w-auto lg:col-span-1"
style={
{
"--sidebar-width": "100%",
} as React.CSSProperties
}>
<Sidebar className="relative h-full p-0" variant="inset" collapsible="icon">
<SidebarContent className="h-full border-r border-slate-200 bg-white p-4">
<SidebarGroup className="p-0">
<SidebarGroupLabel>
<Small className="text-xs text-slate-500">Share via</Small>
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="flex flex-col gap-1">
{tabs.map((tab) => (
<SidebarMenuItem key={tab.id}>
<SidebarMenuButton
onClick={() => 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.icon className="h-4 w-4 text-slate-700" />
<span>{tab.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
)}
<div
className={`h-full w-full grow overflow-y-auto bg-slate-50 px-4 py-6 lg:p-6 ${survey.type === "link" ? "lg:col-span-3" : ""}`}>
{renderActiveTab()}
<div className="flex justify-center gap-2 rounded-md pt-6 text-center md:hidden">
{tabs.map((tab) => (
<TooltipRenderer tooltipContent={tab.label} key={tab.id}>
<Button
variant="ghost"
onClick={() => setActiveId(tab.id)}
className={cn(
"rounded-md px-4 py-2",
tab.id === activeId
? "bg-white text-slate-900 shadow-sm hover:bg-white"
: "border-transparent text-slate-700 hover:text-slate-900"
)}>
<tab.icon className="h-4 w-4 text-slate-700" />
</Button>
</TooltipRenderer>
))}
</div>
</div>
</div>
</div>
);
};

View File

@@ -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<SuccessViewProps> = ({
survey,
surveyUrl,
publicDomain,
setSurveyUrl,
user,
tabs,
handleViewChange,
handleEmbedViewWithTab,
}) => {
const { t } = useTranslate();
const environmentId = survey.environmentId;
return (
<div className="flex h-full max-w-full flex-col overflow-hidden">
{survey.type === "link" && (
<div className="flex h-2/5 w-full flex-col items-center justify-center gap-8 py-[100px] text-center">
<p className="text-xl font-semibold text-slate-900">
{t("environments.surveys.summary.your_survey_is_public")} 🎉
</p>
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>
</div>
)}
<div className="flex h-full flex-col items-center justify-center gap-8 rounded-b-lg bg-slate-50 px-8 py-4">
<p className="text-sm font-medium text-slate-900">{t("environments.surveys.summary.whats_next")}</p>
<div className="grid grid-cols-4 gap-2">
<button
type="button"
onClick={() => handleViewChange("share")}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<Share2Icon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.share_survey")}
</button>
<button
type="button"
onClick={() => handleEmbedViewWithTab(tabs[1].id)}
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.use_personal_links")}
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
</button>
<Link
href={`/environments/${environmentId}/settings/notifications`}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<BellRing className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.configure_alerts")}
</Link>
<Link
href={`/environments/${environmentId}/integrations`}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<BlocksIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.setup_integrations")}
</Link>
</div>
</div>
</div>
);
};

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "檢視網站",

View File

@@ -11,7 +11,7 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
<Input
data-testid="survey-url-input"
autoFocus={true}
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent"
className="h-9 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-3 py-1 text-slate-800 caret-transparent"
value={surveyUrl}
readOnly
/>
@@ -19,7 +19,8 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
//loading state
<div
data-testid="loading-div"
className="mt-2 h-10 w-full min-w-96 animate-pulse rounded-lg bg-slate-100 px-4 py-2 text-slate-800 caret-transparent"></div>
className="h-9 w-full min-w-96 animate-pulse rounded-lg bg-slate-100 px-3 py-1 text-slate-800 caret-transparent"
/>
)}
</>
);

View File

@@ -59,9 +59,9 @@ export const ShareSurveyLink = ({
return (
<div
className={`flex max-w-full flex-col items-center justify-center space-x-2 ${survey.singleUse?.enabled ? "flex-col" : "lg:flex-row"}`}>
className={`flex max-w-full flex-col items-center justify-center gap-2 ${survey.singleUse?.enabled ? "flex-col" : "lg:flex-row"}`}>
<SurveyLinkDisplay surveyUrl={surveyUrl} key={surveyUrl} />
<div className="mt-2 flex items-center justify-center space-x-2">
<div className="flex items-center justify-center space-x-2">
<LanguageDropdown survey={survey} setLanguage={setLanguage} locale={locale} />
<Button
title={t("environments.surveys.preview_survey_in_a_new_tab")}

View File

@@ -19,9 +19,9 @@ describe("Badge", () => {
expect(screen.getByText("Warning")).toHaveClass("text-amber-800");
rerender(<Badge text="Success" type="success" size="normal" />);
expect(screen.getByText("Success")).toHaveClass("bg-emerald-100");
expect(screen.getByText("Success")).toHaveClass("border-emerald-200");
expect(screen.getByText("Success")).toHaveClass("text-emerald-800");
expect(screen.getByText("Success")).toHaveClass("bg-green-50");
expect(screen.getByText("Success")).toHaveClass("border-green-600");
expect(screen.getByText("Success")).toHaveClass("text-green-800");
rerender(<Badge text="Error" type="error" size="normal" />);
expect(screen.getByText("Error")).toHaveClass("bg-red-100");
@@ -64,9 +64,9 @@ describe("Badge", () => {
test("combines all classes correctly", () => {
render(<Badge text="Combined" type="success" size="large" className="custom-class" />);
const badge = screen.getByText("Combined");
expect(badge).toHaveClass("bg-emerald-100");
expect(badge).toHaveClass("border-emerald-200");
expect(badge).toHaveClass("text-emerald-800");
expect(badge).toHaveClass("bg-green-50");
expect(badge).toHaveClass("border-green-600");
expect(badge).toHaveClass("text-green-800");
expect(badge).toHaveClass("px-3.5");
expect(badge).toHaveClass("py-1");
expect(badge).toHaveClass("text-sm");

View File

@@ -11,21 +11,21 @@ interface BadgeProps {
export const Badge: React.FC<BadgeProps> = ({ text, type, size, className, role }) => {
const bgColor = {
warning: "bg-amber-100",
success: "bg-emerald-100",
success: "bg-green-50",
error: "bg-red-100",
gray: "bg-slate-100",
};
const borderColor = {
warning: "border-amber-200",
success: "border-emerald-200",
success: "border-green-600",
error: "border-red-200",
gray: "border-slate-200",
};
const textColor = {
warning: "text-amber-800",
success: "text-emerald-800",
success: "text-green-800",
error: "text-red-800",
gray: "text-slate-600",
};

View File

@@ -83,7 +83,7 @@ const DialogContent = React.forwardRef<
{...props}>
{children}
{!hideCloseButton && (
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent absolute right-3 top-[-0.25rem] z-10 rounded-sm bg-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:text-slate-500">
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent absolute right-3 top-[-0.25rem] z-10 rounded-sm bg-transparent transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:text-slate-500">
<X className="size-4 text-slate-500" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>

View File

@@ -0,0 +1,219 @@
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { Separator } from ".";
// Mock Radix UI Separator component
vi.mock("@radix-ui/react-separator", () => {
const Root = vi.fn(({ className, orientation, decorative, ...props }) => (
<div
data-testid="separator-root"
className={className}
data-orientation={orientation}
data-decorative={decorative}
{...props}
/>
)) as any;
Root.displayName = "SeparatorRoot";
return {
Root,
};
});
describe("Separator Component", () => {
afterEach(() => {
cleanup();
});
test("renders correctly with default props", () => {
render(<Separator />);
const separator = screen.getByTestId("separator-root");
expect(separator).toBeInTheDocument();
expect(separator).toHaveAttribute("data-orientation", "horizontal");
expect(separator).toHaveAttribute("data-decorative", "true");
});
test("applies correct default classes for horizontal orientation", () => {
render(<Separator />);
const separator = screen.getByTestId("separator-root");
expect(separator).toHaveClass("bg-border");
expect(separator).toHaveClass("shrink-0");
expect(separator).toHaveClass("h-[1px]");
expect(separator).toHaveClass("w-full");
});
test("applies correct classes for vertical orientation", () => {
render(<Separator orientation="vertical" />);
const separator = screen.getByTestId("separator-root");
expect(separator).toHaveAttribute("data-orientation", "vertical");
expect(separator).toHaveClass("bg-border");
expect(separator).toHaveClass("shrink-0");
expect(separator).toHaveClass("h-full");
expect(separator).toHaveClass("w-[1px]");
});
test("handles custom className correctly", () => {
render(<Separator className="custom-separator" />);
const separator = screen.getByTestId("separator-root");
expect(separator).toHaveClass("custom-separator");
expect(separator).toHaveClass("bg-border");
expect(separator).toHaveClass("shrink-0");
});
test("forwards decorative prop correctly", () => {
const { rerender } = render(<Separator decorative={false} />);
let separator = screen.getByTestId("separator-root");
expect(separator).toHaveAttribute("data-decorative", "false");
rerender(<Separator decorative={true} />);
separator = screen.getByTestId("separator-root");
expect(separator).toHaveAttribute("data-decorative", "true");
});
test("uses default decorative value when not provided", () => {
render(<Separator />);
const separator = screen.getByTestId("separator-root");
expect(separator).toHaveAttribute("data-decorative", "true");
});
test("uses default orientation value when not provided", () => {
render(<Separator />);
const separator = screen.getByTestId("separator-root");
expect(separator).toHaveAttribute("data-orientation", "horizontal");
});
test("forwards additional props correctly", () => {
render(<Separator data-testid="custom-separator" aria-label="Custom separator" role="separator" />);
const separator = screen.getByTestId("custom-separator");
expect(separator).toHaveAttribute("data-testid", "custom-separator");
expect(separator).toHaveAttribute("aria-label", "Custom separator");
expect(separator).toHaveAttribute("role", "separator");
});
test("ref forwarding works correctly", () => {
const ref = vi.fn();
render(<Separator ref={ref} />);
expect(ref).toHaveBeenCalled();
});
test("combines orientation and custom className correctly", () => {
const { rerender } = render(<Separator orientation="horizontal" className="my-separator" />);
let separator = screen.getByTestId("separator-root");
expect(separator).toHaveClass("my-separator");
expect(separator).toHaveClass("h-[1px]");
expect(separator).toHaveClass("w-full");
rerender(<Separator orientation="vertical" className="my-separator" />);
separator = screen.getByTestId("separator-root");
expect(separator).toHaveClass("my-separator");
expect(separator).toHaveClass("h-full");
expect(separator).toHaveClass("w-[1px]");
});
test("applies all base classes regardless of orientation", () => {
const { rerender } = render(<Separator orientation="horizontal" />);
let separator = screen.getByTestId("separator-root");
expect(separator).toHaveClass("bg-border");
expect(separator).toHaveClass("shrink-0");
rerender(<Separator orientation="vertical" />);
separator = screen.getByTestId("separator-root");
expect(separator).toHaveClass("bg-border");
expect(separator).toHaveClass("shrink-0");
});
test("handles undefined className gracefully", () => {
render(<Separator className={undefined} />);
const separator = screen.getByTestId("separator-root");
expect(separator).toBeInTheDocument();
expect(separator).toHaveClass("bg-border");
expect(separator).toHaveClass("shrink-0");
});
test("handles empty className gracefully", () => {
render(<Separator className="" />);
const separator = screen.getByTestId("separator-root");
expect(separator).toBeInTheDocument();
expect(separator).toHaveClass("bg-border");
expect(separator).toHaveClass("shrink-0");
});
test("handles multiple custom classes", () => {
render(<Separator className="class1 class2 class3" />);
const separator = screen.getByTestId("separator-root");
expect(separator).toHaveClass("class1");
expect(separator).toHaveClass("class2");
expect(separator).toHaveClass("class3");
expect(separator).toHaveClass("bg-border");
expect(separator).toHaveClass("shrink-0");
});
test("export is available", () => {
expect(Separator).toBeDefined();
expect(typeof Separator).toBe("object"); // forwardRef returns an object
});
test("component has correct displayName", () => {
expect(Separator.displayName).toBe(SeparatorPrimitive.Root.displayName);
});
test("renders with all props combined", () => {
render(
<Separator
orientation="vertical"
decorative={false}
className="custom-class"
data-testid="full-separator"
aria-label="Vertical separator"
/>
);
const separator = screen.getByTestId("full-separator");
expect(separator).toBeInTheDocument();
expect(separator).toHaveAttribute("data-orientation", "vertical");
expect(separator).toHaveAttribute("data-decorative", "false");
expect(separator).toHaveAttribute("data-testid", "full-separator");
expect(separator).toHaveAttribute("aria-label", "Vertical separator");
expect(separator).toHaveClass("custom-class");
expect(separator).toHaveClass("bg-border");
expect(separator).toHaveClass("shrink-0");
expect(separator).toHaveClass("h-full");
expect(separator).toHaveClass("w-[1px]");
});
test("orientation prop type checking - accepts valid values", () => {
const { rerender } = render(<Separator orientation="horizontal" />);
let separator = screen.getByTestId("separator-root");
expect(separator).toHaveAttribute("data-orientation", "horizontal");
rerender(<Separator orientation="vertical" />);
separator = screen.getByTestId("separator-root");
expect(separator).toHaveAttribute("data-orientation", "vertical");
});
test("decorative prop type checking - accepts boolean values", () => {
const { rerender } = render(<Separator decorative={true} />);
let separator = screen.getByTestId("separator-root");
expect(separator).toHaveAttribute("data-decorative", "true");
rerender(<Separator decorative={false} />);
separator = screen.getByTestId("separator-root");
expect(separator).toHaveAttribute("data-decorative", "false");
});
});

View File

@@ -0,0 +1,25 @@
"use client";
import { cn } from "@/modules/ui/lib/utils";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import * as React from "react";
const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@@ -0,0 +1,514 @@
import * as SheetPrimitive from "@radix-ui/react-dialog";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
} from ".";
// Mock Radix UI Dialog components (Sheet uses Dialog primitives)
vi.mock("@radix-ui/react-dialog", () => {
const Root = vi.fn(({ children }) => <div data-testid="sheet-root">{children}</div>) as any;
Root.displayName = "SheetRoot";
const Trigger = vi.fn(({ children }) => <button data-testid="sheet-trigger">{children}</button>) as any;
Trigger.displayName = "SheetTrigger";
const Portal = vi.fn(({ children }) => <div data-testid="sheet-portal">{children}</div>) as any;
Portal.displayName = "SheetPortal";
const Overlay = vi.fn(({ className, ...props }) => (
<div data-testid="sheet-overlay" className={className} {...props} />
)) as any;
Overlay.displayName = "SheetOverlay";
const Content = vi.fn(({ className, children, ...props }) => (
<div data-testid="sheet-content" className={className} {...props}>
{children}
</div>
)) as any;
Content.displayName = "SheetContent";
const Close = vi.fn(({ className, children }) => (
<button data-testid="sheet-close" className={className}>
{children}
</button>
)) as any;
Close.displayName = "SheetClose";
const Title = vi.fn(({ className, children, ...props }) => (
<h2 data-testid="sheet-title" className={className} {...props}>
{children}
</h2>
)) as any;
Title.displayName = "SheetTitle";
const Description = vi.fn(({ className, children, ...props }) => (
<p data-testid="sheet-description" className={className} {...props}>
{children}
</p>
)) 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 }) => (
<div data-testid="x-icon" className={className}>
X Icon
</div>
),
}));
describe("Sheet Components", () => {
afterEach(() => {
cleanup();
});
test("Sheet renders correctly", () => {
render(
<Sheet>
<div>Sheet Content</div>
</Sheet>
);
expect(screen.getByTestId("sheet-root")).toBeInTheDocument();
expect(screen.getByText("Sheet Content")).toBeInTheDocument();
});
test("SheetTrigger renders correctly", () => {
render(
<SheetTrigger>
<span>Open Sheet</span>
</SheetTrigger>
);
expect(screen.getByTestId("sheet-trigger")).toBeInTheDocument();
expect(screen.getByText("Open Sheet")).toBeInTheDocument();
});
test("SheetClose renders correctly", () => {
render(
<SheetClose>
<span>Close Sheet</span>
</SheetClose>
);
expect(screen.getByTestId("sheet-close")).toBeInTheDocument();
expect(screen.getByText("Close Sheet")).toBeInTheDocument();
});
test("SheetPortal renders correctly", () => {
render(
<SheetPortal>
<div>Portal Content</div>
</SheetPortal>
);
expect(screen.getByTestId("sheet-portal")).toBeInTheDocument();
expect(screen.getByText("Portal Content")).toBeInTheDocument();
});
test("SheetOverlay renders with correct classes", () => {
render(<SheetOverlay className="test-class" />);
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(
<SheetContent>
<div>Test Content</div>
</SheetContent>
);
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(
<SheetContent side="top">
<div>Top Content</div>
</SheetContent>
);
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(
<SheetContent side="bottom">
<div>Bottom Content</div>
</SheetContent>
);
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(
<SheetContent side="left">
<div>Left Content</div>
</SheetContent>
);
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(
<SheetContent side="right">
<div>Right Content</div>
</SheetContent>
);
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(
<SheetContent className="custom-class">
<div>Custom Content</div>
</SheetContent>
);
const content = screen.getByTestId("sheet-content");
expect(content).toHaveClass("custom-class");
});
test("SheetContent has correct base classes", () => {
render(
<SheetContent>
<div>Base Content</div>
</SheetContent>
);
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(
<SheetContent>
<div>Content</div>
</SheetContent>
);
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(
<SheetContent>
<div>Content</div>
</SheetContent>
);
const icon = screen.getByTestId("x-icon");
expect(icon).toBeInTheDocument();
expect(icon).toHaveClass("h-4");
expect(icon).toHaveClass("w-4");
});
test("SheetHeader renders correctly", () => {
render(
<SheetHeader className="test-class">
<div>Header Content</div>
</SheetHeader>
);
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(
<SheetFooter className="test-class">
<button>OK</button>
</SheetFooter>
);
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(<SheetTitle className="test-class">Sheet Title</SheetTitle>);
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(<SheetDescription className="test-class">Sheet Description</SheetDescription>);
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(
<SheetContent data-testid="custom-sheet" aria-label="Custom Sheet">
<div>Custom Content</div>
</SheetContent>
);
const content = screen.getByTestId("custom-sheet");
expect(content).toHaveAttribute("aria-label", "Custom Sheet");
});
test("SheetTitle forwards props correctly", () => {
render(<SheetTitle data-testid="custom-title">Custom Title</SheetTitle>);
const title = screen.getByTestId("custom-title");
expect(title).toHaveAttribute("data-testid", "custom-title");
});
test("SheetDescription forwards props correctly", () => {
render(<SheetDescription data-testid="custom-description">Custom Description</SheetDescription>);
const description = screen.getByTestId("custom-description");
expect(description).toHaveAttribute("data-testid", "custom-description");
});
test("SheetHeader forwards props correctly", () => {
render(
<SheetHeader data-testid="custom-header">
<div>Header</div>
</SheetHeader>
);
const header = screen.getByText("Header").parentElement;
expect(header).toHaveAttribute("data-testid", "custom-header");
});
test("SheetFooter forwards props correctly", () => {
render(
<SheetFooter data-testid="custom-footer">
<button>Footer</button>
</SheetFooter>
);
const footer = screen.getByText("Footer").parentElement;
expect(footer).toHaveAttribute("data-testid", "custom-footer");
});
test("SheetHeader handles dangerouslySetInnerHTML", () => {
const htmlContent = "<span>Dangerous HTML</span>";
render(<SheetHeader dangerouslySetInnerHTML={{ __html: htmlContent }} />);
const header = document.querySelector(".flex.flex-col.space-y-2");
expect(header).toBeInTheDocument();
expect(header?.innerHTML).toContain(htmlContent);
});
test("SheetFooter handles dangerouslySetInnerHTML", () => {
const htmlContent = "<span>Dangerous Footer HTML</span>";
render(<SheetFooter dangerouslySetInnerHTML={{ __html: htmlContent }} />);
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(
<SheetContent>
<div>Content</div>
</SheetContent>
);
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(
<SheetContent ref={ref}>
<div>Content</div>
</SheetContent>
);
expect(ref).toHaveBeenCalled();
});
test("SheetTitle ref forwarding works", () => {
const ref = vi.fn();
render(<SheetTitle ref={ref}>Title</SheetTitle>);
expect(ref).toHaveBeenCalled();
});
test("SheetDescription ref forwarding works", () => {
const ref = vi.fn();
render(<SheetDescription ref={ref}>Description</SheetDescription>);
expect(ref).toHaveBeenCalled();
});
test("SheetOverlay ref forwarding works", () => {
const ref = vi.fn();
render(<SheetOverlay ref={ref} />);
expect(ref).toHaveBeenCalled();
});
test("Full sheet example renders correctly", () => {
render(
<Sheet>
<SheetTrigger>
<span>Open Sheet</span>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Sheet Title</SheetTitle>
<SheetDescription>Sheet Description</SheetDescription>
</SheetHeader>
<div>Sheet Body Content</div>
<SheetFooter>
<button>Cancel</button>
<button>Submit</button>
</SheetFooter>
</SheetContent>
</Sheet>
);
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();
});
});

View File

@@ -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<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className
)}
{...props}
ref={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<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ComponentRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
)
);
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ComponentRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-foreground text-lg font-semibold", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ComponentRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -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<HTMLButtonElement, any>(({ children, onClick, ...props }, ref) => (
<button ref={ref} onClick={onClick} {...props}>
{children}
</button>
));
MockButton.displayName = "MockButton";
return {
Button: MockButton,
};
});
// Mock Input component
vi.mock("@/modules/ui/components/input", () => {
const MockInput = React.forwardRef<HTMLInputElement, any>((props, ref) => <input ref={ref} {...props} />);
MockInput.displayName = "MockInput";
return {
Input: MockInput,
};
});
// Mock Separator component
vi.mock("@/modules/ui/components/separator", () => {
const MockSeparator = React.forwardRef<HTMLDivElement, any>((props, ref) => (
<div ref={ref} role="separator" {...props} />
));
MockSeparator.displayName = "MockSeparator";
return {
Separator: MockSeparator,
};
});
// Mock Sheet components
vi.mock("@/modules/ui/components/sheet", () => ({
Sheet: ({ children, open, onOpenChange }: any) => (
<div data-testid="sheet" data-open={open} onClick={() => onOpenChange?.(!open)}>
{children}
</div>
),
SheetContent: ({ children, side, ...props }: any) => (
<div data-testid="sheet-content" data-side={side} {...props}>
{children}
</div>
),
SheetHeader: ({ children }: any) => <div data-testid="sheet-header">{children}</div>,
SheetTitle: ({ children }: any) => <div data-testid="sheet-title">{children}</div>,
SheetDescription: ({ children }: any) => <div data-testid="sheet-description">{children}</div>,
}));
// Mock Skeleton component
vi.mock("@/modules/ui/components/skeleton", () => ({
Skeleton: ({ className, style, ...props }: any) => (
<div data-testid="skeleton" className={className} style={style} {...props} />
),
}));
// Mock Tooltip components
vi.mock("@/modules/ui/components/tooltip", () => ({
Tooltip: ({ children }: any) => <div data-testid="tooltip">{children}</div>,
TooltipContent: ({ children, hidden, ...props }: any) => (
<div data-testid="tooltip-content" data-hidden={hidden} {...props}>
{children}
</div>
),
TooltipProvider: ({ children }: any) => <div data-testid="tooltip-provider">{children}</div>,
TooltipTrigger: ({ children }: any) => <div data-testid="tooltip-trigger">{children}</div>,
}));
// Mock Slot from @radix-ui/react-slot
vi.mock("@radix-ui/react-slot", () => {
const MockSlot = React.forwardRef<HTMLDivElement, any>(({ children, ...props }, ref) => (
<div ref={ref} {...props}>
{children}
</div>
));
MockSlot.displayName = "MockSlot";
return {
Slot: MockSlot,
};
});
// Mock Lucide icons
vi.mock("lucide-react", () => ({
Columns2Icon: () => <div data-testid="columns2-icon" />,
}));
// 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 (
<div>
<div data-testid="sidebar-state">{sidebar?.state || "unknown"}</div>
<div data-testid="sidebar-open">{sidebar?.open?.toString() || "unknown"}</div>
<div data-testid="sidebar-mobile">{sidebar?.isMobile?.toString() || "unknown"}</div>
<div data-testid="sidebar-open-mobile">{sidebar?.openMobile?.toString() || "unknown"}</div>
<button type="button" data-testid="toggle-button" onClick={sidebar?.toggleSidebar}>
Toggle
</button>
<button type="button" data-testid="set-open-button" onClick={() => sidebar?.setOpen?.(true)}>
Set Open
</button>
<button
type="button"
data-testid="set-open-mobile-button"
onClick={() => sidebar?.setOpenMobile?.(true)}>
Set Open Mobile
</button>
</div>
);
};
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 <div>Test</div>;
};
expect(() => render(<TestComponentWithoutProvider />)).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(
<SidebarProvider>
<TestComponent />
</SidebarProvider>
);
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(
<SidebarProvider open={false} onOpenChange={onOpenChange}>
<TestComponent />
</SidebarProvider>
);
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(
<SidebarProvider>
<TestComponent />
</SidebarProvider>
);
// 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(
<SidebarProvider>
<SidebarTrigger onClick={customOnClick} />
<SidebarRail />
<TestComponent />
</SidebarProvider>
);
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(
<SidebarProvider>
<Sidebar collapsible="none" variant="floating" side="right">
<div>Sidebar Content</div>
</Sidebar>
</SidebarProvider>
);
expect(screen.getByText("Sidebar Content")).toBeInTheDocument();
// Test different variants
rerender(
<SidebarProvider>
<Sidebar variant="inset">
<div>Sidebar Content</div>
</Sidebar>
</SidebarProvider>
);
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(
<SidebarProvider>
<Component {...testProps}>{content && <div>{content}</div>}</Component>
</SidebarProvider>
);
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(
<SidebarProvider>
<SidebarGroup className="group-class">
<SidebarGroupLabel className="label-class">Group Label</SidebarGroupLabel>
<SidebarGroupAction className="action-class">
<div>Action</div>
</SidebarGroupAction>
<SidebarGroupContent className="content-class">
<div>Group Content</div>
</SidebarGroupContent>
</SidebarGroup>
</SidebarProvider>
);
// 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(
<SidebarProvider>
<SidebarGroupLabel asChild>
<h2>Group Label</h2>
</SidebarGroupLabel>
<SidebarGroupAction asChild>
<button type="button">Action</button>
</SidebarGroupAction>
</SidebarProvider>
);
expect(screen.getByText("Group Label")).toBeInTheDocument();
expect(screen.getByText("Action")).toBeInTheDocument();
});
});
describe("Menu Components", () => {
test("basic menu components render with custom classes", () => {
render(
<SidebarProvider>
<SidebarMenu className="menu-class">
<SidebarMenuItem className="item-class">
<div>Menu Item</div>
</SidebarMenuItem>
</SidebarMenu>
<SidebarMenuBadge className="badge-class">5</SidebarMenuBadge>
</SidebarProvider>
);
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(
<SidebarProvider>
<SidebarMenuButton
isActive
variant="outline"
size="sm"
tooltip="Button tooltip"
className="button-class">
<div>Menu Button</div>
</SidebarMenuButton>
</SidebarProvider>
);
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(
<SidebarProvider>
<SidebarMenuButton tooltip={{ children: "Button tooltip", side: "left" }}>
<div>Menu Button</div>
</SidebarMenuButton>
</SidebarProvider>
);
expect(screen.getByTestId("tooltip-content")).toBeInTheDocument();
// Test asChild
rerender(
<SidebarProvider>
<SidebarMenuButton asChild>
<a href="#">Menu Button</a>
</SidebarMenuButton>
</SidebarProvider>
);
expect(screen.getByText("Menu Button")).toBeInTheDocument();
});
test("SidebarMenuAction handles showOnHover and asChild", () => {
const { rerender } = render(
<SidebarProvider>
<SidebarMenuAction showOnHover>
<div>Action</div>
</SidebarMenuAction>
</SidebarProvider>
);
expect(screen.getByText("Action")).toBeInTheDocument();
rerender(
<SidebarProvider>
<SidebarMenuAction asChild>
<button type="button">Action</button>
</SidebarMenuAction>
</SidebarProvider>
);
expect(screen.getByText("Action")).toBeInTheDocument();
});
test("SidebarMenuSkeleton renders with icon option", () => {
const { rerender } = render(
<SidebarProvider>
<SidebarMenuSkeleton className="skeleton-class" />
</SidebarProvider>
);
expect(screen.getByTestId("skeleton")).toBeInTheDocument();
const skeleton = screen.getAllByTestId("skeleton")[0].parentElement;
expect(skeleton).toHaveClass("skeleton-class");
rerender(
<SidebarProvider>
<SidebarMenuSkeleton showIcon />
</SidebarProvider>
);
expect(screen.getAllByTestId("skeleton")).toHaveLength(2);
});
});
describe("Sub Menu Components", () => {
test("sub menu components render and handle all props", () => {
const { rerender } = render(
<SidebarProvider>
<SidebarMenuSub className="sub-menu-class">
<SidebarMenuSubItem>
<SidebarMenuSubButton isActive size="sm" className="sub-button-class">
<div>Sub Button</div>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</SidebarProvider>
);
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(
<SidebarProvider>
<SidebarMenuSubButton asChild>
<button type="button">Sub Button</button>
</SidebarMenuSubButton>
</SidebarProvider>
);
expect(screen.getByText("Sub Button")).toBeInTheDocument();
});
});
describe("Provider Configuration", () => {
test("SidebarProvider handles custom props and styling", () => {
render(
<SidebarProvider className="custom-class" style={{ backgroundColor: "red" }} defaultOpen={false}>
<TestComponent />
</SidebarProvider>
);
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 (
<button type="button" data-testid="function-callback-button" onClick={() => setOpen(false)}>
Set False
</button>
);
};
render(
<SidebarProvider>
<TestComponentWithCallback />
<TestComponent />
</SidebarProvider>
);
expect(screen.getByTestId("sidebar-open")).toHaveTextContent("true");
await user.click(screen.getByTestId("function-callback-button"));
expect(screen.getByTestId("sidebar-open")).toHaveTextContent("false");
});
});
});

View File

@@ -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<SidebarContextProps | null>(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<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-[[data-variant=inset]]:bg-sidebar flex min-h-svh w-full",
className
)}
ref={ref}
{...props}>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
);
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 (
<div
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-[--sidebar-width] flex-col",
className
)}
ref={ref}
{...props}>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-[--sidebar-width] p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className="text-sidebar-foreground group peer hidden w-full md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}>
<div
data-sidebar="sidebar"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow">
{children}
</div>
</div>
</div>
);
});
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<
React.ComponentRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}>
<Columns2Icon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
});
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button">>(
({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:hover:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
);
}
);
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<"main">>(
({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
);
}
);
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef<React.ComponentRef<typeof Input>, React.ComponentProps<typeof Input>>(
({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"bg-background focus-visible:ring-sidebar-ring h-8 w-full shadow-none focus-visible:ring-2",
className
)}
{...props}
/>
);
}
);
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => {
return (
<div ref={ref} data-sidebar="header" className={cn("flex flex-col gap-2 p-2", className)} {...props} />
);
}
);
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => {
return (
<div ref={ref} data-sidebar="footer" className={cn("flex flex-col gap-2 p-2", className)} {...props} />
);
}
);
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef<
React.ComponentRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
});
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
);
}
);
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
);
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-none transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
);
});
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-none transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
});
SidebarGroupAction.displayName = "SidebarGroupAction";
const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div ref={ref} data-sidebar="group-content" className={cn("w-full text-sm", className)} {...props} />
)
);
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
);
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(
({ className, ...props }, ref) => (
<li ref={ref} data-sidebar="menu-item" className={cn("group/menu-item relative", className)} {...props} />
)
);
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile} {...tooltip} />
</Tooltip>
);
}
);
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-none transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
);
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
);
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(
({ ...props }, ref) => <li ref={ref} {...props} />
);
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@@ -0,0 +1,258 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// Unmock the hook to test the actual implementation
vi.unmock("@/modules/ui/hooks/use-mobile");
const { useIsMobile } = await import("./use-mobile");
// Mock window.matchMedia
const mockMatchMedia = vi.fn();
const mockAddEventListener = vi.fn();
const mockRemoveEventListener = vi.fn();
Object.defineProperty(window, "matchMedia", {
writable: true,
value: mockMatchMedia,
});
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 1024,
});
describe("useIsMobile", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.clearAllMocks();
// Reset window.innerWidth to desktop size
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 1024,
});
// Default mock setup
mockMatchMedia.mockReturnValue({
addEventListener: mockAddEventListener,
removeEventListener: mockRemoveEventListener,
});
});
test("should return false initially when window width is above mobile breakpoint", () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 1024,
});
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});
test("should return true initially when window width is below mobile breakpoint", () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 600,
});
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);
});
test("should return true when window width equals mobile breakpoint - 1", () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 767,
});
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);
});
test("should return false when window width equals mobile breakpoint", () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 768,
});
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});
test("should setup media query with correct breakpoint", () => {
renderHook(() => useIsMobile());
expect(mockMatchMedia).toHaveBeenCalledWith("(max-width: 767px)");
});
test("should add event listener for media query changes", () => {
renderHook(() => useIsMobile());
expect(mockAddEventListener).toHaveBeenCalledWith("change", expect.any(Function));
});
test("should update state when media query changes", () => {
let changeHandler: () => void;
mockAddEventListener.mockImplementation((event, handler) => {
if (event === "change") {
changeHandler = handler;
}
});
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 1024,
});
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
// Simulate window resize to mobile
act(() => {
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 600,
});
changeHandler();
});
expect(result.current).toBe(true);
});
test("should update state when window resizes from mobile to desktop", () => {
let changeHandler: () => void;
mockAddEventListener.mockImplementation((event, handler) => {
if (event === "change") {
changeHandler = handler;
}
});
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 600,
});
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);
// Simulate window resize to desktop
act(() => {
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 1024,
});
changeHandler();
});
expect(result.current).toBe(false);
});
test("should handle multiple rapid changes", () => {
let changeHandler: () => void;
mockAddEventListener.mockImplementation((event, handler) => {
if (event === "change") {
changeHandler = handler;
}
});
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 1024,
});
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
// Multiple rapid changes
act(() => {
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 600,
});
changeHandler();
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 1024,
});
changeHandler();
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 400,
});
changeHandler();
});
expect(result.current).toBe(true);
});
test("should remove event listener on unmount", () => {
const { unmount } = renderHook(() => useIsMobile());
expect(mockAddEventListener).toHaveBeenCalledWith("change", expect.any(Function));
const addEventListenerCall = mockAddEventListener.mock.calls.find((call) => call[0] === "change");
const changeHandler = addEventListenerCall?.[1];
unmount();
expect(mockRemoveEventListener).toHaveBeenCalledWith("change", changeHandler);
});
test("should handle edge case where window.innerWidth is exactly breakpoint boundary", () => {
const testCases = [
{ width: 767, expected: true },
{ width: 768, expected: false },
{ width: 769, expected: false },
];
testCases.forEach(({ width, expected }) => {
Object.defineProperty(window, "innerWidth", {
writable: true,
value: width,
});
const { result, unmount } = renderHook(() => useIsMobile());
expect(result.current).toBe(expected);
unmount();
});
});
test("should work with zero width", () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 0,
});
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);
});
test("should work with very large width", () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 9999,
});
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});
});

View File

@@ -0,0 +1,19 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

View File

@@ -33,6 +33,11 @@ if (!global.ResizeObserver) {
global.ResizeObserver = ResizeObserver;
}
// Mock useIsMobile hook that depends on window.matchMedia
vi.mock("@/modules/ui/hooks/use-mobile", () => ({
useIsMobile: vi.fn().mockReturnValue(false),
}));
// mock react toast
vi.mock("react-hot-toast", () => ({