Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 81 additions & 67 deletions src/components/EditPriceModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,27 @@ import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { EditPriceModal } from "./EditPriceModal.js";
import { useEditPrice } from "../hooks/useEditPrice.js";
import { I18nProvider } from "../i18n/I18nProvider.js";
import { en } from "../i18n/locales/en.js";

vi.mock("../hooks/useEditPrice.js", () => ({
useEditPrice: vi.fn(),
}));

function renderModal(props: {
resourceId: string;
currentPrice: string;
apiKey: string;
onClose: () => void;
onConfirmed: (price: string) => void;
}) {
return render(
<I18nProvider locale="en">
<EditPriceModal {...props} />
</I18nProvider>,
);
}

describe("EditPriceModal", () => {
const onClose = vi.fn();
const onConfirmed = vi.fn();
Expand All @@ -25,38 +41,38 @@ describe("EditPriceModal", () => {
});

it("renders with labeled price input and current value", () => {
render(
<EditPriceModal
resourceId="res-1"
currentPrice="2.50"
apiKey="key-1"
onClose={onClose}
onConfirmed={onConfirmed}
/>,
);

expect(screen.getByRole("heading", { name: /edit price/i })).toBeInTheDocument();
const input = screen.getByLabelText(/new price/i);
renderModal({
resourceId: "res-1",
currentPrice: "2.50",
apiKey: "key-1",
onClose,
onConfirmed,
});

expect(
screen.getByRole("heading", { name: en.editPrice.title }),
).toBeInTheDocument();
const input = screen.getByLabelText(en.editPrice.newPriceLabel);
expect(input).toHaveValue(2.5);
expect(screen.getByRole("button", { name: /update price/i })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: en.editPrice.submit }),
).toBeInTheDocument();
});

it("submits new price on happy path", async () => {
const user = userEvent.setup();
render(
<EditPriceModal
resourceId="res-1"
currentPrice="2.50"
apiKey="key-1"
onClose={onClose}
onConfirmed={onConfirmed}
/>,
);

const input = screen.getByLabelText(/new price/i);
renderModal({
resourceId: "res-1",
currentPrice: "2.50",
apiKey: "key-1",
onClose,
onConfirmed,
});

const input = screen.getByLabelText(en.editPrice.newPriceLabel);
await user.clear(input);
await user.type(input, "3.00");
await user.click(screen.getByRole("button", { name: /update price/i }));
await user.click(screen.getByRole("button", { name: en.editPrice.submit }));

expect(mockEditPrice).toHaveBeenCalledWith("3");
});
Expand All @@ -70,18 +86,20 @@ describe("EditPriceModal", () => {
editPrice: mockEditPrice,
});

render(
<EditPriceModal
resourceId="res-1"
currentPrice="2.50"
apiKey="key-1"
onClose={onClose}
onConfirmed={onConfirmed}
/>,
);

expect(screen.getByText(/waiting for wallet signature/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /working/i })).toBeDisabled();
renderModal({
resourceId: "res-1",
currentPrice: "2.50",
apiKey: "key-1",
onClose,
onConfirmed,
});

expect(
screen.getByText(en.editPrice.status.signing),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: en.editPrice.working }),
).toBeDisabled();
});

it("shows error state", () => {
Expand All @@ -93,15 +111,13 @@ describe("EditPriceModal", () => {
editPrice: mockEditPrice,
});

render(
<EditPriceModal
resourceId="res-1"
currentPrice="2.50"
apiKey="key-1"
onClose={onClose}
onConfirmed={onConfirmed}
/>,
);
renderModal({
resourceId: "res-1",
currentPrice: "2.50",
apiKey: "key-1",
onClose,
onConfirmed,
});

expect(screen.getByText("Wallet rejected signing")).toBeInTheDocument();
});
Expand All @@ -115,35 +131,33 @@ describe("EditPriceModal", () => {
editPrice: mockEditPrice,
});

render(
<EditPriceModal
resourceId="res-1"
currentPrice="2.50"
apiKey="key-1"
onClose={onClose}
onConfirmed={onConfirmed}
/>,
);
renderModal({
resourceId: "res-1",
currentPrice: "2.50",
apiKey: "key-1",
onClose,
onConfirmed,
});

await waitFor(() => {
expect(onConfirmed).toHaveBeenCalledWith("4.00");
});
expect(screen.getByText(/price updated to 4\.00 usdc/i)).toBeInTheDocument();
expect(
screen.getByText(en.editPrice.status.confirmed.replace("{price}", "4.00")),
).toBeInTheDocument();
});

it("closes when cancel is clicked", async () => {
const user = userEvent.setup();
render(
<EditPriceModal
resourceId="res-1"
currentPrice="2.50"
apiKey="key-1"
onClose={onClose}
onConfirmed={onConfirmed}
/>,
);

await user.click(screen.getByRole("button", { name: /cancel/i }));
renderModal({
resourceId: "res-1",
currentPrice: "2.50",
apiKey: "key-1",
onClose,
onConfirmed,
});

await user.click(screen.getByRole("button", { name: en.editPrice.cancel }));
expect(onClose).toHaveBeenCalled();
});
});
40 changes: 22 additions & 18 deletions src/components/EditPriceModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react";
import { useEditPrice } from "../hooks/useEditPrice.js";
import { useDialogA11y } from "../hooks/useDialogA11y.js";
import { useTranslation } from "../i18n/index.js";

interface Props {
resourceId: string;
Expand All @@ -15,26 +16,29 @@ const PRICE_PATTERN = /^\d+(\.\d{1,7})?$/;
const MAX_STROOPS = 9223372036854775807n;
const STROOPS_PER_UNIT = BigInt(10000000);

function validatePrice(value: string): string | null {
type Translate = (key: string, vars?: Record<string, string | number>) => string;

function validatePrice(value: string, t: Translate): string | null {
const v = value.trim();
if (!PRICE_PATTERN.test(v)) return "Enter a price with up to 7 decimal places.";
if (!PRICE_PATTERN.test(v)) return t("editPrice.errors.invalidFormat");
const [whole, frac = ""] = v.split(".");
const stroops = BigInt(whole) * STROOPS_PER_UNIT + BigInt((frac + "0000000").slice(0, 7));
if (stroops <= BigInt(0)) return "Price must be greater than 0.";
if (stroops > MAX_STROOPS) return "Price is too large.";
if (stroops <= BigInt(0)) return t("editPrice.errors.notPositive");
if (stroops > MAX_STROOPS) return t("editPrice.errors.tooLarge");
return null;
}

const STATUS_LABELS: Record<string, string> = {
const STATUS_KEYS: Record<string, string> = {
idle: "",
preparing: "Building transaction…",
signing: "Waiting for wallet signature…",
submitting: "Submitting to Stellar…",
confirmed: "Price updated!",
preparing: "editPrice.status.preparing",
signing: "editPrice.status.signing",
submitting: "editPrice.status.submitting",
confirmed: "editPrice.status.confirmed",
error: "",
};

export function EditPriceModal({ resourceId, currentPrice, apiKey, onClose, onConfirmed }: Props) {
const { t } = useTranslation();
const [price, setPrice] = useState(currentPrice);
const [priceError, setPriceError] = useState<string | null>(null);
const { status, newPrice, error, networkWarning, editPrice } = useEditPrice(resourceId, apiKey);
Expand All @@ -50,7 +54,7 @@ export function EditPriceModal({ resourceId, currentPrice, apiKey, onClose, onCo

function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const problem = validatePrice(price);
const problem = validatePrice(price, t);
setPriceError(problem);
if (problem) return;
editPrice(price.trim());
Expand All @@ -67,13 +71,13 @@ export function EditPriceModal({ resourceId, currentPrice, apiKey, onClose, onCo
className="w-full max-w-none overflow-y-auto rounded-none bg-surface-raised p-4 shadow-xl outline-none sm:max-w-sm sm:rounded-2xl sm:p-6"
>
<h2 id="edit-price-title" className="mb-4 text-lg font-semibold text-fg">
Edit Price
{t("editPrice.title")}
</h2>

<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="price" className="block text-sm font-medium text-fg">
New price (USDC)
{t("editPrice.newPriceLabel")}
</label>
<input
id="price"
Expand All @@ -91,13 +95,13 @@ export function EditPriceModal({ resourceId, currentPrice, apiKey, onClose, onCo
/>
</div>

{STATUS_LABELS[status] && (
<p className="text-sm text-accent-text">{STATUS_LABELS[status]}</p>
{STATUS_KEYS[status] && (
<p className="text-sm text-accent-text">{t(STATUS_KEYS[status])}</p>
)}

{networkWarning && (
<p className="rounded-lg bg-warning-soft px-3 py-2 text-sm text-warning">
⚠️ {networkWarning}
{t("editPrice.networkWarning", { warning: networkWarning })}
</p>
)}

Expand All @@ -106,7 +110,7 @@ export function EditPriceModal({ resourceId, currentPrice, apiKey, onClose, onCo

{status === "confirmed" && (
<p className="text-sm font-medium text-success">
Price updated to {newPrice} USDC on-chain and in the database.
{t("editPrice.success", { price: newPrice ?? "" })}
</p>
)}

Expand All @@ -117,15 +121,15 @@ export function EditPriceModal({ resourceId, currentPrice, apiKey, onClose, onCo
disabled={busy}
className="w-full rounded-lg px-4 py-3 text-sm text-fg-muted hover:bg-surface-hover disabled:opacity-50 sm:w-auto sm:py-2"
>
{status === "confirmed" ? "Close" : "Cancel"}
{status === "confirmed" ? t("common.close") : t("common.cancel")}
</button>
{status !== "confirmed" && (
<button
type="submit"
disabled={busy}
className="w-full rounded-lg bg-accent px-4 py-3 text-sm font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto sm:py-2"
>
{busy ? "Working…" : "Update Price"}
{busy ? t("common.working") : t("editPrice.updatePrice")}
</button>
)}
</div>
Expand Down
16 changes: 10 additions & 6 deletions src/components/TransferOwnershipModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TransferOwnershipModal } from "./TransferOwnershipModal.js";
import { useTransferOwnership } from "../hooks/useTransferOwnership.js";
import { I18nProvider } from "../i18n/I18nProvider.js";

vi.mock("../hooks/useTransferOwnership.js", () => ({
useTransferOwnership: vi.fn(),
}));

const renderWithI18n = (ui: React.ReactElement) =>
render(<I18nProvider>{ui}</I18nProvider>);

describe("TransferOwnershipModal", () => {
const onClose = vi.fn();
const onConfirmed = vi.fn();
Expand All @@ -25,7 +29,7 @@ describe("TransferOwnershipModal", () => {
});

it("renders with labeled owner address input", () => {
render(
renderWithI18n(
<TransferOwnershipModal
resourceId="res-1"
apiKey="key-1"
Expand All @@ -41,7 +45,7 @@ describe("TransferOwnershipModal", () => {

it("submits new owner on happy path", async () => {
const user = userEvent.setup();
render(
renderWithI18n(
<TransferOwnershipModal
resourceId="res-1"
apiKey="key-1"
Expand All @@ -65,7 +69,7 @@ describe("TransferOwnershipModal", () => {
transferOwnership: mockTransfer,
});

render(
renderWithI18n(
<TransferOwnershipModal
resourceId="res-1"
apiKey="key-1"
Expand All @@ -87,7 +91,7 @@ describe("TransferOwnershipModal", () => {
transferOwnership: mockTransfer,
});

render(
renderWithI18n(
<TransferOwnershipModal
resourceId="res-1"
apiKey="key-1"
Expand All @@ -108,7 +112,7 @@ describe("TransferOwnershipModal", () => {
transferOwnership: mockTransfer,
});

render(
renderWithI18n(
<TransferOwnershipModal
resourceId="res-1"
apiKey="key-1"
Expand All @@ -125,7 +129,7 @@ describe("TransferOwnershipModal", () => {

it("closes when cancel is clicked", async () => {
const user = userEvent.setup();
render(
renderWithI18n(
<TransferOwnershipModal
resourceId="res-1"
apiKey="key-1"
Expand Down
Loading