mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
fix(ui): prevent feedback widget overlaps (#12282)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
co-authored by
alejandrobailo
parent
b684ad06f3
commit
ff0ee666e3
@@ -0,0 +1 @@
|
||||
Feedback widget no longer obscures page and side-panel actions
|
||||
@@ -45,6 +45,29 @@ describe("Button", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("supports circular icon buttons without changing the default shape", () => {
|
||||
// Given / When
|
||||
const { rerender } = render(
|
||||
<Button shape="circle" size="icon-lg" aria-label="Give feedback">
|
||||
<svg aria-hidden="true" />
|
||||
</Button>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("button", { name: "Give feedback" })).toHaveClass(
|
||||
"rounded-full",
|
||||
"size-10",
|
||||
);
|
||||
|
||||
// When
|
||||
rerender(<Button>Default shape</Button>);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("button", { name: "Default shape" })).toHaveClass(
|
||||
"rounded-[8px]",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the bare variant chrome-free (no background or border)", () => {
|
||||
render(
|
||||
<Button variant="bare" size="icon-sm" aria-label="Toggle sidebar">
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ComponentProps } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[8px] text-sm font-medium transition-all disabled:pointer-events-none disabled:bg-button-disabled disabled:text-text-neutral-tertiary outline-none focus-visible:ring-2 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:bg-button-disabled disabled:text-text-neutral-tertiary outline-none focus-visible:ring-2 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -38,10 +38,15 @@ const buttonVariants = cva(
|
||||
"link-xs": "text-xs",
|
||||
"link-sm": "text-sm",
|
||||
},
|
||||
shape: {
|
||||
default: "rounded-[8px]",
|
||||
circle: "rounded-full",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
shape: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -50,6 +55,7 @@ function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
shape,
|
||||
asChild = false,
|
||||
...props
|
||||
}: ComponentProps<"button"> &
|
||||
@@ -61,7 +67,7 @@ function Button({
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
className={cn(buttonVariants({ variant, size, shape, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Survey } from "posthog-js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { clampSidePanelWidth } from "@/lib/ui-layout";
|
||||
|
||||
type SurveyCallback = (surveys: Survey[]) => void;
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -12,6 +17,8 @@ const mocks = vi.hoisted(() => ({
|
||||
onSurveysLoaded: vi.fn(),
|
||||
capture: vi.fn(),
|
||||
moduleLoaded: vi.fn(),
|
||||
pathname: "/",
|
||||
isPushViewport: false,
|
||||
// Mirrors the singleton's `__loaded` flag: true when a PostHog instance
|
||||
// already exists (as on Prowler Cloud, initialized in app/providers.tsx).
|
||||
loaded: false,
|
||||
@@ -21,6 +28,12 @@ vi.mock("@/lib/shared/env", () => ({ isCloud: mocks.isCloud }));
|
||||
vi.mock("@/hooks/use-runtime-config", () => ({
|
||||
useRuntimeConfig: mocks.useRuntimeConfig,
|
||||
}));
|
||||
vi.mock("@/hooks/use-media-query", () => ({
|
||||
useMediaQuery: () => mocks.isPushViewport,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => mocks.pathname,
|
||||
}));
|
||||
vi.mock("posthog-js", () => {
|
||||
mocks.moduleLoaded();
|
||||
return {
|
||||
@@ -36,6 +49,10 @@ vi.mock("posthog-js", () => {
|
||||
});
|
||||
|
||||
const POSTHOG_KEY = "phc_key";
|
||||
const GLOBAL_STYLES = readFileSync(
|
||||
resolve(process.cwd(), "styles/globals.css"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const SURVEY_FIXTURE = {
|
||||
id: "survey-123",
|
||||
@@ -77,6 +94,9 @@ describe("FeedbackSurvey", () => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
mocks.loaded = false;
|
||||
mocks.pathname = "/";
|
||||
mocks.isPushViewport = false;
|
||||
localStorage.clear();
|
||||
mocks.isCloud.mockReturnValue(true);
|
||||
mocks.useRuntimeConfig.mockReturnValue({
|
||||
cloudEnabled: true,
|
||||
@@ -90,9 +110,10 @@ describe("FeedbackSurvey", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders the feedback trigger when Cloud and the survey is available", async () => {
|
||||
it("renders an accessible icon-only circular feedback trigger", async () => {
|
||||
// When
|
||||
await renderSurvey();
|
||||
|
||||
@@ -101,7 +122,134 @@ describe("FeedbackSurvey", () => {
|
||||
name: "Give feedback",
|
||||
});
|
||||
expect(trigger).toBeVisible();
|
||||
expect(trigger).toHaveTextContent("Feedback");
|
||||
expect(trigger).toHaveAccessibleName("Give feedback");
|
||||
expect(trigger).not.toHaveTextContent("Feedback");
|
||||
expect(trigger.querySelector("svg")).toBeInTheDocument();
|
||||
expect(trigger).toHaveClass("rounded-full", "size-10");
|
||||
});
|
||||
|
||||
it("activates feedback clearance only on the semantic main page scroller", async () => {
|
||||
// When
|
||||
await renderSurvey();
|
||||
|
||||
// Then
|
||||
const trigger = await screen.findByRole("button", {
|
||||
name: "Give feedback",
|
||||
});
|
||||
expect(trigger).toHaveAttribute("data-feedback-survey-trigger");
|
||||
expect(GLOBAL_STYLES).toMatch(
|
||||
/body:has\(\[data-feedback-survey-trigger\]\)\s+main\[data-responsive-container\]\s*{\s*@apply pb-36;\s*}/,
|
||||
);
|
||||
expect(GLOBAL_STYLES).not.toMatch(
|
||||
/body:has\(\[data-feedback-survey-trigger\]\)\s+\[data-responsive-container\]\s*{/,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the normal right gutter above page content when the side panel is closed", async () => {
|
||||
// Given
|
||||
mocks.isPushViewport = true;
|
||||
|
||||
// When
|
||||
await renderSurvey();
|
||||
|
||||
// Then
|
||||
const trigger = await screen.findByRole("button", {
|
||||
name: "Give feedback",
|
||||
});
|
||||
expect(trigger).toHaveClass("right-6", "z-50");
|
||||
expect(trigger.style.right).toBe("");
|
||||
});
|
||||
|
||||
it("moves left by the clamped panel width on push viewports", async () => {
|
||||
// Given - persisted widths are consumed defensively, just like MainLayout.
|
||||
const persistedWidth = 2000;
|
||||
vi.stubGlobal("innerWidth", 1200);
|
||||
mocks.isPushViewport = true;
|
||||
const { useSidePanelStore } = await import("@/store/side-panel");
|
||||
useSidePanelStore.setState({
|
||||
isOpen: true,
|
||||
width: persistedWidth,
|
||||
});
|
||||
|
||||
// When
|
||||
await renderSurvey();
|
||||
|
||||
// Then
|
||||
const trigger = await screen.findByRole("button", {
|
||||
name: "Give feedback",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(trigger).toHaveStyle({
|
||||
right: `${clampSidePanelWidth(persistedWidth) + 24}px`,
|
||||
}),
|
||||
);
|
||||
expect(trigger).toHaveClass("transition-[right,transform]", "duration-200");
|
||||
});
|
||||
|
||||
it("keeps the normal gutter on Lighthouse when stale state says the absent panel is open", async () => {
|
||||
// Given
|
||||
mocks.pathname = "/lighthouse";
|
||||
mocks.isPushViewport = true;
|
||||
const { useSidePanelStore } = await import("@/store/side-panel");
|
||||
useSidePanelStore.setState({ isOpen: true, width: 720 });
|
||||
|
||||
// When
|
||||
await renderSurvey();
|
||||
|
||||
// Then
|
||||
const trigger = await screen.findByRole("button", {
|
||||
name: "Give feedback",
|
||||
});
|
||||
expect(trigger).toBeVisible();
|
||||
expect(trigger.style.right).toBe("");
|
||||
});
|
||||
|
||||
it("hides the feedback trigger when the side panel overlays on mobile", async () => {
|
||||
// Given
|
||||
mocks.isPushViewport = false;
|
||||
const { useSidePanelStore } = await import("@/store/side-panel");
|
||||
useSidePanelStore.setState({ isOpen: true, width: 720 });
|
||||
|
||||
// When
|
||||
await renderSurvey();
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled());
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Give feedback" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the feedback form closed after the mobile side panel closes", async () => {
|
||||
// Given
|
||||
mocks.isPushViewport = false;
|
||||
const user = userEvent.setup();
|
||||
const { useSidePanelStore } = await import("@/store/side-panel");
|
||||
useSidePanelStore.setState({ isOpen: false });
|
||||
await renderSurvey();
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Give feedback" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "What could we do better?" }),
|
||||
).toBeVisible();
|
||||
|
||||
// When
|
||||
act(() => useSidePanelStore.setState({ isOpen: true }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Give feedback" }),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
act(() => useSidePanelStore.setState({ isOpen: false }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Give feedback" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "What could we do better?" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores a same-name non-API survey and selects the API survey", async () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquareText } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import posthogClient from "posthog-js";
|
||||
import type { Survey } from "posthog-js";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import {
|
||||
@@ -12,9 +13,19 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/shadcn/popover";
|
||||
import { Textarea } from "@/components/shadcn/textarea/textarea";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import { useStore } from "@/hooks/use-store";
|
||||
import { isLighthouseChatRoute } from "@/lib/lighthouse-routes";
|
||||
import {
|
||||
clampSidePanelWidth,
|
||||
SIDE_PANEL_PUSH_MEDIA_QUERY,
|
||||
} from "@/lib/ui-layout";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSidePanelStore } from "@/store/side-panel";
|
||||
|
||||
const SURVEY_NAME = "Prowler Feedback";
|
||||
const FEEDBACK_GUTTER_PX = 24;
|
||||
|
||||
const SURVEY_EVENT = {
|
||||
SHOWN: "survey shown",
|
||||
@@ -31,10 +42,20 @@ export default function RuntimeFeedbackSurvey({
|
||||
posthogKey,
|
||||
posthogHost,
|
||||
}: RuntimeFeedbackSurveyProps) {
|
||||
const pathname = usePathname();
|
||||
const [survey, setSurvey] = useState<Survey | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [response, setResponse] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const sidePanelOpen = useStore(useSidePanelStore, (state) => state.isOpen);
|
||||
const sidePanelWidth = useStore(useSidePanelStore, (state) => state.width);
|
||||
const sidePanelResizing = useStore(
|
||||
useSidePanelStore,
|
||||
(state) => state.isResizing,
|
||||
);
|
||||
const isPushViewport = useMediaQuery(SIDE_PANEL_PUSH_MEDIA_QUERY);
|
||||
const sidePanelVisible =
|
||||
Boolean(sidePanelOpen) && !isLighthouseChatRoute(pathname);
|
||||
|
||||
useMountEffect(() => {
|
||||
if (!posthogClient.__loaded) {
|
||||
@@ -56,13 +77,22 @@ export default function RuntimeFeedbackSurvey({
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (sidePanelVisible && !isPushViewport) setOpen(false);
|
||||
}, [sidePanelVisible, isPushViewport]);
|
||||
|
||||
const question = survey?.questions?.[0];
|
||||
if (!survey || question?.type !== "open") return null;
|
||||
if (sidePanelVisible && !isPushViewport) return null;
|
||||
|
||||
const questionId = question.id ?? "";
|
||||
const appearance = survey.appearance;
|
||||
const trimmedResponse = response.trim();
|
||||
const identity = { $survey_id: survey.id, $survey_name: survey.name };
|
||||
const pushedRight =
|
||||
sidePanelVisible && sidePanelWidth !== undefined && isPushViewport
|
||||
? clampSidePanelWidth(sidePanelWidth) + FEEDBACK_GUTTER_PX
|
||||
: undefined;
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
@@ -98,14 +128,19 @@ export default function RuntimeFeedbackSurvey({
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="Give feedback"
|
||||
size="xl"
|
||||
className="group fixed right-6 bottom-20 z-50 transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98] motion-reduce:transform-none motion-reduce:transition-none"
|
||||
data-feedback-survey-trigger
|
||||
shape="circle"
|
||||
size="icon-lg"
|
||||
className={cn(
|
||||
"group fixed right-6 bottom-20 z-50 transition-[right,transform] duration-200 hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98] motion-reduce:transform-none motion-reduce:transition-none",
|
||||
sidePanelVisible && sidePanelResizing && "transition-none",
|
||||
)}
|
||||
style={{ right: pushedRight }}
|
||||
>
|
||||
<MessageSquareText
|
||||
aria-hidden="true"
|
||||
className="transition-transform duration-200 group-hover:scale-110 group-hover:-rotate-6 motion-reduce:transform-none motion-reduce:transition-none"
|
||||
/>
|
||||
<span>Feedback</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
||||
@@ -656,6 +656,10 @@
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
|
||||
body:has([data-feedback-survey-trigger]) main[data-responsive-container] {
|
||||
@apply pb-36;
|
||||
}
|
||||
|
||||
/* Minimal scrollbar styles
|
||||
*
|
||||
* The descendant selectors target `.cm-scroller` so that CodeMirror
|
||||
|
||||
Reference in New Issue
Block a user