Skip to content

Commit 688d9a4

Browse files
Oba-Onecodex
andcommitted
fix(shared,admin): polish endowment admin UX
Keep the endowments flow consistent in admin by fixing the unlimited deposit limit display, garden creation affordance, and vault detail navigation context. Co-Authored-By: Codex (automated) <noreply@openai.com>
1 parent d7258a1 commit 688d9a4

19 files changed

Lines changed: 420 additions & 84 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React from "react";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import { MemoryRouter } from "react-router-dom";
4+
import { renderWithProviders, screen } from "../test-utils";
5+
6+
const mockUseGardens = vi.fn();
7+
const mockUseActions = vi.fn();
8+
9+
vi.mock("@green-goods/shared", async (importOriginal) => {
10+
const actual = await importOriginal<typeof import("@green-goods/shared")>();
11+
return {
12+
...actual,
13+
useGardens: () => mockUseGardens(),
14+
useActions: () => mockUseActions(),
15+
};
16+
});
17+
18+
import { Breadcrumbs } from "@/components/Layout/Breadcrumbs";
19+
20+
describe("Breadcrumbs", () => {
21+
beforeEach(() => {
22+
vi.clearAllMocks();
23+
mockUseGardens.mockReturnValue({
24+
data: [{ id: "garden-1", name: "Alpha Garden" }],
25+
});
26+
mockUseActions.mockReturnValue({
27+
data: [],
28+
});
29+
});
30+
31+
it("shows endowments as the parent breadcrumb for vault pages opened from endowments", () => {
32+
renderWithProviders(
33+
<MemoryRouter
34+
initialEntries={[
35+
{
36+
pathname: "/gardens/garden-1/vault",
37+
state: { returnTo: "/endowments", returnLabelId: "app.admin.nav.treasury" },
38+
},
39+
]}
40+
>
41+
<Breadcrumbs />
42+
</MemoryRouter>
43+
);
44+
45+
expect(screen.getByRole("link", { name: "Endowments" })).toHaveAttribute(
46+
"href",
47+
"/endowments"
48+
);
49+
expect(screen.getByText("Vault")).toBeInTheDocument();
50+
expect(screen.queryByRole("link", { name: "Gardens" })).not.toBeInTheDocument();
51+
});
52+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from "vitest";
2+
import { getDepositLimitLabel } from "@/components/Vault/depositLimit";
3+
4+
describe("getDepositLimitLabel", () => {
5+
it("renders uint256 max deposit limits as Unlimited", () => {
6+
const maxUint256 = (1n << 256n) - 1n;
7+
8+
expect(
9+
getDepositLimitLabel(maxUint256, {
10+
assetSymbol: "DAI",
11+
decimals: 18,
12+
unlimitedLabel: "Unlimited",
13+
})
14+
).toBe("Unlimited");
15+
});
16+
17+
it("formats bounded deposit limits with token denomination", () => {
18+
expect(
19+
getDepositLimitLabel(1_500_000_000_000_000_000n, {
20+
assetSymbol: "DAI",
21+
decimals: 18,
22+
unlimitedLabel: "Unlimited",
23+
})
24+
).toBe("1.5 DAI");
25+
});
26+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { RiAddLine } from "@remixicon/react";
2+
import { Link } from "react-router-dom";
3+
import { Button } from "@/components/ui/Button";
4+
5+
interface CreateGardenActionProps {
6+
canDeploy: boolean;
7+
isLoading: boolean;
8+
createLabel: string;
9+
tooltip: string;
10+
}
11+
12+
export function CreateGardenAction({
13+
canDeploy,
14+
isLoading,
15+
createLabel,
16+
tooltip,
17+
}: CreateGardenActionProps) {
18+
if (isLoading) {
19+
return (
20+
<Button size="sm" disabled loading>
21+
<RiAddLine className="mr-1.5 h-4 w-4" />
22+
{createLabel}
23+
</Button>
24+
);
25+
}
26+
27+
if (canDeploy) {
28+
return (
29+
<Button size="sm" asChild>
30+
<Link to="/gardens/create">
31+
<RiAddLine className="mr-1.5 h-4 w-4" />
32+
{createLabel}
33+
</Link>
34+
</Button>
35+
);
36+
}
37+
38+
return (
39+
<span
40+
className="inline-flex"
41+
data-tooltip={tooltip}
42+
title={tooltip}
43+
tabIndex={0}
44+
aria-label={tooltip}
45+
>
46+
<Button size="sm" disabled>
47+
<RiAddLine className="mr-1.5 h-4 w-4" />
48+
{createLabel}
49+
</Button>
50+
</span>
51+
);
52+
}

packages/admin/src/components/Layout/Breadcrumbs.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,32 @@ interface BreadcrumbSegment {
3333
}
3434

3535
export function Breadcrumbs() {
36-
const { pathname } = useLocation();
36+
const location = useLocation();
37+
const { pathname } = location;
3738
const { formatMessage } = useIntl();
3839
const { data: gardens } = useGardens();
3940
const { data: actions } = useActions(DEFAULT_CHAIN_ID);
41+
const routeState = (location.state as { returnTo?: string; returnLabelId?: string } | null) ?? null;
4042

4143
const segments = useMemo(() => {
44+
if (routeState?.returnTo && /^\/gardens\/[^/]+\/vault$/.test(pathname)) {
45+
const returnRoot = routeState.returnTo.split("/").filter(Boolean)[0];
46+
const returnRouteLabel = returnRoot ? ROUTE_LABELS[returnRoot] : undefined;
47+
const returnLabel = routeState.returnLabelId
48+
? formatMessage({
49+
id: routeState.returnLabelId,
50+
defaultMessage: returnRouteLabel?.defaultMessage ?? "Back",
51+
})
52+
: returnRouteLabel
53+
? formatMessage(returnRouteLabel)
54+
: routeState.returnTo;
55+
56+
return [
57+
{ label: returnLabel, href: routeState.returnTo },
58+
{ label: formatMessage(SUB_ROUTE_LABELS.vault), href: pathname },
59+
];
60+
}
61+
4262
const parts = pathname.split("/").filter(Boolean);
4363
if (parts.length === 0) return [];
4464

@@ -85,7 +105,7 @@ export function Breadcrumbs() {
85105
}
86106

87107
return result;
88-
}, [pathname, gardens, actions, formatMessage]);
108+
}, [pathname, gardens, actions, formatMessage, routeState?.returnLabelId, routeState?.returnTo]);
89109

90110
if (segments.length <= 1) return null;
91111

packages/admin/src/components/Vault/DepositModal.tsx

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { ConnectButton } from "@/components/ConnectButton";
2424
import { TxInlineFeedback } from "@/components/feedback/TxInlineFeedback";
2525
import { Button } from "@/components/ui/Button";
2626
import { FormField } from "@/components/ui/FormField";
27+
import { getDepositLimitLabel } from "./depositLimit";
2728

2829
const VAULT_DEPOSIT_ABI = [
2930
{
@@ -156,6 +157,17 @@ export function DepositModal({
156157
id: txErrorView.messageKey,
157158
defaultMessage: "Something went wrong. Please try again.",
158159
});
160+
const depositLimitLabel =
161+
healthCheck?.maxDeposit && healthCheck.maxDeposit > 0n
162+
? getDepositLimitLabel(healthCheck.maxDeposit, {
163+
assetSymbol,
164+
decimals,
165+
unlimitedLabel: formatMessage({
166+
id: "app.treasury.unlimited",
167+
defaultMessage: "Unlimited",
168+
}),
169+
})
170+
: null;
159171

160172
const onSubmit = () => {
161173
if (!vaultAcceptingDeposits) return;
@@ -297,14 +309,14 @@ export function DepositModal({
297309
</span>
298310
</p>
299311
{healthCheck.maxDeposit > 0n && (
300-
<p>
301-
{formatMessage({ id: "app.treasury.depositLimit" })}:{" "}
302-
<span className="font-medium text-text-strong">
303-
{formatTokenAmount(healthCheck.maxDeposit, decimals)} {assetSymbol}
304-
</span>
305-
</p>
306-
)}
307-
</div>
312+
<p>
313+
{formatMessage({ id: "app.treasury.depositLimit" })}:{" "}
314+
<span className="font-medium text-text-strong">
315+
{depositLimitLabel}
316+
</span>
317+
</p>
318+
)}
319+
</div>
308320
)}
309321

310322
{!vaultAcceptingDeposits && (
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { formatTokenAmount, isUnlimitedVaultLimit } from "@green-goods/shared";
2+
3+
interface DepositLimitLabelOptions {
4+
assetSymbol: string;
5+
decimals: number;
6+
locale?: string;
7+
unlimitedLabel: string;
8+
}
9+
10+
export function getDepositLimitLabel(
11+
value: bigint,
12+
{ assetSymbol, decimals, locale, unlimitedLabel }: DepositLimitLabelOptions
13+
): string {
14+
if (isUnlimitedVaultLimit(value)) {
15+
return unlimitedLabel;
16+
}
17+
18+
return `${formatTokenAmount(value, decimals, 4, locale)} ${assetSymbol}`;
19+
}

packages/admin/src/index.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,8 @@
382382
opacity: 0;
383383
transition: opacity 0.15s ease;
384384
}
385-
[data-tooltip]:hover::after {
385+
[data-tooltip]:hover::after,
386+
[data-tooltip]:focus-visible::after {
386387
opacity: 1;
387388
}
388389
/* Right-aligned variant to prevent overflow on right-edge elements */
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import React from "react";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import { renderWithProviders, screen } from "@/__tests__/test-utils";
4+
5+
const TEST_WETH = "0x7b79995e5f793a07bc00c21412e50ecae098e7f9";
6+
7+
const mockUseGardens = vi.fn();
8+
const mockUseGardenVaults = vi.fn();
9+
const mockUseMyVaultDeposits = vi.fn();
10+
const mockUseVaultPreview = vi.fn();
11+
12+
vi.mock("@green-goods/shared", async (importOriginal) => {
13+
const actual = await importOriginal<typeof import("@green-goods/shared")>();
14+
return {
15+
...actual,
16+
formatAddress: (value: string) => value.slice(0, 6),
17+
formatTokenAmount: (value: bigint) => (value === 0n ? "0" : `${Number(value) / 10 ** 18}`),
18+
getNetDeposited: (deposited: bigint, withdrawn: bigint) => deposited - withdrawn,
19+
getVaultAssetSymbol: (asset: string) => (asset.toLowerCase() === TEST_WETH ? "WETH" : "DAI"),
20+
ImageWithFallback: ({ alt }: { alt: string }) => React.createElement("div", null, alt),
21+
useDebouncedValue: <T,>(value: T) => value,
22+
useGardens: () => mockUseGardens(),
23+
useGardenVaults: (...args: unknown[]) => mockUseGardenVaults(...args),
24+
useMyVaultDeposits: (...args: unknown[]) => mockUseMyVaultDeposits(...args),
25+
useUser: () => ({ primaryAddress: "0x1111111111111111111111111111111111111111" }),
26+
useVaultPreview: (...args: unknown[]) => mockUseVaultPreview(...args),
27+
};
28+
});
29+
30+
vi.mock("react-router-dom", () => ({
31+
Link: ({ to, state, children, ...props }: any) =>
32+
React.createElement("a", { href: to, "data-state": JSON.stringify(state), ...props }, children),
33+
}));
34+
35+
vi.mock("@/components/Layout/PageHeader", () => ({
36+
PageHeader: ({ title, description }: { title: string; description?: string }) =>
37+
React.createElement(
38+
"div",
39+
{ "data-testid": "page-header" },
40+
React.createElement("h1", null, title),
41+
description ? React.createElement("p", null, description) : null
42+
),
43+
}));
44+
45+
vi.mock("@/components/StatCard", () => ({
46+
StatCard: ({ label, value }: { label: string; value: React.ReactNode }) =>
47+
React.createElement(
48+
"div",
49+
null,
50+
React.createElement("span", null, label),
51+
React.createElement("span", null, value)
52+
),
53+
}));
54+
55+
vi.mock("@/components/ui/Card", () => ({
56+
Card: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
57+
}));
58+
59+
vi.mock("@/components/ui/EmptyState", () => ({
60+
EmptyState: ({ title, description }: { title: string; description?: string }) =>
61+
React.createElement("div", null, title, description),
62+
}));
63+
64+
vi.mock("@/components/ui/ListToolbar", () => ({
65+
ListToolbar: ({ children }: { children: React.ReactNode }) =>
66+
React.createElement("div", null, children),
67+
}));
68+
69+
vi.mock("@/components/ui/SortSelect", () => ({
70+
SortSelect: () => React.createElement("div", null, "sort"),
71+
}));
72+
73+
import EndowmentsOverview from "./index";
74+
75+
describe("EndowmentsOverview", () => {
76+
beforeEach(() => {
77+
vi.clearAllMocks();
78+
mockUseGardens.mockReturnValue({
79+
data: [{ id: "garden-1", name: "Alpha Garden", location: "One" }],
80+
isLoading: false,
81+
});
82+
mockUseGardenVaults.mockReturnValue({
83+
vaults: [
84+
{
85+
id: "vault-1",
86+
chainId: 11155111,
87+
garden: "garden-1",
88+
asset: TEST_WETH,
89+
vaultAddress: "0x4444444444444444444444444444444444444444",
90+
totalDeposited: 1_000_000_000_000_000_000n,
91+
totalWithdrawn: 0n,
92+
totalHarvestCount: 3,
93+
},
94+
],
95+
isLoading: false,
96+
});
97+
mockUseMyVaultDeposits.mockReturnValue({
98+
deposits: [],
99+
isLoading: false,
100+
});
101+
mockUseVaultPreview.mockReturnValue({
102+
preview: { previewAssets: 1_000_000_000_000_000_000n },
103+
isLoading: false,
104+
});
105+
});
106+
107+
it("passes endowments return context into the manage vault link", () => {
108+
renderWithProviders(<EndowmentsOverview />);
109+
110+
expect(screen.getByRole("link", { name: /manage vault/i })).toHaveAttribute(
111+
"data-state",
112+
JSON.stringify({ returnTo: "/endowments", returnLabelId: "app.admin.nav.treasury" })
113+
);
114+
});
115+
});

packages/admin/src/views/Endowments/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,10 @@ export default function EndowmentsOverview() {
528528
</p>
529529
{item.garden && (
530530
<Button variant="secondary" size="sm" asChild>
531-
<Link to={`/gardens/${item.garden.id}/vault`}>
531+
<Link
532+
to={`/gardens/${item.garden.id}/vault`}
533+
state={{ returnTo: "/endowments", returnLabelId: "app.admin.nav.treasury" }}
534+
>
532535
{formatMessage({ id: "app.treasury.manageVault" })}
533536
<RiArrowRightLine className="h-4 w-4" />
534537
</Link>

0 commit comments

Comments
 (0)