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
35 changes: 34 additions & 1 deletion app-lib/contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ vi.mock("@stellar/stellar-sdk/contract", async (importOriginal) => {
return { ...actual, Client: { from: fromMock } }
})

import { connectAsyncVault, connectNavOracle } from "./contracts"
import {
connectAsyncVault,
connectNavOracle,
connectShareToken,
} from "./contracts"

describe("connectAsyncVault", () => {
beforeEach(() => fromMock.mockClear())
Expand Down Expand Up @@ -67,3 +71,32 @@ describe("connectNavOracle", () => {
expect(options.signTransaction).toBe(signTransaction)
})
})

describe("connectShareToken", () => {
beforeEach(() => fromMock.mockClear())

it("builds a read client with no publicKey or signTransaction when no signer is given", async () => {
fromMock.mockResolvedValue({})

await connectShareToken("CTOKEN")

const options = fromMock.mock.calls[0]?.[0]
expect(options.contractId).toBe("CTOKEN")
expect(options.publicKey).toBeUndefined()
expect(options.signTransaction).toBeUndefined()
})

it("carries the connected address and signer when one is given", async () => {
fromMock.mockResolvedValue({})
const signTransaction = vi.fn()

await connectShareToken("CTOKEN", {
publicKey: "GTOKENADMIN",
signTransaction,
})

const options = fromMock.mock.calls[0]?.[0]
expect(options.publicKey).toBe("GTOKENADMIN")
expect(options.signTransaction).toBe(signTransaction)
})
})
7 changes: 5 additions & 2 deletions app-lib/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,11 @@ export const connectIdentityVerifier = (
): Promise<IdentityVerifierApi> =>
Client.from<IdentityVerifierApi>(clientOptions(contractId))

export const connectShareToken = (contractId: string): Promise<ShareTokenApi> =>
Client.from<ShareTokenApi>(clientOptions(contractId))
export const connectShareToken = (
contractId: string,
signer?: Signer,
): Promise<ShareTokenApi> =>
Client.from<ShareTokenApi>(clientOptions(contractId, signer))

export const connectAsset = (contractId: string): Promise<AssetApi> =>
Client.from<AssetApi>(clientOptions(contractId))
12 changes: 12 additions & 0 deletions app/src/components/admin/CycleActionRail.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@
}

.actionPrimary,
.actionDanger,
.actionUnavailable {
min-height: 48px;
padding: 13px 18px;
Expand All @@ -161,6 +162,17 @@
color: var(--vault-action);
}

.actionDanger {
border: 1px solid var(--vault-danger);
background: var(--vault-danger);
color: var(--vault-surface-card);
}

.actionDanger:hover:not(:disabled) {
background: var(--vault-danger-fill);
color: var(--vault-danger);
}

.actionUnavailable {
border: 1px solid var(--vault-border-hairline);
background: var(--vault-surface-inset);
Expand Down
17 changes: 17 additions & 0 deletions app/src/components/admin/CycleActionRail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const renderRail = (
) =>
render(
<CycleActionRail
scope="cycle"
actions={actions}
pending={pending}
unreadable={unreadable}
Expand Down Expand Up @@ -219,6 +220,7 @@ describe("CycleActionRail", () => {

rerender(
<CycleActionRail
scope="cycle"
actions={[
{ ...closeAction, enabled: false },
{ ...fulfillAction, enabled: true },
Expand All @@ -238,6 +240,7 @@ describe("CycleActionRail", () => {

rerender(
<CycleActionRail
scope="cycle"
actions={[fulfillAction, closeAction]}
pending={false}
unreadable={false}
Expand Down Expand Up @@ -268,6 +271,20 @@ describe("CycleActionRail", () => {
expect(screen.queryByText("No actions available.")).toBeNull()
})

it("names the surface it serves when its state could not be read", () => {
render(
<CycleActionRail
scope="emergency"
actions={[]}
pending={false}
unreadable
onRun={vi.fn()}
/>,
)

expect(screen.getByText("Could not read the emergency state.")).toBeTruthy()
})

it("lists each condition as met or not met", () => {
renderRail([fulfillAction])
expand("Fulfill epoch")
Expand Down
172 changes: 104 additions & 68 deletions app/src/components/admin/CycleActionRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,16 @@ import React, { useState } from "react"
import {
type ActionField as ActionFieldSpec,
type Condition,
type CycleAction,
type CycleActionId,
type RailAction,
type Readiness,
readiness,
readyCount,
} from "../../pages/cycle"
} from "../../pages/actions"
import typeStyles from "../../styles/type.module.css"
import Skeleton from "../Skeleton"
import styles from "./CycleActionRail.module.css"

type OnInput = (action: CycleAction, value: string) => void
type OnInput<A> = (action: A, value: string) => void

const conditionValue = (condition: Condition): string => {
if (condition.met === null) return "—"
Expand All @@ -25,12 +24,17 @@ const summaryOf = (state: Readiness): string => {
return `${state.count} unmet`
}

const ActionField: React.FC<{
action: CycleAction
function ActionField<A extends RailAction>({
id,
action,
field,
onInput,
}: {
id: string
action: A
field: ActionFieldSpec
onInput: OnInput | undefined
}> = ({ action, field, onInput }) => {
const id = `cycle-${action.id}-field`
onInput: OnInput<A> | undefined
}) {
return (
<div className={field.invalid ? styles.fieldOver : styles.field}>
<label htmlFor={id} className={styles.srOnly}>
Expand All @@ -57,65 +61,92 @@ const ActionField: React.FC<{
)
}

const ActionBody: React.FC<{
action: CycleAction
onRun: (action: CycleAction) => void
onInput: OnInput | undefined
}> = ({ action, onRun, onInput }) => (
<>
{action.field !== undefined && (
<ActionField action={action} field={action.field} onInput={onInput} />
)}
<ul aria-label={`${action.label} conditions`} className={styles.conditions}>
{action.conditions.map((condition) => (
<li className={styles.condition} key={condition.label}>
<span className={`${typeStyles.footnote} ${styles.conditionLabel}`}>
{condition.label}
</span>
<span
className={`${typeStyles.railValue} ${
condition.met ? styles.met : styles.unmet
}`}
>
{conditionValue(condition)}
</span>
</li>
))}
</ul>
{action.note !== undefined && (
<p className={`${typeStyles.footnote} ${styles.note}`}>{action.note}</p>
)}
{action.outcome !== null && (
<p className={`${typeStyles.footnote} ${styles.outcome}`}>
{action.outcome}
</p>
)}
<button
type="button"
className={
action.enabled ? styles.actionPrimary : styles.actionUnavailable
}
disabled={!action.enabled}
onClick={() => onRun(action)}
>
{action.label}
</button>
{action.unavailable !== undefined && (
<p className={`${typeStyles.footnote} ${styles.note}`}>
{action.unavailable}
</p>
)}
</>
)
const buttonClass = (action: RailAction): string | undefined => {
if (!action.enabled) return styles.actionUnavailable
return action.tone === "danger" ? styles.actionDanger : styles.actionPrimary
}

const CycleActionRail: React.FC<{
actions: CycleAction[]
function ActionBody<A extends RailAction>({
fieldId,
action,
onRun,
onInput,
}: {
fieldId: string
action: A
onRun: (action: A) => void
onInput: OnInput<A> | undefined
}) {
return (
<>
{action.field !== undefined && (
<ActionField
id={fieldId}
action={action}
field={action.field}
onInput={onInput}
/>
)}
<ul
aria-label={`${action.label} conditions`}
className={styles.conditions}
>
{action.conditions.map((condition) => (
<li className={styles.condition} key={condition.label}>
<span className={`${typeStyles.footnote} ${styles.conditionLabel}`}>
{condition.label}
</span>
<span
className={`${typeStyles.railValue} ${
condition.met ? styles.met : styles.unmet
}`}
>
{conditionValue(condition)}
</span>
</li>
))}
</ul>
{action.note !== undefined && (
<p className={`${typeStyles.footnote} ${styles.note}`}>{action.note}</p>
)}
{action.outcome !== null && (
<p className={`${typeStyles.footnote} ${styles.outcome}`}>
{action.outcome}
</p>
)}
<button
type="button"
className={buttonClass(action)}
disabled={!action.enabled}
onClick={() => onRun(action)}
>
{action.label}
</button>
{action.unavailable !== undefined && (
<p className={`${typeStyles.footnote} ${styles.note}`}>
{action.unavailable}
</p>
)}
</>
)
}

function CycleActionRail<A extends RailAction>({
scope,
actions,
pending,
unreadable,
onRun,
onInput,
}: {
scope: string
actions: A[]
pending: boolean
unreadable: boolean
onRun: (action: CycleAction) => void
onInput?: OnInput
}> = ({ actions, pending, unreadable, onRun, onInput }) => {
const [open, setOpen] = useState<CycleActionId | null | undefined>()
onRun: (action: A) => void
onInput?: OnInput<A>
}) {
const [open, setOpen] = useState<string | null | undefined>()
if (open === undefined && actions.length > 0)
setOpen(actions.find((action) => action.enabled)?.id ?? null)

Expand All @@ -137,7 +168,7 @@ const CycleActionRail: React.FC<{
) : (
<p className={`${typeStyles.footnote} ${styles.note}`}>
{unreadable
? "Could not read the cycle state."
? `Could not read the ${scope} state.`
: "No actions available."}
</p>
))}
Expand All @@ -146,7 +177,7 @@ const CycleActionRail: React.FC<{
{actions.map((action) => {
const state = readiness(action)
const expanded = open === action.id
const id = `cycle-${action.id}-action`
const id = `${scope}-${action.id}-action`
return (
<li className={styles.item} key={action.id}>
<button
Expand Down Expand Up @@ -180,7 +211,12 @@ const CycleActionRail: React.FC<{
</span>
</button>
<div id={id} className={styles.body} hidden={!expanded}>
<ActionBody action={action} onRun={onRun} onInput={onInput} />
<ActionBody
fieldId={`${scope}-${action.id}-field`}
action={action}
onRun={onRun}
onInput={onInput}
/>
</div>
</li>
)
Expand Down
1 change: 1 addition & 0 deletions app/src/components/admin/CycleSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ const CycleSection: React.FC<{
activity={toActivityList(cycleEvents)}
rail={
<CycleActionRail
scope="cycle"
actions={actions}
pending={cycle.status === "checking"}
unreadable={cycle.status === "unreadable"}
Expand Down
Loading
Loading