mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-08 22:38:05 -05:00
refactor(frontend): remove HeroUI BaseModal and migrate MetricsModal (#12174)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
This commit is contained in:
@@ -1,151 +0,0 @@
|
|||||||
import { render, screen, act } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { describe, it, vi, expect } from "vitest";
|
|
||||||
import { BaseModal } from "#/components/shared/modals/base-modal/base-modal";
|
|
||||||
|
|
||||||
describe("BaseModal", () => {
|
|
||||||
const onOpenChangeMock = vi.fn();
|
|
||||||
|
|
||||||
it("should render if the modal is open", () => {
|
|
||||||
const { rerender } = render(
|
|
||||||
<BaseModal
|
|
||||||
isOpen={false}
|
|
||||||
onOpenChange={onOpenChangeMock}
|
|
||||||
title="Settings"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
rerender(
|
|
||||||
<BaseModal title="Settings" onOpenChange={onOpenChangeMock} isOpen />,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render an optional subtitle", () => {
|
|
||||||
render(
|
|
||||||
<BaseModal
|
|
||||||
isOpen
|
|
||||||
onOpenChange={onOpenChangeMock}
|
|
||||||
title="Settings"
|
|
||||||
subtitle="Subtitle"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("Subtitle")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render actions", async () => {
|
|
||||||
const onPrimaryClickMock = vi.fn();
|
|
||||||
const onSecondaryClickMock = vi.fn();
|
|
||||||
|
|
||||||
const primaryAction = {
|
|
||||||
action: onPrimaryClickMock,
|
|
||||||
label: "Save",
|
|
||||||
};
|
|
||||||
|
|
||||||
const secondaryAction = {
|
|
||||||
action: onSecondaryClickMock,
|
|
||||||
label: "Cancel",
|
|
||||||
};
|
|
||||||
|
|
||||||
render(
|
|
||||||
<BaseModal
|
|
||||||
isOpen
|
|
||||||
onOpenChange={onOpenChangeMock}
|
|
||||||
title="Settings"
|
|
||||||
actions={[primaryAction, secondaryAction]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("Save")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByText("Save"));
|
|
||||||
expect(onPrimaryClickMock).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByText("Cancel"));
|
|
||||||
expect(onSecondaryClickMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should close the modal after an action is performed", async () => {
|
|
||||||
render(
|
|
||||||
<BaseModal
|
|
||||||
isOpen
|
|
||||||
onOpenChange={onOpenChangeMock}
|
|
||||||
title="Settings"
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
label: "Save",
|
|
||||||
action: () => {},
|
|
||||||
closeAfterAction: true,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByText("Save"));
|
|
||||||
expect(onOpenChangeMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render children", () => {
|
|
||||||
render(
|
|
||||||
<BaseModal isOpen onOpenChange={onOpenChangeMock} title="Settings">
|
|
||||||
<div>Children</div>
|
|
||||||
</BaseModal>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("Children")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should disable the action given the condition", () => {
|
|
||||||
const { rerender } = render(
|
|
||||||
<BaseModal
|
|
||||||
isOpen
|
|
||||||
onOpenChange={onOpenChangeMock}
|
|
||||||
title="Settings"
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
label: "Save",
|
|
||||||
action: () => {},
|
|
||||||
isDisabled: true,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("Save")).toBeDisabled();
|
|
||||||
|
|
||||||
rerender(
|
|
||||||
<BaseModal
|
|
||||||
isOpen
|
|
||||||
onOpenChange={onOpenChangeMock}
|
|
||||||
title="Settings"
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
label: "Save",
|
|
||||||
action: () => {},
|
|
||||||
isDisabled: false,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("Save")).not.toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.skip("should not close if the backdrop or escape key is pressed", () => {
|
|
||||||
render(
|
|
||||||
<BaseModal
|
|
||||||
isOpen
|
|
||||||
onOpenChange={onOpenChangeMock}
|
|
||||||
title="Settings"
|
|
||||||
isDismissable={false}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
userEvent.keyboard("{esc}");
|
|
||||||
});
|
|
||||||
// fails because the nextui component wraps the modal content in an aria-hidden div
|
|
||||||
expect(screen.getByRole("dialog")).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { BaseModal } from "../../../shared/modals/base-modal/base-modal";
|
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
|
||||||
|
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||||
|
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||||
import { I18nKey } from "#/i18n/declaration";
|
import { I18nKey } from "#/i18n/declaration";
|
||||||
import { CostSection } from "./cost-section";
|
import { CostSection } from "./cost-section";
|
||||||
import { UsageSection } from "./usage-section";
|
import { UsageSection } from "./usage-section";
|
||||||
@@ -15,15 +17,13 @@ interface MetricsModalProps {
|
|||||||
export function MetricsModal({ isOpen, onOpenChange }: MetricsModalProps) {
|
export function MetricsModal({ isOpen, onOpenChange }: MetricsModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const metrics = useMetricsStore();
|
const metrics = useMetricsStore();
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseModal
|
<ModalBackdrop onClose={() => onOpenChange(false)}>
|
||||||
isOpen={isOpen}
|
<ModalBody className="items-center border border-tertiary">
|
||||||
onOpenChange={onOpenChange}
|
<BaseModalTitle title={t(I18nKey.CONVERSATION$METRICS_INFO)} />
|
||||||
title={t(I18nKey.CONVERSATION$METRICS_INFO)}
|
<div className="space-y-4 w-full">
|
||||||
testID="metrics-modal"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{(metrics?.cost !== null || metrics?.usage !== null) && (
|
{(metrics?.cost !== null || metrics?.usage !== null) && (
|
||||||
<div className="rounded-md p-3">
|
<div className="rounded-md p-3">
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
@@ -47,6 +47,7 @@ export function MetricsModal({ isOpen, onOpenChange }: MetricsModalProps) {
|
|||||||
|
|
||||||
{!metrics?.cost && !metrics?.usage && <EmptyState />}
|
{!metrics?.cost && !metrics?.usage && <EmptyState />}
|
||||||
</div>
|
</div>
|
||||||
</BaseModal>
|
</ModalBody>
|
||||||
|
</ModalBackdrop>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import {
|
|
||||||
Modal,
|
|
||||||
ModalBody,
|
|
||||||
ModalContent,
|
|
||||||
ModalFooter,
|
|
||||||
ModalHeader,
|
|
||||||
} from "@heroui/react";
|
|
||||||
import React from "react";
|
|
||||||
import { Action, FooterContent } from "./footer-content";
|
|
||||||
import { HeaderContent } from "./header-content";
|
|
||||||
|
|
||||||
interface BaseModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (isOpen: boolean) => void;
|
|
||||||
title: string;
|
|
||||||
contentClassName?: string;
|
|
||||||
bodyClassName?: string;
|
|
||||||
isDismissable?: boolean;
|
|
||||||
subtitle?: string;
|
|
||||||
actions?: Action[];
|
|
||||||
children?: React.ReactNode;
|
|
||||||
testID?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BaseModal({
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
title,
|
|
||||||
contentClassName = "max-w-[30rem] p-[40px]",
|
|
||||||
bodyClassName = "px-0 py-[20px]",
|
|
||||||
isDismissable = true,
|
|
||||||
subtitle = undefined,
|
|
||||||
actions = [],
|
|
||||||
children = null,
|
|
||||||
testID,
|
|
||||||
}: BaseModalProps) {
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
data-testid={testID}
|
|
||||||
isOpen={isOpen}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
isDismissable={isDismissable}
|
|
||||||
backdrop="blur"
|
|
||||||
hideCloseButton
|
|
||||||
size="sm"
|
|
||||||
className="bg-base-secondary rounded-lg"
|
|
||||||
>
|
|
||||||
<ModalContent className={contentClassName}>
|
|
||||||
{(closeModal) => (
|
|
||||||
<>
|
|
||||||
{title && (
|
|
||||||
<ModalHeader className="flex flex-col p-0">
|
|
||||||
<HeaderContent maintitle={title} subtitle={subtitle} />
|
|
||||||
</ModalHeader>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ModalBody className={bodyClassName}>{children}</ModalBody>
|
|
||||||
|
|
||||||
{actions && actions.length > 0 && (
|
|
||||||
<ModalFooter className="flex-row flex justify-start p-0">
|
|
||||||
<FooterContent actions={actions} closeModal={closeModal} />
|
|
||||||
</ModalFooter>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { Button } from "@heroui/react";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export interface Action {
|
|
||||||
action: () => void;
|
|
||||||
isDisabled?: boolean;
|
|
||||||
label: string;
|
|
||||||
className?: string;
|
|
||||||
closeAfterAction?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FooterContentProps {
|
|
||||||
actions: Action[];
|
|
||||||
closeModal: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FooterContent({ actions, closeModal }: FooterContentProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{actions.map(
|
|
||||||
({ action, isDisabled, label, className, closeAfterAction }) => (
|
|
||||||
<Button
|
|
||||||
key={label}
|
|
||||||
type="button"
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
onPress={() => {
|
|
||||||
action();
|
|
||||||
if (closeAfterAction) closeModal();
|
|
||||||
}}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
interface HeaderContentProps {
|
|
||||||
maintitle: string;
|
|
||||||
subtitle?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HeaderContent({
|
|
||||||
maintitle,
|
|
||||||
subtitle = undefined,
|
|
||||||
}: HeaderContentProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h3>{maintitle}</h3>
|
|
||||||
{subtitle && (
|
|
||||||
<span className="text-neutral-400 text-sm font-light">{subtitle}</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user