Skip to content
Open
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
1 change: 1 addition & 0 deletions app-lib/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface AsyncVaultApi {
treasury: Call<Option<string>>
guardian: Call<Option<string>>
custodian: Call<Option<string>>
paused: Call<boolean>
current_epoch: Call<u64>
get_epoch: CallOf<{ epoch_id: u64 }, Option<EpochInfo>>
get_deposit_request: CallOf<
Expand Down
46 changes: 45 additions & 1 deletion app/src/components/vault/ActionPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,15 @@ describe("ActionPanel", () => {
expect(onSubmit).not.toHaveBeenCalled()
})

it("hides the form and shows only the reason for a message block", () => {
it("hides the whole panel, tabs included, for a message block that covers both sides", () => {
render(
<ActionPanel
{...baseProps}
balance={null}
block={{
kind: "message",
reason: "This address is not on the vault's allowlist.",
sides: ["subscribe", "redeem"],
}}
/>,
)
Expand All @@ -139,6 +140,49 @@ describe("ActionPanel", () => {
).toBeTruthy()
})

it("keeps the tabs reachable for a message block scoped to the current side only", () => {
render(
<ActionPanel
{...baseProps}
balance={null}
block={{
kind: "message",
reason: "The vault is not accepting new requests right now.",
sides: ["subscribe"],
}}
/>,
)

expect(screen.getByRole("tablist")).toBeTruthy()
expect(screen.queryByRole("textbox")).toBeNull()
expect(screen.queryByRole("button", { name: "Subscribe" })).toBeNull()
expect(
screen.getByText("The vault is not accepting new requests right now."),
).toBeTruthy()
})

it("leaves the other side's form untouched by a block scoped to the current side", () => {
render(
<ActionPanel
{...baseProps}
side="redeem"
submitLabel="Redeem"
block={{
kind: "message",
reason: "The vault is not accepting new requests right now.",
sides: ["subscribe"],
}}
/>,
)

expect(screen.getByRole("tablist")).toBeTruthy()
expect(screen.getByRole("textbox")).toBeTruthy()
expect(screen.getByRole("button", { name: "Redeem" })).toBeTruthy()
expect(
screen.queryByText("The vault is not accepting new requests right now."),
).toBeNull()
})

it("hides MAX and cannot reach the over-balance state when balance is null", () => {
render(<ActionPanel {...baseProps} balance={null} amount="999999999" />)

Expand Down
142 changes: 84 additions & 58 deletions app/src/components/vault/ActionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type ActionBlock = {
export type MessageBlock = {
kind: "message"
reason: string
sides: ActionPanelSide[]
}

export type ActionPanelBlock = ActionBlock | MessageBlock
Expand Down Expand Up @@ -121,17 +122,31 @@ const ActionPanel: React.FC<ActionPanelProps> = ({
if (balance !== null) onAmountChange(formatAmount(balance))
}
const actionBlock = block?.kind === "action" ? block : undefined
const messageBlock = block?.kind === "message" ? block : undefined
const blocksEverySide =
messageBlock !== undefined &&
messageBlock.sides.includes("subscribe") &&
messageBlock.sides.includes("redeem")
const blocksThisSide =
messageBlock !== undefined && messageBlock.sides.includes(side)

return (
<div className={styles.panel}>
<h2 className={`${typeStyles.sectionHead} ${styles.heading}`}>
{heading}
</h2>
{note && <p className={`${typeStyles.body} ${styles.note}`}>{note}</p>}
{block?.kind === "message" ? (
<p className={`${typeStyles.body} ${styles.blocked}`}>{block.reason}</p>
{blocksEverySide ? (
<p className={`${typeStyles.body} ${styles.blocked}`}>
{messageBlock.reason}
</p>
) : (
<>
{blocksThisSide && (
<p className={`${typeStyles.body} ${styles.blocked}`}>
{messageBlock.reason}
</p>
)}
{actionBlock && (
<p className={`${typeStyles.body} ${styles.blocked}`}>
{actionBlock.reason}
Expand Down Expand Up @@ -163,66 +178,77 @@ const ActionPanel: React.FC<ActionPanelProps> = ({
</button>
</div>

<div className={isOverBalance ? styles.fieldOver : styles.field}>
<div className={styles.amountRow}>
<label htmlFor="action-panel-amount" className={styles.srOnly}>
{amountLabel}
</label>
<input
id="action-panel-amount"
className={`${typeStyles.amountInput} ${styles.amountInput}`}
value={amount}
onChange={(event) => onAmountChange(event.target.value)}
placeholder="0.00"
inputMode="decimal"
/>
<span className={styles.ticker}>{ticker}</span>
{balance !== null && (
<button
type="button"
className={styles.maxButton}
onClick={fillMax}
{!blocksThisSide && (
<>
<div className={isOverBalance ? styles.fieldOver : styles.field}>
<div className={styles.amountRow}>
<label
htmlFor="action-panel-amount"
className={styles.srOnly}
>
{amountLabel}
</label>
<input
id="action-panel-amount"
className={`${typeStyles.amountInput} ${styles.amountInput}`}
value={amount}
onChange={(event) => onAmountChange(event.target.value)}
placeholder="0.00"
inputMode="decimal"
/>
<span className={styles.ticker}>{ticker}</span>
{balance !== null && (
<button
type="button"
className={styles.maxButton}
onClick={fillMax}
>
MAX
</button>
)}
</div>
<div className={styles.balanceRow}>
<span
className={`${typeStyles.footnote} ${styles.balanceLabel}`}
>
{balanceLabel}
</span>
</div>
</div>

<div className={styles.estimate}>
<span className={`${typeStyles.label} ${styles.estimateLabel}`}>
{estimate.label}
</span>
<span
className={`${
estimate.status === "ready"
? typeStyles.estimate
: typeStyles.body
} ${styles.estimateValue}`}
>
MAX
</button>
)}
</div>
<div className={styles.balanceRow}>
<span className={`${typeStyles.footnote} ${styles.balanceLabel}`}>
{balanceLabel}
</span>
</div>
</div>
{estimate.status === "ready"
? estimate.value
: estimate.reason}
</span>
</div>

<div className={styles.estimate}>
<span className={`${typeStyles.label} ${styles.estimateLabel}`}>
{estimate.label}
</span>
<span
className={`${
estimate.status === "ready"
? typeStyles.estimate
: typeStyles.body
} ${styles.estimateValue}`}
>
{estimate.status === "ready" ? estimate.value : estimate.reason}
</span>
</div>
{overBalance !== undefined && (
<p className={`${typeStyles.body} ${styles.errorMessage}`}>
{`You have ${overBalance} ${ticker}. Enter ${overBalance} or less.`}
</p>
)}

{overBalance !== undefined && (
<p className={`${typeStyles.body} ${styles.errorMessage}`}>
{`You have ${overBalance} ${ticker}. Enter ${overBalance} or less.`}
</p>
<PanelActions
block={actionBlock}
overBalance={overBalance}
submitLabel={submitLabel}
canSubmit={canSubmit}
onSubmit={onSubmit}
onFillMax={fillMax}
/>
</>
)}

<PanelActions
block={actionBlock}
overBalance={overBalance}
submitLabel={submitLabel}
canSubmit={canSubmit}
onSubmit={onSubmit}
onFillMax={fillMax}
/>
</>
)}
</div>
Expand Down
28 changes: 28 additions & 0 deletions app/src/hooks/useVaultPaused.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { type ContractRead } from "@stellar-scaffold/app-lib"
import { describe, expect, it, vi } from "vitest"
import { classifyPause } from "./useVaultPaused"

vi.mock("../config/clients", () => ({
asyncVault: async () => ({
paused: async () => ({}),
}),
}))

const paused: ContractRead<boolean> = { kind: "value", value: true }
const open: ContractRead<boolean> = { kind: "value", value: false }
const contractError: ContractRead<boolean> = {
kind: "contract-error",
code: 1,
}
const unreadable: ContractRead<boolean> = { kind: "unreadable" }

describe("classifyPause", () => {
it.each<[string, ContractRead<boolean>, string]>([
["a value of true", paused, "paused"],
["a value of false", open, "open"],
["a contract error, never as open", contractError, "unreadable"],
["an unreadable read", unreadable, "unreadable"],
])("classifies %s as %s", (_name, read, expected) => {
expect(classifyPause(read)).toBe(expected)
})
})
25 changes: 25 additions & 0 deletions app/src/hooks/useVaultPaused.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { type ContractRead, readContract } from "@stellar-scaffold/app-lib"
import { useQuery } from "@tanstack/react-query"
import { asyncVault } from "../config/clients"

export type PauseState = "checking" | "open" | "paused" | "unreadable"

export function classifyPause(read: ContractRead<boolean>): PauseState {
if (read.kind !== "value") return "unreadable"
return read.value ? "paused" : "open"
}

async function fetchVaultPause(): Promise<PauseState> {
const read = await readContract(async () => (await asyncVault()).paused())
return classifyPause(read)
}

export function useVaultPaused(): { pause: PauseState } {
const { data } = useQuery({
queryKey: ["vault", "paused"],
queryFn: fetchVaultPause,
staleTime: 30_000,
})

return { pause: data ?? "checking" }
}
Loading
Loading