mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-12 09:39:39 -06:00
fix: scroll to bottom on error (#6301)
This commit is contained in:
committed by
GitHub
parent
6ef281647a
commit
91ace0e821
@@ -1,93 +1,134 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/preact";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyCalQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { CalEmbed } from "../general/cal-embed";
|
||||
import { CalQuestion } from "./cal-question";
|
||||
|
||||
// Mock the CalEmbed component
|
||||
vi.mock("../general/cal-embed", () => ({
|
||||
CalEmbed: vi.fn(({ question }) => (
|
||||
<div data-testid="cal-embed-mock">
|
||||
Cal Embed for {question.calUserName}
|
||||
{question.calHost && <span>Host: {question.calHost}</span>}
|
||||
</div>
|
||||
)),
|
||||
// Mock ScrollableContainer with ref support for new functionality
|
||||
vi.mock("@/components/wrappers/scrollable-container", () => {
|
||||
const { forwardRef } = require("preact/compat");
|
||||
|
||||
const MockScrollableContainer = forwardRef(({ children }: { children: React.ReactNode }, ref: any) => {
|
||||
if (ref) {
|
||||
if (typeof ref === "function") {
|
||||
ref({ scrollToBottom: vi.fn() });
|
||||
} else if (typeof ref === "object" && ref !== null) {
|
||||
ref.current = { scrollToBottom: vi.fn() };
|
||||
}
|
||||
}
|
||||
return <div data-testid="scrollable-container">{children}</div>;
|
||||
});
|
||||
|
||||
return {
|
||||
ScrollableContainer: MockScrollableContainer,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock other components - minimal mocks
|
||||
vi.mock("@/components/general/cal-embed", () => ({
|
||||
CalEmbed: () => <div data-testid="cal-embed">CalEmbed</div>,
|
||||
}));
|
||||
|
||||
describe("CalQuestion", () => {
|
||||
vi.mock("@/components/general/headline", () => ({
|
||||
Headline: () => <h1>Headline</h1>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/general/subheader", () => ({
|
||||
Subheader: () => <p>Subheader</p>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/buttons/submit-button", () => ({
|
||||
SubmitButton: ({ buttonLabel }: { buttonLabel: string }) => (
|
||||
<button type="submit" data-testid="submit-button">
|
||||
{buttonLabel}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/buttons/back-button", () => ({
|
||||
BackButton: () => <button data-testid="back-button">Back</button>,
|
||||
}));
|
||||
|
||||
// Mock lib functions
|
||||
vi.mock("@/lib/i18n", () => ({
|
||||
getLocalizedValue: (value: any) => value?.default || value || "Default",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ttc", () => ({
|
||||
useTtc: vi.fn(),
|
||||
getUpdatedTtc: vi.fn(() => ({ [Date.now()]: 123 })),
|
||||
}));
|
||||
|
||||
describe("CalQuestion - New Error Handling", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("performance", {
|
||||
now: vi.fn(() => 1000),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const mockQuestion: TSurveyCalQuestion = {
|
||||
id: "cal-question-1",
|
||||
type: TSurveyQuestionTypeEnum.Cal,
|
||||
headline: { default: "Schedule a meeting" },
|
||||
subheader: { default: "Choose a time that works for you" },
|
||||
required: true,
|
||||
calUserName: "johndoe",
|
||||
calHost: "cal.com",
|
||||
};
|
||||
|
||||
const mockQuestionWithoutHost: TSurveyCalQuestion = {
|
||||
id: "cal-question-2",
|
||||
type: TSurveyQuestionTypeEnum.Cal,
|
||||
headline: { default: "Schedule a meeting" },
|
||||
required: false,
|
||||
calUserName: "janedoe",
|
||||
buttonLabel: { default: "Next" },
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
question: mockQuestion,
|
||||
value: null,
|
||||
value: "",
|
||||
onChange: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
isInvalid: false,
|
||||
direction: "vertical" as const,
|
||||
onBack: vi.fn(),
|
||||
isFirstQuestion: false,
|
||||
isLastQuestion: false,
|
||||
languageCode: "en",
|
||||
} as any;
|
||||
ttc: {},
|
||||
setTtc: vi.fn(),
|
||||
autoFocusEnabled: true,
|
||||
currentQuestionId: "cal-question-1",
|
||||
isBackButtonHidden: false,
|
||||
};
|
||||
|
||||
test("renders with headline and subheader", () => {
|
||||
test("shows error message when required field is not filled", async () => {
|
||||
render(<CalQuestion {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Schedule a meeting")).toBeInTheDocument();
|
||||
expect(screen.getByText("Choose a time that works for you")).toBeInTheDocument();
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Please book an appointment")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("renders without subheader", () => {
|
||||
render(<CalQuestion {...defaultProps} question={mockQuestionWithoutHost} />);
|
||||
|
||||
expect(screen.getByText("Schedule a meeting")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Choose a time that works for you")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders CalEmbed component with correct props", () => {
|
||||
test("error message is positioned after CalEmbed", async () => {
|
||||
render(<CalQuestion {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("cal-embed-mock")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cal Embed for johndoe")).toBeInTheDocument();
|
||||
expect(screen.getByText("Host: cal.com")).toBeInTheDocument();
|
||||
expect(CalEmbed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
question: mockQuestion,
|
||||
onSuccessfulBooking: expect.any(Function),
|
||||
}),
|
||||
{}
|
||||
);
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const calEmbed = screen.getByTestId("cal-embed");
|
||||
const errorMessage = screen.getByText("Please book an appointment");
|
||||
|
||||
// Check that error message comes after CalEmbed in DOM order
|
||||
expect(calEmbed.compareDocumentPosition(errorMessage)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
});
|
||||
});
|
||||
|
||||
test("renders CalEmbed without host when not provided", () => {
|
||||
render(<CalQuestion {...defaultProps} question={mockQuestionWithoutHost} />);
|
||||
test("does not show error when appointment is booked", () => {
|
||||
render(<CalQuestion {...defaultProps} value="booked" />);
|
||||
|
||||
expect(screen.getByTestId("cal-embed-mock")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cal Embed for janedoe")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Host:/)).not.toBeInTheDocument();
|
||||
});
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
test("does not add required indicator when question is optional", () => {
|
||||
render(<CalQuestion {...defaultProps} question={mockQuestionWithoutHost} />);
|
||||
|
||||
expect(screen.queryByText("*")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Please book an appointment")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,13 @@ import { CalEmbed } from "@/components/general/cal-embed";
|
||||
import { Headline } from "@/components/general/headline";
|
||||
import { QuestionMedia } from "@/components/general/question-media";
|
||||
import { Subheader } from "@/components/general/subheader";
|
||||
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
||||
import {
|
||||
ScrollableContainer,
|
||||
type ScrollableContainerHandle,
|
||||
} from "@/components/wrappers/scrollable-container";
|
||||
import { getLocalizedValue } from "@/lib/i18n";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
import { useCallback, useState } from "preact/hooks";
|
||||
import { useCallback, useRef, useState } from "preact/hooks";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import { type TSurveyCalQuestion, type TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
|
||||
@@ -44,6 +47,7 @@ export function CalQuestion({
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const scrollableRef = useRef<ScrollableContainerHandle>(null);
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
|
||||
const isCurrent = question.id === currentQuestionId;
|
||||
const onSuccessfulBooking = useCallback(() => {
|
||||
@@ -60,6 +64,12 @@ export function CalQuestion({
|
||||
e.preventDefault();
|
||||
if (question.required && !value) {
|
||||
setErrorMessage("Please book an appointment");
|
||||
// Scroll to bottom to show the error message
|
||||
setTimeout(() => {
|
||||
if (scrollableRef.current?.scrollToBottom) {
|
||||
scrollableRef.current.scrollToBottom();
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -70,7 +80,7 @@ export function CalQuestion({
|
||||
onSubmit({ [question.id]: value }, updatedttc);
|
||||
}}
|
||||
className="fb-w-full">
|
||||
<ScrollableContainer>
|
||||
<ScrollableContainer ref={scrollableRef}>
|
||||
<div>
|
||||
{isMediaAvailable ? (
|
||||
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
|
||||
@@ -84,18 +94,17 @@ export function CalQuestion({
|
||||
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
|
||||
questionId={question.id}
|
||||
/>
|
||||
{errorMessage ? <span className="fb-text-red-500">{errorMessage}</span> : null}
|
||||
<CalEmbed key={question.id} question={question} onSuccessfulBooking={onSuccessfulBooking} />
|
||||
{errorMessage ? <span className="fb-text-red-500">{errorMessage}</span> : null}
|
||||
</div>
|
||||
</ScrollableContainer>
|
||||
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-px-6 fb-py-4">
|
||||
{!question.required && (
|
||||
<SubmitButton
|
||||
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
|
||||
isLastQuestion={isLastQuestion}
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
/>
|
||||
)}
|
||||
<SubmitButton
|
||||
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
|
||||
isLastQuestion={isLastQuestion}
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
/>
|
||||
|
||||
<div />
|
||||
{!isFirstQuestion && !isBackButtonHidden && (
|
||||
<BackButton
|
||||
|
||||
@@ -1,258 +1,131 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/preact";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type { TResponseTtc } from "@formbricks/types/responses";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyQuestionTypeEnum, type TSurveyRankingQuestion } from "@formbricks/types/surveys/types";
|
||||
import { RankingQuestion } from "./ranking-question";
|
||||
|
||||
// Mock components used in the RankingQuestion component
|
||||
vi.mock("@/components/buttons/back-button", () => ({
|
||||
BackButton: ({ onClick, backButtonLabel }: { onClick: () => void; backButtonLabel: string }) => (
|
||||
<button data-testid="back-button" onClick={onClick}>
|
||||
{backButtonLabel}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
// Mock ScrollableContainer with ref support for new functionality
|
||||
vi.mock("@/components/wrappers/scrollable-container", () => {
|
||||
const { forwardRef } = require("preact/compat");
|
||||
|
||||
const MockScrollableContainer = forwardRef(({ children }: { children: React.ReactNode }, ref: any) => {
|
||||
if (ref) {
|
||||
if (typeof ref === "function") {
|
||||
ref({ scrollToBottom: vi.fn() });
|
||||
} else if (typeof ref === "object" && ref !== null) {
|
||||
ref.current = { scrollToBottom: vi.fn() };
|
||||
}
|
||||
}
|
||||
return <div data-testid="scrollable-container">{children}</div>;
|
||||
});
|
||||
|
||||
return {
|
||||
ScrollableContainer: MockScrollableContainer,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock other components - minimal mocks
|
||||
vi.mock("@/components/buttons/submit-button", () => ({
|
||||
SubmitButton: ({ buttonLabel }: { buttonLabel: string }) => (
|
||||
<button data-testid="submit-button" type="submit">
|
||||
{buttonLabel}
|
||||
SubmitButton: () => (
|
||||
<button type="submit" data-testid="submit-button">
|
||||
Submit
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/general/headline", () => ({
|
||||
Headline: ({ headline }: { headline: string }) => <h1 data-testid="headline">{headline}</h1>,
|
||||
Headline: () => <h1>Headline</h1>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/general/subheader", () => ({
|
||||
Subheader: ({ subheader }: { subheader: string }) => <p data-testid="subheader">{subheader}</p>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/general/question-media", () => ({
|
||||
QuestionMedia: () => <div data-testid="question-media"></div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/wrappers/scrollable-container", () => ({
|
||||
ScrollableContainer: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="scrollable-container">{children}</div>
|
||||
),
|
||||
Subheader: () => <p>Subheader</p>,
|
||||
}));
|
||||
|
||||
vi.mock("@formkit/auto-animate/react", () => ({
|
||||
useAutoAnimate: () => [null],
|
||||
}));
|
||||
|
||||
// Mock lib functions
|
||||
vi.mock("@/lib/i18n", () => ({
|
||||
getLocalizedValue: (value: any, _: string) => (typeof value === "string" ? value : value.default),
|
||||
getLocalizedValue: (value: any) => value?.default || value || "Default",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ttc", () => ({
|
||||
useTtc: vi.fn(),
|
||||
getUpdatedTtc: () => ({}),
|
||||
getUpdatedTtc: vi.fn(() => ({ [Date.now()]: 123 })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils", () => ({
|
||||
cn: (...args: any[]) => args.filter(Boolean).join(" "),
|
||||
getShuffledChoicesIds: (choices: any[], _?: string) => choices.map((c) => c.id),
|
||||
getShuffledChoicesIds: vi.fn((_, length) => Array.from({ length }, (_, i) => i.toString())),
|
||||
}));
|
||||
|
||||
describe("RankingQuestion", () => {
|
||||
describe("RankingQuestion - New Error Handling", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("performance", {
|
||||
now: vi.fn(() => 1000),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const mockQuestion: TSurveyRankingQuestion = {
|
||||
id: "q1",
|
||||
id: "ranking-question-1",
|
||||
type: TSurveyQuestionTypeEnum.Ranking,
|
||||
headline: { default: "Rank these items" },
|
||||
subheader: { default: "Please rank all items" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "c1", label: { default: "Item 1" } },
|
||||
{ id: "c2", label: { default: "Item 2" } },
|
||||
{ id: "c3", label: { default: "Item 3" } },
|
||||
],
|
||||
buttonLabel: { default: "Next" },
|
||||
backButtonLabel: { default: "Back" },
|
||||
choices: [
|
||||
{ id: "choice1", label: { default: "Choice 1" } },
|
||||
{ id: "choice2", label: { default: "Choice 2" } },
|
||||
{ id: "choice3", label: { default: "Choice 3" } },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
};
|
||||
|
||||
const mockOptionalQuestion: TSurveyRankingQuestion = {
|
||||
...mockQuestion,
|
||||
required: false,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
question: mockQuestion,
|
||||
value: [] as string[],
|
||||
value: [],
|
||||
onChange: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
onBack: vi.fn(),
|
||||
isFirstQuestion: false,
|
||||
isLastQuestion: false,
|
||||
languageCode: "en",
|
||||
ttc: {} as TResponseTtc,
|
||||
ttc: {},
|
||||
setTtc: vi.fn(),
|
||||
autoFocusEnabled: false,
|
||||
currentQuestionId: "q1",
|
||||
autoFocusEnabled: true,
|
||||
currentQuestionId: "ranking-question-1",
|
||||
isBackButtonHidden: false,
|
||||
};
|
||||
|
||||
test("renders correctly with all elements", () => {
|
||||
test("shows error message when required question has incomplete ranking", async () => {
|
||||
render(<RankingQuestion {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("headline")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("subheader")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("submit-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("back-button")).toBeInTheDocument();
|
||||
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Item 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Item 3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders media when available", () => {
|
||||
const questionWithMedia = {
|
||||
...mockQuestion,
|
||||
imageUrl: "https://example.com/image.jpg",
|
||||
};
|
||||
|
||||
render(<RankingQuestion {...defaultProps} question={questionWithMedia} />);
|
||||
expect(screen.getByTestId("question-media")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("doesn't show back button when isFirstQuestion is true", () => {
|
||||
render(<RankingQuestion {...defaultProps} isFirstQuestion={true} />);
|
||||
expect(screen.queryByTestId("back-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("doesn't show back button when isBackButtonHidden is true", () => {
|
||||
render(<RankingQuestion {...defaultProps} isBackButtonHidden={true} />);
|
||||
expect(screen.queryByTestId("back-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clicking on item adds it to the sorted list", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RankingQuestion {...defaultProps} />);
|
||||
|
||||
const item1 = screen.getByText("Item 1").closest("div");
|
||||
await user.click(item1!);
|
||||
|
||||
const itemElements = screen
|
||||
.getAllByRole("button")
|
||||
.filter((btn) => btn.innerHTML.includes("chevron-up") || btn.innerHTML.includes("chevron-down"));
|
||||
expect(itemElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("clicking on a sorted item removes it from the sorted list", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RankingQuestion {...defaultProps} value={["c1"]} />);
|
||||
|
||||
// First verify the item is in the sorted list
|
||||
const sortedItems = screen
|
||||
.getAllByRole("button")
|
||||
.filter((btn) => btn.innerHTML.includes("chevron-up") || btn.innerHTML.includes("chevron-down"));
|
||||
expect(sortedItems.length).toBeGreaterThan(0);
|
||||
|
||||
// Click the item to unselect it
|
||||
const item1 = screen.getByText("Item 1").closest("div");
|
||||
await user.click(item1!);
|
||||
|
||||
// The move buttons should be gone
|
||||
const moveButtons = screen
|
||||
.queryAllByRole("button")
|
||||
.filter((btn) => btn.innerHTML.includes("chevron-up") || btn.innerHTML.includes("chevron-down"));
|
||||
expect(moveButtons.length).toBe(0);
|
||||
});
|
||||
|
||||
test("moving an item up in the list", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RankingQuestion {...defaultProps} value={["c1", "c2"]} />);
|
||||
|
||||
const upButtons = screen.getAllByRole("button").filter((btn) => btn.innerHTML.includes("chevron-up"));
|
||||
|
||||
// The first item's up button should be disabled
|
||||
expect(upButtons[0]).toBeDisabled();
|
||||
|
||||
// The second item's up button should work
|
||||
expect(upButtons[1]).not.toBeDisabled();
|
||||
await user.click(upButtons[1]);
|
||||
});
|
||||
|
||||
test("moving an item down in the list", async () => {
|
||||
// For this test, we'll just verify the component renders correctly with ranked items
|
||||
render(<RankingQuestion {...defaultProps} value={["c1", "c2"]} />);
|
||||
|
||||
// Verify both items are rendered
|
||||
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Item 2")).toBeInTheDocument();
|
||||
|
||||
// Verify there are some move buttons present
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const moveButtons = buttons.filter(
|
||||
(btn) =>
|
||||
btn.innerHTML && (btn.innerHTML.includes("chevron-up") || btn.innerHTML.includes("chevron-down"))
|
||||
);
|
||||
|
||||
// Just make sure we have some move buttons rendered
|
||||
expect(moveButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("submits form with complete ranking", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RankingQuestion {...defaultProps} value={["c1", "c2", "c3"]} />);
|
||||
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
await user.click(submitButton);
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Please rank all items before submitting.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("allows submission with empty ranking for optional question", () => {
|
||||
render(<RankingQuestion {...defaultProps} question={mockOptionalQuestion} />);
|
||||
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(defaultProps.onSubmit).toHaveBeenCalled();
|
||||
expect(screen.queryByText("Please rank all items before submitting.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clicking back button calls onBack", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RankingQuestion {...defaultProps} />);
|
||||
|
||||
const backButton = screen.getByTestId("back-button");
|
||||
await user.click(backButton);
|
||||
|
||||
expect(defaultProps.onChange).toHaveBeenCalled();
|
||||
expect(defaultProps.onBack).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("allows incomplete ranking if not required", async () => {
|
||||
const user = userEvent.setup();
|
||||
const nonRequiredQuestion = {
|
||||
...mockQuestion,
|
||||
required: false,
|
||||
};
|
||||
|
||||
render(<RankingQuestion {...defaultProps} question={nonRequiredQuestion} value={[]} />);
|
||||
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(defaultProps.onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles keyboard navigation", () => {
|
||||
render(<RankingQuestion {...defaultProps} />);
|
||||
|
||||
const item = screen.getByText("Item 1").closest("div");
|
||||
fireEvent.keyDown(item!, { key: " " }); // Space key
|
||||
|
||||
const moveButtons = screen
|
||||
.getAllByRole("button")
|
||||
.filter((btn) => btn.innerHTML.includes("chevron-up") || btn.innerHTML.includes("chevron-down"));
|
||||
expect(moveButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("applies shuffle option correctly", () => {
|
||||
const shuffledQuestion = {
|
||||
...mockQuestion,
|
||||
shuffleOption: "all",
|
||||
} as TSurveyRankingQuestion;
|
||||
|
||||
render(<RankingQuestion {...defaultProps} question={shuffledQuestion} />);
|
||||
|
||||
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Item 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Item 3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,15 @@ import { SubmitButton } from "@/components/buttons/submit-button";
|
||||
import { Headline } from "@/components/general/headline";
|
||||
import { QuestionMedia } from "@/components/general/question-media";
|
||||
import { Subheader } from "@/components/general/subheader";
|
||||
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
||||
import {
|
||||
ScrollableContainer,
|
||||
type ScrollableContainerHandle,
|
||||
} from "@/components/wrappers/scrollable-container";
|
||||
import { getLocalizedValue } from "@/lib/i18n";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
import { cn, getShuffledChoicesIds } from "@/lib/utils";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useCallback, useMemo, useState } from "preact/hooks";
|
||||
import { useCallback, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type {
|
||||
TSurveyQuestionChoice,
|
||||
@@ -58,6 +61,7 @@ export function RankingQuestion({
|
||||
}, [question.shuffleOption, question.choices.length]);
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
const scrollableRef = useRef<ScrollableContainerHandle>(null);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
@@ -119,6 +123,13 @@ export function RankingQuestion({
|
||||
|
||||
if (hasIncompleteRanking) {
|
||||
setError("Please rank all items before submitting.");
|
||||
// Scroll to bottom to show the error message
|
||||
setTimeout(() => {
|
||||
if (scrollableRef.current?.scrollToBottom) {
|
||||
scrollableRef.current.scrollToBottom();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -144,7 +155,7 @@ export function RankingQuestion({
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="fb-w-full">
|
||||
<ScrollableContainer>
|
||||
<ScrollableContainer ref={scrollableRef}>
|
||||
<div>
|
||||
{isMediaAvailable ? (
|
||||
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
|
||||
|
||||
@@ -1,216 +1,94 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact";
|
||||
import { cleanup, render } from "@testing-library/preact";
|
||||
import { createRef } from "preact";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { ScrollableContainer } from "./scrollable-container";
|
||||
import { ScrollableContainer, type ScrollableContainerHandle } from "./scrollable-container";
|
||||
|
||||
// Mock cn utility
|
||||
vi.mock("@/lib/utils", () => ({
|
||||
cn: (...args: any[]) => args.filter(Boolean).join(" "),
|
||||
}));
|
||||
|
||||
describe("ScrollableContainer", () => {
|
||||
describe("ScrollableContainer - New Ref Functionality", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks(); // Restore all spies
|
||||
});
|
||||
|
||||
// Helper to set scroll properties on an element
|
||||
const setScrollProps = (
|
||||
element: HTMLElement,
|
||||
scrollHeight: number,
|
||||
clientHeight: number,
|
||||
scrollTop: number
|
||||
) => {
|
||||
Object.defineProperty(element, "scrollHeight", { configurable: true, value: scrollHeight });
|
||||
Object.defineProperty(element, "clientHeight", { configurable: true, value: clientHeight });
|
||||
Object.defineProperty(element, "scrollTop", { configurable: true, value: scrollTop });
|
||||
};
|
||||
test("ref exposes scrollToBottom method", () => {
|
||||
const ref = createRef<ScrollableContainerHandle>();
|
||||
|
||||
test("renders children correctly", () => {
|
||||
render(
|
||||
<ScrollableContainer>
|
||||
<div>Test Content</div>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
expect(screen.getByText("Test Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("initial state with short content (not scrollable)", async () => {
|
||||
const { container } = render(
|
||||
<ScrollableContainer>
|
||||
<div style={{ height: "50px" }}>Short Content</div>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
const scrollableDiv = container.querySelector<HTMLElement>(".fb-overflow-auto");
|
||||
expect(scrollableDiv).toBeInTheDocument();
|
||||
|
||||
if (scrollableDiv) {
|
||||
setScrollProps(scrollableDiv, 50, 100, 0); // Content shorter than container
|
||||
fireEvent.scroll(scrollableDiv); // Trigger checkScroll
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
// isAtTop = true, isAtBottom = true
|
||||
expect(container.querySelector(".fb-bg-gradient-to-b")).toBeNull(); // No top gradient
|
||||
expect(container.querySelector(".fb-bg-gradient-to-t")).toBeNull(); // No bottom gradient
|
||||
});
|
||||
});
|
||||
|
||||
test("initial state with long content (scrollable at top)", async () => {
|
||||
const { container } = render(
|
||||
<ScrollableContainer>
|
||||
<div style={{ height: "200px" }}>Long Content</div>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
const scrollableDiv = container.querySelector<HTMLElement>(".fb-overflow-auto");
|
||||
expect(scrollableDiv).toBeInTheDocument();
|
||||
|
||||
if (scrollableDiv) {
|
||||
setScrollProps(scrollableDiv, 200, 100, 0); // Content longer than container, at top
|
||||
fireEvent.scroll(scrollableDiv); // Trigger checkScroll
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
// isAtTop = true, isAtBottom = false
|
||||
expect(container.querySelector(".fb-bg-gradient-to-b")).toBeNull(); // No top gradient
|
||||
expect(container.querySelector(".fb-bg-gradient-to-t")).not.toBeNull(); // Bottom gradient visible
|
||||
});
|
||||
});
|
||||
|
||||
test("scrolling behavior updates gradients", async () => {
|
||||
const { container } = render(
|
||||
<ScrollableContainer>
|
||||
<div style={{ height: "300px" }}>Scrollable Content</div>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
const scrollableDiv = container.querySelector<HTMLElement>(".fb-overflow-auto");
|
||||
expect(scrollableDiv).toBeInTheDocument();
|
||||
|
||||
if (!scrollableDiv) throw new Error("Scrollable div not found");
|
||||
|
||||
// Initial: At top
|
||||
setScrollProps(scrollableDiv, 300, 100, 0);
|
||||
fireEvent.scroll(scrollableDiv); // Trigger checkScroll for initial state
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".fb-bg-gradient-to-b")).toBeNull();
|
||||
expect(container.querySelector(".fb-bg-gradient-to-t")).not.toBeNull();
|
||||
});
|
||||
|
||||
// Scroll to middle
|
||||
setScrollProps(scrollableDiv, 300, 100, 50);
|
||||
fireEvent.scroll(scrollableDiv);
|
||||
await waitFor(() => {
|
||||
// isAtTop = false, isAtBottom = false
|
||||
expect(container.querySelector(".fb-bg-gradient-to-b")).not.toBeNull(); // Top gradient visible
|
||||
expect(container.querySelector(".fb-bg-gradient-to-t")).not.toBeNull(); // Bottom gradient visible
|
||||
});
|
||||
|
||||
// Scroll to bottom
|
||||
setScrollProps(scrollableDiv, 300, 100, 200); // scrollTop + clientHeight = scrollHeight
|
||||
fireEvent.scroll(scrollableDiv);
|
||||
await waitFor(() => {
|
||||
// isAtTop = false, isAtBottom = true
|
||||
expect(container.querySelector(".fb-bg-gradient-to-b")).not.toBeNull(); // Top gradient visible
|
||||
expect(container.querySelector(".fb-bg-gradient-to-t")).toBeNull(); // No bottom gradient
|
||||
});
|
||||
|
||||
// Scroll back to top
|
||||
setScrollProps(scrollableDiv, 300, 100, 0);
|
||||
fireEvent.scroll(scrollableDiv);
|
||||
await waitFor(() => {
|
||||
// isAtTop = true, isAtBottom = false
|
||||
expect(container.querySelector(".fb-bg-gradient-to-b")).toBeNull(); // No top gradient
|
||||
expect(container.querySelector(".fb-bg-gradient-to-t")).not.toBeNull(); // Bottom gradient visible
|
||||
});
|
||||
});
|
||||
|
||||
test("uses 60dvh maxHeight by default when not in survey preview", () => {
|
||||
vi.spyOn(document, "getElementById").mockReturnValue(null);
|
||||
const { container } = render(
|
||||
<ScrollableContainer>
|
||||
<ScrollableContainer ref={ref}>
|
||||
<div>Content</div>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
const scrollableDiv = container.querySelector<HTMLElement>(".fb-overflow-auto");
|
||||
expect(scrollableDiv).toHaveStyle({ maxHeight: "60dvh" });
|
||||
|
||||
expect(ref.current).toEqual({
|
||||
scrollToBottom: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
test("uses 42dvh maxHeight when isSurveyPreview is true", () => {
|
||||
vi.spyOn(document, "getElementById").mockReturnValue(document.createElement("div")); // Simulate survey-preview element exists
|
||||
test("scrollToBottom functionality works correctly", () => {
|
||||
const ref = createRef<ScrollableContainerHandle>();
|
||||
const { container } = render(
|
||||
<ScrollableContainer>
|
||||
<div>Content</div>
|
||||
<ScrollableContainer ref={ref}>
|
||||
<div style={{ height: "200px" }}>Tall Content</div>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
const scrollableDiv = container.querySelector<HTMLElement>(".fb-overflow-auto");
|
||||
expect(scrollableDiv).toHaveStyle({ maxHeight: "42dvh" });
|
||||
});
|
||||
|
||||
test("cleans up scroll event listener on unmount", () => {
|
||||
const { unmount, container } = render(
|
||||
<ScrollableContainer>
|
||||
<div>Test Content</div>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
const scrollableDiv = container.querySelector<HTMLElement>(".fb-overflow-auto");
|
||||
expect(scrollableDiv).toBeInTheDocument();
|
||||
|
||||
if (scrollableDiv) {
|
||||
const removeEventListenerSpy = vi.spyOn(scrollableDiv, "removeEventListener");
|
||||
unmount();
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith("scroll", expect.any(Function));
|
||||
} else {
|
||||
throw new Error("Scrollable div not found for unmount test");
|
||||
// Mock scroll properties
|
||||
Object.defineProperty(scrollableDiv, "scrollHeight", {
|
||||
value: 200,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
let currentScrollTop = 0;
|
||||
Object.defineProperty(scrollableDiv, "scrollTop", {
|
||||
get: () => currentScrollTop,
|
||||
set: (value) => {
|
||||
currentScrollTop = value;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Call scrollToBottom
|
||||
ref.current?.scrollToBottom();
|
||||
|
||||
// Check that scrollTop was set to scrollHeight
|
||||
expect(currentScrollTop).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
test("updates scroll state when children prop changes causing scrollHeight change", async () => {
|
||||
const { rerender, container } = render(
|
||||
<ScrollableContainer>
|
||||
<div style={{ height: "50px" }}>Short Content</div>
|
||||
test("scrollToBottom handles null containerRef gracefully", () => {
|
||||
const ref = createRef<ScrollableContainerHandle>();
|
||||
|
||||
render(
|
||||
<ScrollableContainer ref={ref}>
|
||||
<div>Content</div>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
const scrollableDiv = container.querySelector<HTMLElement>(".fb-overflow-auto");
|
||||
expect(scrollableDiv).toBeInTheDocument();
|
||||
|
||||
if (!scrollableDiv) throw new Error("Scrollable div not found");
|
||||
|
||||
// Initial: Short content
|
||||
setScrollProps(scrollableDiv, 50, 100, 0);
|
||||
fireEvent.scroll(scrollableDiv); // Trigger checkScroll
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".fb-bg-gradient-to-b")).toBeNull();
|
||||
expect(container.querySelector(".fb-bg-gradient-to-t")).toBeNull();
|
||||
});
|
||||
|
||||
// Rerender with long content
|
||||
rerender(
|
||||
<ScrollableContainer>
|
||||
<div style={{ height: "200px" }}>Long Content</div>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
// Simulate new scrollHeight due to new children, and assume it's at the top
|
||||
setScrollProps(scrollableDiv, 200, 100, 0);
|
||||
fireEvent.scroll(scrollableDiv); // Trigger checkScroll with new props after rerender
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".fb-bg-gradient-to-b")).toBeNull(); // Still at top
|
||||
expect(container.querySelector(".fb-bg-gradient-to-t")).not.toBeNull(); // Bottom gradient visible
|
||||
});
|
||||
// Should not throw when called
|
||||
expect(() => ref.current?.scrollToBottom()).not.toThrow();
|
||||
});
|
||||
|
||||
test("handles containerRef.current being null initially in checkScroll", () => {
|
||||
// This test ensures that the null check for containerRef.current prevents errors.
|
||||
// It's hard to directly test the early return without altering the component's timing.
|
||||
// We rely on the fact that if it were to error, other tests might fail or it would show in coverage.
|
||||
// Forcing containerRef.current to be null when checkScroll is called is tricky.
|
||||
// However, the component's structure with useEffect should mean checkScroll is called after render.
|
||||
// We can ensure no error is thrown during render.
|
||||
expect(() => {
|
||||
render(
|
||||
<ScrollableContainer>
|
||||
<div>Content</div>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
}).not.toThrow();
|
||||
test("handles function ref correctly", () => {
|
||||
let refValue: ScrollableContainerHandle | null = null;
|
||||
const functionRef = (ref: ScrollableContainerHandle | null) => {
|
||||
refValue = ref;
|
||||
};
|
||||
|
||||
render(
|
||||
<ScrollableContainer ref={functionRef}>
|
||||
<div>Content</div>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
|
||||
expect(refValue).toEqual({
|
||||
scrollToBottom: expect.any(Function),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,61 +1,79 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { JSX } from "react";
|
||||
import type { JSX, Ref } from "preact";
|
||||
import { forwardRef } from "preact/compat";
|
||||
import { useEffect, useImperativeHandle, useRef, useState } from "preact/hooks";
|
||||
|
||||
interface ScrollableContainerProps {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export function ScrollableContainer({ children }: Readonly<ScrollableContainerProps>) {
|
||||
const [isAtBottom, setIsAtBottom] = useState(false);
|
||||
const [isAtTop, setIsAtTop] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isSurveyPreview = Boolean(document.getElementById("survey-preview"));
|
||||
|
||||
const checkScroll = () => {
|
||||
if (!containerRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
|
||||
setIsAtBottom(Math.round(scrollTop) + clientHeight >= scrollHeight);
|
||||
|
||||
setIsAtTop(scrollTop === 0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
checkScroll();
|
||||
};
|
||||
element.addEventListener("scroll", handleScroll);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkScroll();
|
||||
}, [children]);
|
||||
|
||||
return (
|
||||
<div className="fb-relative">
|
||||
{!isAtTop && (
|
||||
<div className="fb-from-survey-bg fb-absolute fb-left-0 fb-right-2 fb-top-0 fb-z-10 fb-h-6 fb-bg-gradient-to-b fb-to-transparent" />
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
scrollbarGutter: "stable both-edges",
|
||||
maxHeight: isSurveyPreview ? "42dvh" : "60dvh",
|
||||
}}
|
||||
className={cn("fb-overflow-auto fb-px-4 fb-pb-4 fb-bg-survey-bg")}>
|
||||
{children}
|
||||
</div>
|
||||
{!isAtBottom && (
|
||||
<div className="fb-from-survey-bg fb-absolute -fb-bottom-2 fb-left-0 fb-right-2 fb-h-8 fb-bg-gradient-to-t fb-to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
export interface ScrollableContainerHandle {
|
||||
scrollToBottom: () => void;
|
||||
}
|
||||
|
||||
export const ScrollableContainer = forwardRef<ScrollableContainerHandle, ScrollableContainerProps>(
|
||||
({ children }: ScrollableContainerProps, ref: Ref<ScrollableContainerHandle>) => {
|
||||
const [isAtBottom, setIsAtBottom] = useState(false);
|
||||
const [isAtTop, setIsAtTop] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isSurveyPreview = Boolean(document.getElementById("survey-preview"));
|
||||
|
||||
const checkScroll = () => {
|
||||
if (!containerRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
|
||||
setIsAtBottom(Math.round(scrollTop) + clientHeight >= scrollHeight);
|
||||
|
||||
setIsAtTop(scrollTop === 0);
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
// Expose only the `scrollToBottom` method to parent components via the forwarded ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToBottom,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
checkScroll();
|
||||
};
|
||||
element.addEventListener("scroll", handleScroll);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkScroll();
|
||||
}, [children]);
|
||||
|
||||
return (
|
||||
<div className="fb-relative">
|
||||
{!isAtTop && (
|
||||
<div className="fb-from-survey-bg fb-absolute fb-left-0 fb-right-2 fb-top-0 fb-z-10 fb-h-6 fb-bg-gradient-to-b fb-to-transparent" />
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
scrollbarGutter: "stable both-edges",
|
||||
maxHeight: isSurveyPreview ? "42dvh" : "60dvh",
|
||||
}}
|
||||
className={cn("fb-overflow-auto fb-px-4 fb-pb-4 fb-bg-survey-bg")}>
|
||||
{children}
|
||||
</div>
|
||||
{!isAtBottom && (
|
||||
<div className="fb-from-survey-bg fb-absolute -fb-bottom-2 fb-left-0 fb-right-2 fb-h-8 fb-bg-gradient-to-t fb-to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user