mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-08 12:24:45 +00:00
feat(ui): offer Lighthouse AI help in the add-provider wizard (#13178)
This commit is contained in:
1 parent
d7bffba4ac
commit
4eb468619f
50 files changed
+2214
-170
No files matched your search
@@ -22,6 +22,7 @@ import { isLighthouseChatRoute } from "@/lib/lighthouse-routes";
|
||||
import {
|
||||
clampSidePanelWidth,
|
||||
getSidePanelMaxWidth,
|
||||
SIDE_PANEL_ELEMENT_ID,
|
||||
SIDE_PANEL_MIN_WIDTH,
|
||||
SIDE_PANEL_PUSH_MEDIA_QUERY,
|
||||
} from "@/lib/ui-layout";
|
||||
@@ -115,6 +116,7 @@ export function GlobalSidePanel() {
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
id={SIDE_PANEL_ELEMENT_ID}
|
||||
open={isOpen}
|
||||
aria-label="Side panel"
|
||||
data-testid="global-side-panel"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { DetailSidePanel } from "./detail-side-panel";
|
||||
export { GlobalSidePanel } from "./global-side-panel";
|
||||
export { SidePanelHostModal } from "./side-panel-host-modal";
|
||||
export { getVisibleSidePanelTabs, SIDE_PANEL_TABS } from "./side-panel-tabs";
|
||||
export { SidePanelTrigger } from "./side-panel-trigger";
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState } from "react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { page, userEvent } from "vitest/browser";
|
||||
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import { SidePanel } from "@/components/shadcn/side-panel/side-panel";
|
||||
import { SIDE_PANEL_ELEMENT_ID } from "@/lib/ui-layout";
|
||||
import {
|
||||
selectHasModalHost,
|
||||
SIDE_PANEL_TAB,
|
||||
useSidePanelStore,
|
||||
} from "@/store/side-panel";
|
||||
|
||||
import { SidePanelHostModal } from "./side-panel-host-modal";
|
||||
|
||||
const PANEL_WIDTH = 480;
|
||||
|
||||
function PanelStandIn() {
|
||||
const isOpen = useSidePanelStore((state) => state.isOpen);
|
||||
return (
|
||||
<SidePanel
|
||||
id={SIDE_PANEL_ELEMENT_ID}
|
||||
open={isOpen}
|
||||
aria-label="Side panel"
|
||||
style={{ width: PANEL_WIDTH }}
|
||||
>
|
||||
<textarea aria-label="Lighthouse composer" />
|
||||
</SidePanel>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardStandIn() {
|
||||
return (
|
||||
<>
|
||||
<PanelStandIn />
|
||||
<SidePanelHostModal
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title="Adding A Provider"
|
||||
>
|
||||
<input aria-label="AWS account ID" />
|
||||
</SidePanelHostModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// The page behind goes inert while the wizard is open, like MainLayout does.
|
||||
function ShellWithOpener() {
|
||||
const isShellInert = useSidePanelStore(selectHasModalHost);
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<main inert={isShellInert}>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
Add provider
|
||||
</button>
|
||||
</main>
|
||||
<PanelStandIn />
|
||||
<SidePanelHostModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Adding A Provider"
|
||||
>
|
||||
<input aria-label="AWS account ID" />
|
||||
</SidePanelHostModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("SidePanelHostModal beside the side panel", () => {
|
||||
beforeEach(() => {
|
||||
useSidePanelStore.setState({
|
||||
isOpen: false,
|
||||
selectedTab: SIDE_PANEL_TAB.AI_CHAT,
|
||||
width: PANEL_WIDTH,
|
||||
modalHostCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("lets the user chat in the panel without losing the wizard or its input", async () => {
|
||||
// Given: the user is halfway through the wizard
|
||||
render(<WizardStandIn />);
|
||||
await userEvent.fill(page.getByLabelText("AWS account ID"), "123456789012");
|
||||
|
||||
// When: Lighthouse opens beside it and the user writes in the composer
|
||||
useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT);
|
||||
await userEvent.click(page.getByLabelText("Lighthouse composer"));
|
||||
await userEvent.keyboard("How do I create the IAM role?");
|
||||
|
||||
// Then: the wizard is still there, untouched, and clear of the panel
|
||||
await expect.element(page.getByRole("dialog")).toBeVisible();
|
||||
await expect
|
||||
.element(page.getByLabelText("AWS account ID"))
|
||||
.toHaveValue("123456789012");
|
||||
await expect
|
||||
.element(page.getByLabelText("Lighthouse composer"))
|
||||
.toHaveValue("How do I create the IAM role?");
|
||||
await expect
|
||||
.poll(
|
||||
() => page.getByRole("dialog").element().getBoundingClientRect().right,
|
||||
)
|
||||
.toBeLessThanOrEqual(window.innerWidth - PANEL_WIDTH);
|
||||
});
|
||||
|
||||
it("closes only the panel on Escape inside it and hands focus back to the wizard", async () => {
|
||||
// Given
|
||||
render(<WizardStandIn />);
|
||||
useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT);
|
||||
await userEvent.click(page.getByLabelText("Lighthouse composer"));
|
||||
|
||||
// When
|
||||
await userEvent.keyboard("{Escape}");
|
||||
|
||||
// Then
|
||||
expect(useSidePanelStore.getState().isOpen).toBe(false);
|
||||
await expect.element(page.getByRole("dialog")).toBeVisible();
|
||||
await expect.element(page.getByRole("dialog")).toHaveFocus();
|
||||
});
|
||||
|
||||
it("gives focus back to the button that opened the wizard once the page is usable again", async () => {
|
||||
// Given: the user opened the wizard and is typing in it
|
||||
render(<ShellWithOpener />);
|
||||
await userEvent.click(page.getByRole("button", { name: "Add provider" }));
|
||||
await userEvent.click(page.getByLabelText("AWS account ID"));
|
||||
await userEvent.keyboard("1234");
|
||||
|
||||
// When
|
||||
await userEvent.keyboard("{Escape}");
|
||||
|
||||
// Then
|
||||
await expect.element(page.getByRole("dialog")).not.toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole("button", { name: "Add provider" }))
|
||||
.toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SIDE_PANEL_ELEMENT_ID } from "@/lib/ui-layout";
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
|
||||
import { SidePanelHostModal } from "./side-panel-host-modal";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/providers",
|
||||
}));
|
||||
|
||||
function renderWithPanel(onOpenChange = vi.fn(), open = true) {
|
||||
return render(
|
||||
<>
|
||||
<aside id={SIDE_PANEL_ELEMENT_ID}>
|
||||
<textarea aria-label="Lighthouse composer" />
|
||||
</aside>
|
||||
<SidePanelHostModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Add provider"
|
||||
>
|
||||
<button type="button">Next</button>
|
||||
</SidePanelHostModal>
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SidePanelHostModal", () => {
|
||||
beforeEach(() => {
|
||||
useSidePanelStore.setState({
|
||||
isOpen: true,
|
||||
selectedTab: SIDE_PANEL_TAB.AI_CHAT,
|
||||
modalHostCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the dialog open while the user chats in the side panel", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
renderWithPanel(onOpenChange);
|
||||
await screen.findByRole("dialog");
|
||||
|
||||
// When
|
||||
await user.click(screen.getByLabelText("Lighthouse composer"));
|
||||
await user.keyboard("How do I create the IAM role?");
|
||||
|
||||
// Then
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText("Lighthouse composer")).toHaveValue(
|
||||
"How do I create the IAM role?",
|
||||
);
|
||||
});
|
||||
|
||||
it("closes only the side panel when Escape is pressed inside it", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
renderWithPanel(onOpenChange);
|
||||
await user.click(await screen.findByLabelText("Lighthouse composer"));
|
||||
|
||||
// When
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
// Then: the panel closes, the dialog stays and takes the focus back
|
||||
expect(useSidePanelStore.getState().isOpen).toBe(false);
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("dialog")).toHaveFocus();
|
||||
});
|
||||
|
||||
it("still closes on Escape pressed inside the dialog", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
renderWithPanel(onOpenChange);
|
||||
await user.click(await screen.findByRole("button", { name: "Next" }));
|
||||
|
||||
// When
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
// Then
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(useSidePanelStore.getState().isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it("holds the app shell inert only while it is open", async () => {
|
||||
// Given
|
||||
const { rerender } = renderWithPanel();
|
||||
await screen.findByRole("dialog");
|
||||
expect(useSidePanelStore.getState().modalHostCount).toBe(1);
|
||||
|
||||
// When
|
||||
rerender(
|
||||
<SidePanelHostModal open={false} onOpenChange={vi.fn()} title="Add">
|
||||
<button type="button">Next</button>
|
||||
</SidePanelHostModal>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(useSidePanelStore.getState().modalHostCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { type ComponentProps, useRef } from "react";
|
||||
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import { useSidePanelPushWidth } from "@/hooks/use-side-panel-push-width";
|
||||
import { SIDE_PANEL_ELEMENT_ID } from "@/lib/ui-layout";
|
||||
import { useSidePanelStore } from "@/store/side-panel";
|
||||
|
||||
type SidePanelHostModalProps = Omit<
|
||||
ComponentProps<typeof Modal>,
|
||||
"modal" | "insetRight" | "isInsetResizing" | "ref"
|
||||
>;
|
||||
|
||||
function isInsideSidePanel(target: EventTarget | null): boolean {
|
||||
return (
|
||||
target instanceof Element &&
|
||||
target.closest(`#${SIDE_PANEL_ELEMENT_ID}`) !== null
|
||||
);
|
||||
}
|
||||
|
||||
// A dialog that shares the screen with the global side panel: it centers in
|
||||
// the space the panel leaves and nothing done in the panel can dismiss it.
|
||||
export function SidePanelHostModal({
|
||||
onEscapeKeyDown,
|
||||
children,
|
||||
...props
|
||||
}: SidePanelHostModalProps) {
|
||||
const insetRight = useSidePanelPushWidth();
|
||||
const isResizing = useSidePanelStore((state) => state.isResizing);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleEscapeKeyDown = (event: KeyboardEvent) => {
|
||||
onEscapeKeyDown?.(event);
|
||||
if (event.defaultPrevented || !isInsideSidePanel(event.target)) return;
|
||||
// Radix handles Escape first (document capture), so the panel's own
|
||||
// listener sees it prevented: close the panel here instead of the dialog.
|
||||
event.preventDefault();
|
||||
useSidePanelStore.getState().closePanel();
|
||||
contentRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
{...props}
|
||||
ref={contentRef}
|
||||
modal={false}
|
||||
insetRight={insetRight}
|
||||
isInsetResizing={isResizing}
|
||||
onEscapeKeyDown={handleEscapeKeyDown}
|
||||
>
|
||||
<ModalHostRegistration />
|
||||
{children}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Mounted with the dialog content, so it is registered exactly while open.
|
||||
function ModalHostRegistration() {
|
||||
useMountEffect(() => useSidePanelStore.getState().registerModalHost());
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user