mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-06 05:40:02 -06:00
feat: revamp sharing modal shell (#6190)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "檢視網站",
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
219
apps/web/modules/ui/components/separator/index.test.tsx
Normal file
219
apps/web/modules/ui/components/separator/index.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
25
apps/web/modules/ui/components/separator/index.tsx
Normal file
25
apps/web/modules/ui/components/separator/index.tsx
Normal 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 };
|
||||
514
apps/web/modules/ui/components/sheet/index.test.tsx
Normal file
514
apps/web/modules/ui/components/sheet/index.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
119
apps/web/modules/ui/components/sheet/index.tsx
Normal file
119
apps/web/modules/ui/components/sheet/index.tsx
Normal 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,
|
||||
};
|
||||
586
apps/web/modules/ui/components/sidebar/index.test.tsx
Normal file
586
apps/web/modules/ui/components/sidebar/index.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
691
apps/web/modules/ui/components/sidebar/index.tsx
Normal file
691
apps/web/modules/ui/components/sidebar/index.tsx
Normal 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,
|
||||
};
|
||||
258
apps/web/modules/ui/hooks/use-mobile.test.tsx
Normal file
258
apps/web/modules/ui/hooks/use-mobile.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
19
apps/web/modules/ui/hooks/use-mobile.tsx
Normal file
19
apps/web/modules/ui/hooks/use-mobile.tsx
Normal 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;
|
||||
}
|
||||
@@ -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", () => ({
|
||||
|
||||
Reference in New Issue
Block a user