diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index e0cecae..6cf9ce1 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -72,4 +72,13 @@ describe("App", () => { ).toBeTruthy() expect(screen.getByText("GitHub")).toBeTruthy() }) + + it("serves the admin panel at a surface path too", () => { + useAdminAuthorityMock.mockReturnValue(disconnectedAuthority) + renderAt("/admin/cycle") + + expect( + screen.getByText("Connect a wallet to operate this vault."), + ).toBeTruthy() + }) }) diff --git a/app/src/App.tsx b/app/src/App.tsx index 537f32e..3fde5b2 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -11,6 +11,7 @@ function App() { }> } /> } /> + } /> ) diff --git a/app/src/components/admin/CycleActionRail.module.css b/app/src/components/admin/CycleActionRail.module.css new file mode 100644 index 0000000..3e6cc47 --- /dev/null +++ b/app/src/components/admin/CycleActionRail.module.css @@ -0,0 +1,224 @@ +.panel { + display: flex; + flex-direction: column; + gap: 15px; + padding: 24px; + border: 1px solid var(--vault-ink); + background: var(--vault-surface-card); +} + +.head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: baseline; + gap: 6px 12px; +} + +.heading { + margin: 0; + color: var(--vault-ink); +} + +.count { + color: var(--vault-text-muted); +} + +.skeleton { + height: 1.2em; + width: 60%; +} + +.list { + margin: 0; + padding: 0; + list-style: none; +} + +.item { + border-top: 1px solid var(--vault-border-hairline); +} + +.toggle { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 48px; + padding: 12px 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--vault-ink); + text-align: left; + cursor: pointer; +} + +.toggle:hover .toggleLabel { + color: var(--vault-action); +} + +.toggle:focus-visible { + outline: 2px solid var(--vault-action); + outline-offset: 2px; +} + +.marker, +.markerReady { + flex: none; + width: 10px; + height: 10px; + border: 1.5px solid var(--vault-border-inactive); + background: transparent; +} + +.markerReady { + border-color: var(--vault-action); + background: var(--vault-action); +} + +.toggleLabel { + flex: 1; + min-width: 0; + font-weight: 500; +} + +.summary { + flex: none; + color: var(--vault-text-muted); +} + +.body { + display: flex; + flex-direction: column; + gap: 11px; + padding-bottom: 18px; +} + +.body[hidden] { + display: none; +} + +.conditions { + display: flex; + flex-direction: column; + gap: 7px; + margin: 0; + padding: 0; + list-style: none; +} + +.condition { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; +} + +.conditionLabel { + color: var(--vault-text-muted); +} + +.met, +.unmet { + text-align: right; + white-space: nowrap; +} + +.unmet { + color: var(--vault-text-muted); +} + +.note { + margin: 0; + color: var(--vault-text-muted); +} + +.outcome { + margin: 0; + color: var(--vault-text-body); +} + +.actionPrimary, +.actionUnavailable { + min-height: 48px; + padding: 13px 18px; + font-family: var(--vault-font-sans); + font-size: 15.5px; + font-weight: 500; + border-radius: 0; +} + +.actionPrimary { + border: 1px solid var(--vault-action); + background: var(--vault-action); + color: var(--vault-surface-card); +} + +.actionPrimary:hover:not(:disabled) { + background: var(--vault-action-ring); + border-color: var(--vault-action); + color: var(--vault-action); +} + +.actionUnavailable { + border: 1px solid var(--vault-border-hairline); + background: var(--vault-surface-inset); + color: var(--vault-text-muted); + cursor: not-allowed; +} + +.field, +.fieldOver { + display: flex; + align-items: center; + gap: 10px; + min-height: 58px; + padding: 8px 8px 8px 16px; + border: 1px solid var(--vault-action); + background: var(--vault-surface-card); +} + +.fieldOver { + border: 2px solid var(--vault-danger); +} + +.fieldInput { + flex: 1; + min-width: 0; + border: 0; + outline: none; + background: transparent; + color: var(--vault-ink); +} + +.fieldInput::placeholder { + color: var(--vault-text-muted); +} + +.useMax { + flex: none; + min-height: 40px; + padding: 9px 11px; + border: 1px solid var(--vault-border-hairline); + border-radius: 0; + background: var(--vault-surface-card); + color: var(--vault-text-body); + font-family: var(--vault-font-mono); + font-weight: 500; + font-size: 12px; +} + +.useMax:hover { + background: var(--vault-action-ring); + border-color: var(--vault-action); + color: var(--vault-action); +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); +} diff --git a/app/src/components/admin/CycleActionRail.test.tsx b/app/src/components/admin/CycleActionRail.test.tsx new file mode 100644 index 0000000..1174d3b --- /dev/null +++ b/app/src/components/admin/CycleActionRail.test.tsx @@ -0,0 +1,407 @@ +import { fireEvent, render, screen, within } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import { type CycleAction } from "../../pages/cycle" +import CycleActionRail from "./CycleActionRail" + +vi.mock("@stellar-scaffold/app-lib", () => ({ + shortAddress: (address: string) => + `${address.slice(0, 4)}...${address.slice(-4)}`, +})) + +const closeAction: CycleAction = { + id: "close-epoch", + group: "Epoch", + label: "Close epoch", + conditions: [ + { label: "Wind-down not active", met: true }, + { label: "Epoch open", met: true }, + ], + outcome: "Seals epoch 5 and opens epoch 6.", + epochId: 5n, + enabled: true, +} + +const fulfillAction: CycleAction = { + id: "fulfill-epoch", + group: "Epoch", + label: "Fulfill epoch", + conditions: [ + { label: "Sealed epoch awaiting a price", met: true }, + { label: "Notice elapsed", met: false }, + ], + outcome: "Prices epoch 4 at 1.0000 and settles its requests.", + epochId: 4n, + enabled: false, +} + +const attestAction: CycleAction = { + id: "attest", + group: "Price", + label: "Attest price", + conditions: [ + { label: "Within the band", met: null }, + { label: "Cooldown elapsed", met: true }, + ], + outcome: null, + field: { + label: "Share price", + placeholder: "0.0000", + value: "", + invalid: false, + }, + price: null, + freshness: null, + enabled: false, +} + +const deployAction: CycleAction = { + id: "deploy", + group: "Reserve", + label: "Deploy to custodian", + conditions: [ + { label: "Custodian set", met: true, detail: "CCUS...7890" }, + { label: "Within the free reserve", met: null }, + ], + outcome: null, + field: { + label: "Amount to deploy", + placeholder: "0.00", + value: "", + invalid: false, + max: "900.0000001", + }, + amount: null, + enabled: false, +} + +const unavailableClose: CycleAction = { + ...closeAction, + enabled: false, + unavailable: "Needs 2 signatures; collecting them is not supported yet.", +} + +const renderRail = ( + actions: CycleAction[], + { + onRun = vi.fn(), + onInput = vi.fn(), + pending = false, + unreadable = false, + }: { + onRun?: (action: CycleAction) => void + onInput?: (action: CycleAction, value: string) => void + pending?: boolean + unreadable?: boolean + } = {}, +) => + render( + , + ) + +const rowToggle = (label: string) => + screen + .getAllByRole("button", { name: label }) + .find((button) => button.hasAttribute("aria-expanded"))! + +const submitButton = (label: string) => + screen + .getAllByRole("button", { name: label }) + .find((button) => !button.hasAttribute("aria-expanded")) as + HTMLButtonElement | undefined + +const expand = (label: string) => { + const toggle = rowToggle(label) + if (toggle.getAttribute("aria-expanded") !== "true") fireEvent.click(toggle) +} + +const expanded = (label: string) => + rowToggle(label).getAttribute("aria-expanded") === "true" + +describe("CycleActionRail", () => { + it("heads the rail with how many of its actions are ready", () => { + renderRail([closeAction, fulfillAction, attestAction]) + + expect(screen.getByRole("heading", { name: "Actions" })).toBeTruthy() + expect(screen.getByText("1 of 3 ready")).toBeTruthy() + }) + + it("lists one row per action, in order, summarising its readiness", () => { + renderRail([unavailableClose, fulfillAction, attestAction, deployAction]) + + expect( + screen + .getAllByRole("button") + .filter((button) => button.hasAttribute("aria-expanded")) + .map((toggle) => toggle.textContent), + ).toEqual([ + "Close epochUnavailable", + "Fulfill epoch1 unmet", + "Attest price1 unmet", + "Deploy to custodian1 unmet", + ]) + for (const [name, description] of [ + ["Close epoch", "Unavailable"], + ["Fulfill epoch", "1 unmet"], + ["Attest price", "1 unmet"], + ["Deploy to custodian", "1 unmet"], + ] as const) + expect( + screen.getAllByRole("button", { name, description })[0], + ).toBeTruthy() + }) + + it("summarises a ready action as Ready", () => { + renderRail([closeAction]) + + expect( + screen.getByRole("button", { name: "Close epoch", description: "Ready" }), + ).toBeTruthy() + }) + + it("opens the first ready action and keeps the others collapsed", () => { + renderRail([fulfillAction, closeAction, { ...attestAction, enabled: true }]) + + expect(expanded("Fulfill epoch")).toBe(false) + expect(expanded("Close epoch")).toBe(true) + expect(expanded("Attest price")).toBe(false) + expect(submitButton("Close epoch")).toBeTruthy() + expect(submitButton("Fulfill epoch")).toBeUndefined() + }) + + it("keeps every row collapsed when no action is ready", () => { + renderRail([fulfillAction, attestAction]) + + expect(expanded("Fulfill epoch")).toBe(false) + expect(expanded("Attest price")).toBe(false) + expect(screen.queryByRole("textbox")).toBeNull() + }) + + it("opens one row at a time and collapses the open row on a second click", () => { + renderRail([closeAction, fulfillAction]) + + fireEvent.click(rowToggle("Fulfill epoch")) + + expect(expanded("Fulfill epoch")).toBe(true) + expect(expanded("Close epoch")).toBe(false) + expect(submitButton("Fulfill epoch")).toBeTruthy() + expect(submitButton("Close epoch")).toBeUndefined() + + fireEvent.click(rowToggle("Fulfill epoch")) + + expect(expanded("Fulfill epoch")).toBe(false) + expect(expanded("Close epoch")).toBe(false) + }) + + it("controls the region holding the action's content", () => { + renderRail([closeAction, fulfillAction]) + + const region = document.getElementById( + rowToggle("Fulfill epoch").getAttribute("aria-controls")!, + )! + expect(region.hidden).toBe(true) + + fireEvent.click(rowToggle("Fulfill epoch")) + + expect(region.hidden).toBe(false) + expect( + within(region).getByRole("button", { name: "Fulfill epoch" }), + ).toBeTruthy() + }) + + it("keeps the open row open once its action stops being ready", () => { + const { rerender } = renderRail([closeAction, fulfillAction]) + + rerender( + , + ) + + expect(expanded("Close epoch")).toBe(true) + expect(expanded("Fulfill epoch")).toBe(false) + }) + + it("opens the first ready action once the actions arrive", () => { + const { rerender } = renderRail([], { pending: true }) + + rerender( + , + ) + + expect(expanded("Close epoch")).toBe(true) + }) + + it("shows a loading line and no rows while the actions are being read", () => { + renderRail([], { pending: true }) + + expect(screen.queryByRole("button")).toBeNull() + expect(screen.queryByText(/ready/)).toBeNull() + }) + + it("says so when there is no action to offer", () => { + renderRail([]) + + expect(screen.getByText("No actions available.")).toBeTruthy() + }) + + it("says the cycle state could not be read, not that no action exists", () => { + renderRail([], { unreadable: true }) + + expect(screen.getByText("Could not read the cycle state.")).toBeTruthy() + expect(screen.queryByText("No actions available.")).toBeNull() + }) + + it("lists each condition as met or not met", () => { + renderRail([fulfillAction]) + expand("Fulfill epoch") + + const list = screen.getByRole("list", { name: "Fulfill epoch conditions" }) + const [awaiting, notice] = within(list).getAllByRole("listitem") + expect( + within(awaiting!).getByText("Sealed epoch awaiting a price"), + ).toBeTruthy() + expect(within(awaiting!).getByText("Met")).toBeTruthy() + expect(within(notice!).getByText("Notice elapsed")).toBeTruthy() + expect(within(notice!).getByText("Not met")).toBeTruthy() + }) + + it("states the resulting state before the button and runs only an enabled action", () => { + const onRun = vi.fn() + renderRail([closeAction, fulfillAction], { onRun }) + + expect(screen.getByText("Seals epoch 5 and opens epoch 6.")).toBeTruthy() + const close = submitButton("Close epoch")! + expect(close.disabled).toBe(false) + fireEvent.click(close) + expand("Fulfill epoch") + const fulfill = submitButton("Fulfill epoch")! + expect(fulfill.disabled).toBe(true) + fireEvent.click(fulfill) + + expect(onRun).toHaveBeenCalledTimes(1) + expect(onRun).toHaveBeenCalledWith(closeAction) + }) + + it("says in full why an action is unavailable once expanded, keeping its button disabled", () => { + renderRail([unavailableClose]) + expand("Close epoch") + + expect( + screen.getByText( + "Needs 2 signatures; collecting them is not supported yet.", + ), + ).toBeTruthy() + expect(submitButton("Close epoch")?.disabled).toBe(true) + }) + + it("omits the resulting state when there is none to show", () => { + renderRail([{ ...fulfillAction, outcome: null }]) + expand("Fulfill epoch") + + expect(screen.queryByText(/Prices epoch/)).toBeNull() + expect(submitButton("Fulfill epoch")).toBeTruthy() + }) + + it("asks for a figure in a decimal field, labelled for screen readers, and reports each keystroke", () => { + const onInput = vi.fn() + renderRail([attestAction], { onInput }) + expand("Attest price") + + const field = screen.getByRole("textbox", { + name: "Share price", + }) as HTMLInputElement + expect(field.placeholder).toBe("0.0000") + expect(field.inputMode).toBe("decimal") + + fireEvent.change(field, { target: { value: "1.04" } }) + + expect(onInput).toHaveBeenCalledWith(attestAction, "1.04") + }) + + it("renders no field for an action that takes no figure", () => { + renderRail([closeAction]) + + expect(screen.queryByRole("textbox")).toBeNull() + }) + + it("leaves a condition open with a dash while it cannot be judged yet", () => { + renderRail([attestAction]) + expand("Attest price") + + const band = screen.getByText("Within the band").closest("li")! + expect(within(band).getByText("—")).toBeTruthy() + expect(within(band).queryByText("Not met")).toBeNull() + }) + + it("shows a condition's detail in place of Not met when it has one", () => { + renderRail([ + { + ...attestAction, + conditions: [ + { label: "Within the band", met: false, detail: "0.5000 – 2.0000" }, + ], + }, + ]) + expand("Attest price") + + const band = screen.getByText("Within the band").closest("li")! + expect(within(band).getByText("0.5000 – 2.0000")).toBeTruthy() + expect(within(band).queryByText("Not met")).toBeNull() + }) + + it("shows a condition's detail in place of Met when it has one", () => { + renderRail([deployAction]) + expand("Deploy to custodian") + + const custodian = screen.getByText("Custodian set").closest("li")! + expect(within(custodian).getByText("CCUS...7890")).toBeTruthy() + expect(within(custodian).queryByText("Met")).toBeNull() + }) + + it("offers to fill the field with its max, reporting the exact figure", () => { + const onInput = vi.fn() + renderRail([deployAction], { onInput }) + expand("Deploy to custodian") + + fireEvent.click(screen.getByRole("button", { name: "Use max" })) + + expect(onInput).toHaveBeenCalledWith(deployAction, "900.0000001") + }) + + it("offers no max for a field that has none", () => { + renderRail([attestAction]) + expand("Attest price") + + expect(screen.queryByRole("button", { name: "Use max" })).toBeNull() + }) + + it("states an action's note outside the conditions", () => { + renderRail([ + { ...attestAction, note: "Ripcord raised — attesting does not lift it." }, + ]) + expand("Attest price") + + const list = screen.getByRole("list", { name: "Attest price conditions" }) + expect( + screen.getByText("Ripcord raised — attesting does not lift it."), + ).toBeTruthy() + expect(within(list).queryByText(/Ripcord/)).toBeNull() + }) +}) diff --git a/app/src/components/admin/CycleActionRail.tsx b/app/src/components/admin/CycleActionRail.tsx new file mode 100644 index 0000000..8093336 --- /dev/null +++ b/app/src/components/admin/CycleActionRail.tsx @@ -0,0 +1,194 @@ +import React, { useState } from "react" +import { + type ActionField as ActionFieldSpec, + type Condition, + type CycleAction, + type CycleActionId, + type Readiness, + readiness, + readyCount, +} from "../../pages/cycle" +import typeStyles from "../../styles/type.module.css" +import Skeleton from "../Skeleton" +import styles from "./CycleActionRail.module.css" + +type OnInput = (action: CycleAction, value: string) => void + +const conditionValue = (condition: Condition): string => { + if (condition.met === null) return "—" + return condition.detail ?? (condition.met ? "Met" : "Not met") +} + +const summaryOf = (state: Readiness): string => { + if (state.kind === "ready") return "Ready" + if (state.kind === "unavailable") return "Unavailable" + return `${state.count} unmet` +} + +const ActionField: React.FC<{ + action: CycleAction + field: ActionFieldSpec + onInput: OnInput | undefined +}> = ({ action, field, onInput }) => { + const id = `cycle-${action.id}-field` + return ( +
+ + onInput?.(action, event.target.value)} + placeholder={field.placeholder} + inputMode="decimal" + /> + {field.max !== undefined && ( + + )} +
+ ) +} + +const ActionBody: React.FC<{ + action: CycleAction + onRun: (action: CycleAction) => void + onInput: OnInput | undefined +}> = ({ action, onRun, onInput }) => ( + <> + {action.field !== undefined && ( + + )} +
    + {action.conditions.map((condition) => ( +
  • + + {condition.label} + + + {conditionValue(condition)} + +
  • + ))} +
+ {action.note !== undefined && ( +

{action.note}

+ )} + {action.outcome !== null && ( +

+ {action.outcome} +

+ )} + + {action.unavailable !== undefined && ( +

+ {action.unavailable} +

+ )} + +) + +const CycleActionRail: React.FC<{ + actions: CycleAction[] + pending: boolean + unreadable: boolean + onRun: (action: CycleAction) => void + onInput?: OnInput +}> = ({ actions, pending, unreadable, onRun, onInput }) => { + const [open, setOpen] = useState() + if (open === undefined && actions.length > 0) + setOpen(actions.find((action) => action.enabled)?.id ?? null) + + return ( +
+
+

+ Actions +

+ {actions.length > 0 && ( + + {readyCount(actions)} of {actions.length} ready + + )} +
+ {actions.length === 0 && + (pending ? ( + + ) : ( +

+ {unreadable + ? "Could not read the cycle state." + : "No actions available."} +

+ ))} + {actions.length > 0 && ( +
    + {actions.map((action) => { + const state = readiness(action) + const expanded = open === action.id + const id = `cycle-${action.id}-action` + return ( +
  • + + +
  • + ) + })} +
+ )} +
+ ) +} + +export default CycleActionRail diff --git a/app/src/components/admin/CycleSection.test.tsx b/app/src/components/admin/CycleSection.test.tsx index 29dffe1..1621a09 100644 --- a/app/src/components/admin/CycleSection.test.tsx +++ b/app/src/components/admin/CycleSection.test.tsx @@ -187,8 +187,35 @@ const manager = grant("vault manager") const treasury = grant("vault treasury") const attester = grant("oracle attester") -const button = (name: string) => - screen.getByRole("button", { name }) as HTMLButtonElement +const rowToggle = (label: string) => + screen + .getAllByRole("button", { name: label }) + .find((candidate) => candidate.hasAttribute("aria-expanded"))! + +const openRow = (label: string) => { + const toggle = rowToggle(label) + if (toggle.getAttribute("aria-expanded") !== "true") fireEvent.click(toggle) +} + +const button = (name: string) => { + openRow(name) + return screen + .getAllByRole("button", { name }) + .find( + (candidate) => !candidate.hasAttribute("aria-expanded"), + ) as HTMLButtonElement +} + +const FIELD_ROWS: Record = { + "Amount to deploy": "Deploy to custodian", + "Amount to fund": "Fund the reserve", + "Share price": "Attest price", +} + +const fieldOf = (name: string) => { + openRow(FIELD_ROWS[name]!) + return screen.getByRole("textbox", { name }) as HTMLInputElement +} describe("CycleSection", () => { beforeEach(() => { @@ -229,38 +256,28 @@ describe("CycleSection", () => { render() expect(button("Close epoch").disabled).toBe(false) - expect(button("Fulfill epoch").disabled).toBe(false) const closeConditions = screen.getByRole("list", { name: "Close epoch conditions", }) expect(within(closeConditions).getByText("Epoch open")).toBeTruthy() + expect(button("Fulfill epoch").disabled).toBe(false) expect(screen.getByText("Seals epoch 5 and opens epoch 6.")).toBeTruthy() expect( screen.getByText("Prices epoch 4 at 1.0000 and settles its requests."), ).toBeTruthy() }) - it("offers the treasury the settlement and both reserve actions, under the Reserve group", () => { + it("offers the treasury the settlement and both reserve actions, in the Actions rail", () => { render() expect(screen.queryByRole("button", { name: "Close epoch" })).toBeNull() expect(screen.queryByRole("button", { name: "Attest price" })).toBeNull() expect(button("Fulfill epoch").disabled).toBe(false) - const reserveGroup = screen.getByRole("heading", { - name: "Reserve", - }).parentElement! - expect( - within(reserveGroup).getByRole("button", { name: "Deploy to custodian" }), - ).toBeTruthy() - expect( - within(reserveGroup).getByRole("button", { name: "Fund the reserve" }), - ).toBeTruthy() - expect( - within(reserveGroup).getByRole("textbox", { name: "Amount to deploy" }), - ).toBeTruthy() - expect( - within(reserveGroup).getByRole("textbox", { name: "Amount to fund" }), - ).toBeTruthy() + const rail = screen.getByRole("complementary", { name: "Actions" }) + expect(rail.contains(fieldOf("Amount to deploy"))).toBe(true) + expect(rail.contains(button("Deploy to custodian"))).toBe(true) + expect(rail.contains(fieldOf("Amount to fund"))).toBe(true) + expect(rail.contains(button("Fund the reserve"))).toBe(true) }) it("offers the manager the funding but not the deployment", () => { @@ -271,7 +288,7 @@ describe("CycleSection", () => { ).toBeNull() expect(screen.queryByRole("button", { name: "Use max" })).toBeNull() expect(button("Fund the reserve").disabled).toBe(true) - expect(screen.getByRole("textbox", { name: "Amount to fund" })).toBeTruthy() + expect(fieldOf("Amount to fund")).toBeTruthy() }) it("holds the deployment until an amount is typed, then enables it and states the resulting reserve", () => { @@ -281,12 +298,9 @@ describe("CycleSection", () => { const within_ = screen.getByText("Within the free reserve").closest("li")! expect(within(within_).getByText("—")).toBeTruthy() - fireEvent.change( - screen.getByRole("textbox", { name: "Amount to deploy" }), - { - target: { value: "250" }, - }, - ) + fireEvent.change(fieldOf("Amount to deploy"), { + target: { value: "250" }, + }) expect(button("Deploy to custodian").disabled).toBe(false) expect(within(within_).getByText("Met")).toBeTruthy() @@ -300,11 +314,9 @@ describe("CycleSection", () => { it("fills the deployment with the exact free reserve on Use max", () => { render() - fireEvent.click(button("Use max")) + const field = fieldOf("Amount to deploy") + fireEvent.click(screen.getByRole("button", { name: "Use max" })) - const field = screen.getByRole("textbox", { - name: "Amount to deploy", - }) as HTMLInputElement expect(field.value).toBe("900.00") expect(button("Deploy to custodian").disabled).toBe(false) expect( @@ -319,12 +331,9 @@ describe("CycleSection", () => { , ) - fireEvent.change( - screen.getByRole("textbox", { name: "Amount to deploy" }), - { - target: { value: "250" }, - }, - ) + fireEvent.change(fieldOf("Amount to deploy"), { + target: { value: "250" }, + }) fireEvent.click(button("Deploy to custodian")) expect(deployMock.submit).toHaveBeenCalledWith(amount(250n)) @@ -337,15 +346,41 @@ describe("CycleSection", () => { ).toBeTruthy() }) + it("shows the flow modal only while the surface is active, and again on return", () => { + deployMock.status = { status: "preparing" } + const { rerender } = render( + , + ) + expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1) + + rerender( + , + ) + expect(screen.queryAllByRole("dialog", { hidden: true })).toHaveLength(0) + expect(deployMock.reset).not.toHaveBeenCalled() + + rerender() + expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1) + }) + + it("pauses the cycle polling while the surface is inactive", () => { + const { rerender } = render( + , + ) + expect(useCycleStateMock).toHaveBeenLastCalledWith({ active: false }) + + rerender() + expect(useCycleStateMock).toHaveBeenLastCalledWith({ active: true }) + }) + it("labels the deployment modal with the exact amount that is signed", () => { const { rerender } = render( , ) - fireEvent.change( - screen.getByRole("textbox", { name: "Amount to deploy" }), - { target: { value: "250.1234567" } }, - ) + fireEvent.change(fieldOf("Amount to deploy"), { + target: { value: "250.1234567" }, + }) fireEvent.click(button("Deploy to custodian")) deployMock.status = { status: "preparing" } rerender() @@ -359,10 +394,7 @@ describe("CycleSection", () => { const { rerender } = render( , ) - const field = () => - screen.getByRole("textbox", { - name: "Amount to deploy", - }) as HTMLInputElement + const field = () => fieldOf("Amount to deploy") fireEvent.change(field(), { target: { value: "250" } }) fireEvent.click(button("Deploy to custodian")) @@ -378,7 +410,7 @@ describe("CycleSection", () => { , ) - fireEvent.change(screen.getByRole("textbox", { name: "Amount to fund" }), { + fireEvent.change(fieldOf("Amount to fund"), { target: { value: "100.1234567" }, }) fireEvent.click(button("Fund the reserve")) @@ -392,7 +424,7 @@ describe("CycleSection", () => { it("judges the funding against the connected wallet's balance", () => { render() - const field = screen.getByRole("textbox", { name: "Amount to fund" }) + const field = fieldOf("Amount to fund") fireEvent.change(field, { target: { value: "1000.0000001" } }) @@ -416,7 +448,7 @@ describe("CycleSection", () => { }) render() - fireEvent.change(screen.getByRole("textbox", { name: "Amount to fund" }), { + fireEvent.change(fieldOf("Amount to fund"), { target: { value: "100" }, }) @@ -430,7 +462,7 @@ describe("CycleSection", () => { , ) - fireEvent.change(screen.getByRole("textbox", { name: "Amount to fund" }), { + fireEvent.change(fieldOf("Amount to fund"), { target: { value: "100" }, }) fireEvent.click(button("Fund the reserve")) @@ -455,12 +487,9 @@ describe("CycleSection", () => { />, ) - fireEvent.change( - screen.getByRole("textbox", { name: "Amount to deploy" }), - { - target: { value: "250" }, - }, - ) + fireEvent.change(fieldOf("Amount to deploy"), { + target: { value: "250" }, + }) expect(button("Deploy to custodian").disabled).toBe(true) expect( @@ -469,21 +498,21 @@ describe("CycleSection", () => { ), ).toBeTruthy() - fireEvent.change(screen.getByRole("textbox", { name: "Amount to fund" }), { + fireEvent.change(fieldOf("Amount to fund"), { target: { value: "100" }, }) expect(button("Fund the reserve").disabled).toBe(false) }) - it("offers the attester a price field under the Price group, with the button held until a price is typed", () => { + it("offers the attester a price field in the Actions rail, with the button held until a price is typed", () => { render() - const field = screen.getByRole("textbox", { name: "Share price" }) - const priceGroup = screen.getByRole("heading", { - name: "Price", - }).parentElement! - expect(within(priceGroup).getByRole("textbox")).toBe(field) + const field = fieldOf("Share price") + const rail = screen.getByRole("complementary", { name: "Actions" }) + expect(within(rail).getByRole("textbox", { name: "Share price" })).toBe( + field, + ) expect(button("Attest price").disabled).toBe(true) const band = screen.getByText("Within the band").closest("li")! expect(within(band).getByText("—")).toBeTruthy() @@ -502,7 +531,7 @@ describe("CycleSection", () => { , ) - fireEvent.change(screen.getByRole("textbox", { name: "Share price" }), { + fireEvent.change(fieldOf("Share price"), { target: { value: "1.04" }, }) fireEvent.click(button("Attest price")) @@ -525,7 +554,7 @@ describe("CycleSection", () => { , ) - fireEvent.change(screen.getByRole("textbox", { name: "Share price" }), { + fireEvent.change(fieldOf("Share price"), { target: { value: "1.04123456" }, }) fireEvent.click(button("Attest price")) @@ -541,8 +570,7 @@ describe("CycleSection", () => { const { rerender } = render( , ) - const field = () => - screen.getByRole("textbox", { name: "Share price" }) as HTMLInputElement + const field = () => fieldOf("Share price") fireEvent.change(field(), { target: { value: "1.04" } }) fireEvent.click(button("Attest price")) @@ -561,8 +589,7 @@ describe("CycleSection", () => { const { rerender } = render( , ) - const field = () => - screen.getByRole("textbox", { name: "Share price" }) as HTMLInputElement + const field = () => fieldOf("Share price") fireEvent.change(field(), { target: { value: "1.04" } }) fireEvent.click(button("Attest price")) @@ -677,6 +704,52 @@ describe("CycleSection", () => { expect(fulfillEpochMock.reset).toHaveBeenCalledTimes(1) }) + it("lists the role's actions in the rail with their readiness, the first ready one open", () => { + render() + + const rail = screen.getByRole("complementary", { name: "Actions" }) + expect(within(rail).getByText("2 of 3 ready")).toBeTruthy() + expect(rowToggle("Close epoch").getAttribute("aria-expanded")).toBe("true") + expect(rowToggle("Fulfill epoch").getAttribute("aria-expanded")).toBe( + "false", + ) + expect(rowToggle("Fund the reserve").getAttribute("aria-expanded")).toBe( + "false", + ) + }) + + it("keeps the overview free of actions and the static limits behind a disclosure", () => { + render() + + const rail = screen.getByRole("complementary", { name: "Actions" }) + const overviewButtons = screen + .getAllByRole("button") + .filter((candidate) => !rail.contains(candidate)) + expect(overviewButtons).toEqual([]) + const limits = screen + .getAllByText("Limits") + .map((summary) => summary.closest("details")!) + expect(limits.every((disclosure) => !disclosure.open)).toBe(true) + expect(within(limits[0]!).getByText("Freshness window")).toBeTruthy() + expect(within(limits[1]!).getByText("Deposit cap")).toBeTruthy() + }) + + it("keeps the row open after its action confirms", () => { + const { rerender } = render( + , + ) + fireEvent.click(button("Close epoch")) + + closeEpochMock.status = { + status: "confirmed", + sealedEpoch: 5n, + hash: "abc", + } + rerender() + + expect(rowToggle("Close epoch").getAttribute("aria-expanded")).toBe("true") + }) + it("offers no wind-down action while none is proposed", () => { render() @@ -686,7 +759,7 @@ describe("CycleSection", () => { expect(screen.queryByRole("button", { name: "Finalize round" })).toBeNull() }) - it("offers every cycle role the activation under the Wind-down group once proposed, and follows it in the modal", () => { + it("offers every cycle role the activation in the Actions rail once proposed, and follows it in the modal", () => { const proposed = ready({ windDown: { ...windDown, phase: "proposed", activeAt: 1_700_000_000n }, }) @@ -694,13 +767,10 @@ describe("CycleSection", () => { const { rerender } = render( , ) - const windDownGroup = screen.getByRole("heading", { - name: "Wind-down", - }).parentElement! - const activate = within(windDownGroup).getByRole("button", { - name: "Activate wind-down", - }) as HTMLButtonElement + const rail = screen.getByRole("complementary", { name: "Actions" }) + const activate = button("Activate wind-down") + expect(rail.contains(activate)).toBe(true) expect(activate.disabled).toBe(false) expect( screen.getByText("Stops new requests and opens distribution rounds."), diff --git a/app/src/components/admin/CycleSection.tsx b/app/src/components/admin/CycleSection.tsx index 3bd0f5a..a8e93ea 100644 --- a/app/src/components/admin/CycleSection.tsx +++ b/app/src/components/admin/CycleSection.tsx @@ -24,7 +24,8 @@ import { import TransactionModal, { type TransactionModalProps, } from "../vault/TransactionModal" -import CycleSurface, { CycleActions, type CycleGroup } from "./CycleSurface" +import CycleActionRail from "./CycleActionRail" +import CycleSurface from "./CycleSurface" const useAmountInput = (confirmed: boolean) => { const [input, setInput] = useState("") @@ -34,11 +35,13 @@ const useAmountInput = (confirmed: boolean) => { return [input, setInput] as const } -const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ - grants, - wallet, -}) => { - const { cycle } = useCycleState() +const CycleSection: React.FC<{ + grants: Grant[] + wallet: string + contracts?: React.ReactNode + active?: boolean +}> = ({ grants, wallet, contracts, active = true }) => { + const { cycle } = useCycleState({ active }) const { history } = useEpochHistory() const { cycleEvents } = useCycleEvents() const { balance } = useDepositBalance() @@ -118,15 +121,6 @@ const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ fund: fundInput, walletBalance: balance.status === "held" ? balance.amount : null, }) - const withActions = (group: CycleGroup): CycleGroup => { - const own = actions.filter((action) => action.group === group.title) - return own.length === 0 - ? group - : { - ...group, - actions: , - } - } const freshness = cycle.status === "ready" ? cycle.oracle.limits?.freshness : undefined @@ -187,12 +181,24 @@ const CycleSection: React.FC<{ grants: Grant[]; wallet: string }> = ({ return ( <> + } + contracts={contracts} /> - {activeModal !== undefined && } + {active && activeModal !== undefined && ( + + )} ) } diff --git a/app/src/components/admin/CycleSurface.module.css b/app/src/components/admin/CycleSurface.module.css index 65ee2f9..ac8a496 100644 --- a/app/src/components/admin/CycleSurface.module.css +++ b/app/src/components/admin/CycleSurface.module.css @@ -1,7 +1,49 @@ -.surface { +.layout { + container-type: inline-size; +} + +.body { + display: grid; + grid-template-columns: minmax(0, 2.2fr) minmax(320px, 1fr); + align-items: start; + gap: 18px 32px; +} + +.main { display: flex; flex-direction: column; gap: 22px; + min-width: 0; + grid-column: 1; + grid-row: 1; +} + +.side { + position: sticky; + top: 24px; + max-height: calc(100dvh - 24px - 24px); + overflow-y: auto; + min-width: 0; + grid-column: 2; + grid-row: 1; +} + +@container (max-width: 1080px) { + .body { + grid-template-columns: minmax(0, 1fr); + } + + .main, + .side { + grid-column: 1; + grid-row: auto; + } + + .side { + position: static; + max-height: none; + overflow-y: visible; + } } .group { @@ -56,82 +98,6 @@ margin: 0; } -.unmet { - color: var(--vault-text-muted); - text-align: right; - white-space: nowrap; -} - -.actionArea { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); - gap: 20px 24px; - margin-top: 9px; -} - -.action { - display: flex; - flex-direction: column; - gap: 11px; - padding: 16px; - border: 1px solid var(--vault-border-hairline); - background: var(--vault-surface-inset); -} - -.conditions { - display: flex; - flex-direction: column; - gap: 7px; - margin: 0; - padding: 0; - list-style: none; -} - -.conditions .row { - padding-bottom: 0; -} - -.outcome { - margin: 0; - color: var(--vault-text-body); -} - -.actions { - display: flex; - flex-wrap: wrap; - gap: 10px; -} - -.actionPrimary, -.actionUnavailable { - flex: 1; - min-height: 48px; - padding: 13px 18px; - font-family: var(--vault-font-sans); - font-size: 15.5px; - font-weight: 500; - border-radius: 0; -} - -.actionPrimary { - border: 1px solid var(--vault-action); - background: var(--vault-action); - color: var(--vault-surface-card); -} - -.actionPrimary:hover:not(:disabled) { - background: var(--vault-action-ring); - border-color: var(--vault-action); - color: var(--vault-action); -} - -.actionUnavailable { - border: 1px solid var(--vault-border-hairline); - background: var(--vault-surface-card); - color: var(--vault-text-muted); - cursor: not-allowed; -} - .rowSkeleton { height: 1em; width: 11ch; @@ -171,63 +137,3 @@ color: var(--vault-text-muted); white-space: normal; } - -.field, -.fieldOver { - display: flex; - align-items: center; - gap: 10px; - min-height: 58px; - padding: 8px 16px; - border: 1px solid var(--vault-action); - background: var(--vault-surface-card); -} - -.fieldOver { - border: 2px solid var(--vault-danger); -} - -.fieldInput { - flex: 1; - min-width: 0; - border: 0; - outline: none; - background: transparent; - color: var(--vault-ink); -} - -.fieldInput::placeholder { - color: var(--vault-text-muted); -} - -.useMax { - flex: none; - min-height: 40px; - padding: 9px 11px; - border: 1px solid var(--vault-border-hairline); - border-radius: 0; - background: var(--vault-surface-card); - color: var(--vault-text-body); - font-family: var(--vault-font-mono); - font-weight: 500; - font-size: 12px; -} - -.useMax:hover { - background: var(--vault-action-ring); - border-color: var(--vault-action); - color: var(--vault-action); -} - -.note { - margin: 0; - color: var(--vault-text-muted); -} - -.srOnly { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0 0 0 0); -} diff --git a/app/src/components/admin/CycleSurface.test.tsx b/app/src/components/admin/CycleSurface.test.tsx index 25a1e7c..d87fe5e 100644 --- a/app/src/components/admin/CycleSurface.test.tsx +++ b/app/src/components/admin/CycleSurface.test.tsx @@ -1,9 +1,8 @@ -import { fireEvent, render, screen, within } from "@testing-library/react" +import { render, screen, within } from "@testing-library/react" +import type React from "react" import { describe, expect, it, vi } from "vitest" -import { type CycleAction } from "../../pages/cycle" import CycleSurface, { type ActivityRow, - CycleActions, type CycleGroup, type EpochRow, type ListState, @@ -30,7 +29,8 @@ const groups: CycleGroup[] = [ { title: "Reserve", tiles: [{ label: "Uncovered", value: "0.00", note: "Covered" }], - rows: [{ label: "Deposit cap", value: "No cap" }], + rows: [{ label: "Liquid reserve", value: "800.00" }], + limits: [{ label: "Deposit cap", value: "No cap" }], addresses: [{ label: "Custodian", address: "CCUSTODIAN1234567890" }], }, ] @@ -77,6 +77,7 @@ const renderSurface = ( unreadable?: boolean epochs?: ListState activity?: ListState + contracts?: React.ReactNode } = {}, ) => render( @@ -85,6 +86,8 @@ const renderSurface = ( unreadable={overrides.unreadable ?? false} epochs={overrides.epochs ?? { status: "loaded", rows: epochRows }} activity={overrides.activity ?? { status: "loaded", rows: activityRows }} + rail={} + contracts={overrides.contracts} />, ) @@ -115,8 +118,8 @@ describe("CycleSurface", () => { expect(within(epochRowsList!).getByText("1h")).toBeTruthy() const row = within(epochRowsList!).getByText("Sealed at").closest("li") expect(within(row!).getByText("Unavailable")).toBeTruthy() - expect(within(reserveRowsList!).getByText("Deposit cap")).toBeTruthy() - expect(within(reserveRowsList!).getByText("No cap")).toBeTruthy() + expect(within(reserveRowsList!).getByText("Liquid reserve")).toBeTruthy() + expect(within(reserveRowsList!).queryByText("Deposit cap")).toBeNull() expect(within(epochRowsList!).queryByText("Open epoch")).toBeNull() }) @@ -250,279 +253,69 @@ describe("CycleSurface", () => { expect(screen.queryByRole("table", { name: "Recent activity" })).toBeNull() }) - it("renders a group's action area after its rows", () => { - renderSurface({ - groups: [ - { ...groups[0]!, actions: }, - ], - }) + it("keeps a group's static limits in a closed Limits disclosure after its rows", () => { + renderSurface() - const group = screen.getByText("Notice period").closest("li")! - .parentElement!.parentElement! - const button = within(group).getByRole("button", { name: "Close epoch" }) - expect( - group.compareDocumentPosition(button) & - Node.DOCUMENT_POSITION_CONTAINED_BY, - ).toBeTruthy() + const disclosure = screen.getByText("Limits").closest("details")! + expect(disclosure.open).toBe(false) + expect(within(disclosure).getByText("Deposit cap")).toBeTruthy() + expect(within(disclosure).getByText("No cap")).toBeTruthy() expect( - screen.getByText("Notice period").compareDocumentPosition(button) & + screen.getByText("Liquid reserve").compareDocumentPosition(disclosure) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy() }) - it("heads each group and section with a sentence-case title below the card title", () => { - renderSurface() + it("adds no Limits disclosure to a group without limits", () => { + renderSurface({ groups: [groups[0]!] }) - expect( - screen - .getAllByRole("heading", { level: 3 }) - .map((heading) => heading.textContent), - ).toEqual(["Epoch", "Reserve", "Epochs", "Recent activity — last 7 days"]) + expect(screen.queryByText("Limits")).toBeNull() }) -}) - -const closeAction: CycleAction = { - id: "close-epoch", - group: "Epoch", - label: "Close epoch", - conditions: [ - { label: "Wind-down not active", met: true }, - { label: "Epoch open", met: true }, - ], - outcome: "Seals epoch 5 and opens epoch 6.", - epochId: 5n, - enabled: true, -} - -const fulfillAction: CycleAction = { - id: "fulfill-epoch", - group: "Epoch", - label: "Fulfill epoch", - conditions: [ - { label: "Sealed epoch awaiting a price", met: true }, - { label: "Notice elapsed", met: false }, - ], - outcome: "Prices epoch 4 at 1.0000 and settles its requests.", - epochId: 4n, - enabled: false, -} - -const attestAction: CycleAction = { - id: "attest", - group: "Price", - label: "Attest price", - conditions: [ - { label: "Within the band", met: null }, - { label: "Cooldown elapsed", met: true }, - ], - outcome: null, - field: { - label: "Share price", - placeholder: "0.0000", - value: "", - invalid: false, - }, - price: null, - freshness: null, - enabled: false, -} - -const deployAction: CycleAction = { - id: "deploy", - group: "Reserve", - label: "Deploy to custodian", - conditions: [ - { label: "Custodian set", met: true, detail: "CCUS...7890" }, - { label: "Within the free reserve", met: null }, - ], - outcome: null, - field: { - label: "Amount to deploy", - placeholder: "0.00", - value: "", - invalid: false, - max: "900.0000001", - }, - amount: null, - enabled: false, -} -describe("CycleActions", () => { - it("lists each condition as met or not met, in the card's label and figure typography", () => { - render() + it("acts only from the Actions aside, the overview holding no action", () => { + renderSurface() - const list = screen.getByRole("list", { name: "Fulfill epoch conditions" }) - const [awaiting, notice] = within(list).getAllByRole("listitem") + const rail = screen.getByRole("complementary", { name: "Actions" }) expect( - within(awaiting!).getByText("Sealed epoch awaiting a price"), + within(rail).getByRole("button", { name: "Close epoch" }), ).toBeTruthy() - expect(within(awaiting!).getByText("Met")).toBeTruthy() - expect(within(notice!).getByText("Notice elapsed")).toBeTruthy() - expect(within(notice!).getByText("Not met")).toBeTruthy() + expect(screen.getAllByRole("button")).toHaveLength(1) }) - it("states the resulting state before the button and enables the button only when the action is", () => { - const onRun = vi.fn() - render( - , - ) - - expect(screen.getByText("Seals epoch 5 and opens epoch 6.")).toBeTruthy() - const close = screen.getByRole("button", { name: "Close epoch" }) - const fulfill = screen.getByRole("button", { name: "Fulfill epoch" }) - expect((close as HTMLButtonElement).disabled).toBe(false) - expect((fulfill as HTMLButtonElement).disabled).toBe(true) - - fireEvent.click(close) - fireEvent.click(fulfill) + it("puts the Actions aside before the overview in reading order", () => { + renderSurface() - expect(onRun).toHaveBeenCalledTimes(1) - expect(onRun).toHaveBeenCalledWith(closeAction) + const rail = screen.getByRole("complementary", { name: "Actions" }) + expect( + rail.compareDocumentPosition( + screen.getByRole("heading", { name: "Epoch" }), + ) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() }) - it("says why an action is unavailable and keeps its button disabled", () => { - render( - , - ) + it("closes the overview with the contracts it is given", () => { + renderSurface({ contracts:

Contract addresses

}) + const contracts = screen.getByText("Contract addresses") expect( - screen.getByText( - "Needs 2 signatures; collecting them is not supported yet.", - ), + screen + .getByRole("table", { name: "Recent activity" }) + .compareDocumentPosition(contracts) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy() expect( - (screen.getByRole("button", { name: "Close epoch" }) as HTMLButtonElement) - .disabled, - ).toBe(true) - }) - - it("omits the resulting state when there is none to show", () => { - render( - , - ) - - expect(screen.queryByText(/Prices epoch/)).toBeNull() - expect(screen.getByRole("button", { name: "Fulfill epoch" })).toBeTruthy() - }) - - it("asks for a figure in a decimal field, labelled for screen readers, and reports each keystroke", () => { - const onInput = vi.fn() - render( - , - ) - - const field = screen.getByRole("textbox", { - name: "Share price", - }) as HTMLInputElement - expect(field.placeholder).toBe("0.0000") - expect(field.inputMode).toBe("decimal") - - fireEvent.change(field, { target: { value: "1.04" } }) - - expect(onInput).toHaveBeenCalledWith(attestAction, "1.04") - }) - - it("renders no field for an action that takes no figure", () => { - render() - - expect(screen.queryByRole("textbox")).toBeNull() - }) - - it("leaves a condition open with a dash while it cannot be judged yet", () => { - render() - - const band = screen.getByText("Within the band").closest("li")! - expect(within(band).getByText("—")).toBeTruthy() - expect(within(band).queryByText("Not met")).toBeNull() - }) - - it("shows a condition's detail in place of Not met when it has one", () => { - render( - , - ) - - const band = screen.getByText("Within the band").closest("li")! - expect(within(band).getByText("0.5000 – 2.0000")).toBeTruthy() - expect(within(band).queryByText("Not met")).toBeNull() - }) - - it("shows a condition's detail in place of Met when it has one", () => { - render() - - const custodian = screen.getByText("Custodian set").closest("li")! - expect(within(custodian).getByText("CCUS...7890")).toBeTruthy() - expect(within(custodian).queryByText("Met")).toBeNull() + within( + screen.getByRole("complementary", { name: "Actions" }), + ).queryByText("Contract addresses"), + ).toBeNull() }) - it("offers to fill the field with its max, reporting the exact figure", () => { - const onInput = vi.fn() - render( - , - ) - - fireEvent.click(screen.getByRole("button", { name: "Use max" })) - - expect(onInput).toHaveBeenCalledWith(deployAction, "900.0000001") - }) - - it("offers no max for a field that has none", () => { - render() - - expect(screen.queryByRole("button", { name: "Use max" })).toBeNull() - }) + it("heads each group and section with a sentence-case title below the card title", () => { + renderSurface() - it("states an action's note outside the conditions", () => { - render( - , - ) - - const list = screen.getByRole("list", { name: "Attest price conditions" }) expect( - screen.getByText("Ripcord raised — attesting does not lift it."), - ).toBeTruthy() - expect(within(list).queryByText(/Ripcord/)).toBeNull() + screen + .getAllByRole("heading", { level: 3 }) + .map((heading) => heading.textContent), + ).toEqual(["Epoch", "Reserve", "Epochs", "Recent activity — last 7 days"]) }) }) diff --git a/app/src/components/admin/CycleSurface.tsx b/app/src/components/admin/CycleSurface.tsx index 169a899..b7bd6f9 100644 --- a/app/src/components/admin/CycleSurface.tsx +++ b/app/src/components/admin/CycleSurface.tsx @@ -1,20 +1,20 @@ import React from "react" -import { - type ActionField as ActionFieldSpec, - type Condition, - type CycleAction, -} from "../../pages/cycle" import typeStyles from "../../styles/type.module.css" import Skeleton from "../Skeleton" -import { type AddressRow, type FigureGroup } from "../vault/AboutVault" +import { + type AddressRow, + type FigureGroup, + type FigureRow, +} from "../vault/AboutVault" import AddressRows from "../vault/AddressRows" import MetricsStrip, { type Metric } from "../vault/MetricsStrip" import styles from "./CycleSurface.module.css" +import Disclosure from "./Disclosure" export type CycleGroup = FigureGroup & { tiles: Metric[] + limits?: FigureRow[] addresses?: AddressRow[] - actions?: React.ReactNode } export type EpochRow = { @@ -44,6 +44,8 @@ type CycleSurfaceProps = { unreadable: boolean epochs: ListState activity: ListState + rail: React.ReactNode + contracts?: React.ReactNode } const EPOCH_COLUMNS = [ @@ -58,133 +60,42 @@ const ACTIVITY_COLUMNS = ["Time", "Event", "Detail"] const figureCell = `${typeStyles.railValue} ${styles.cell}` +const FigureRows: React.FC<{ rows: FigureRow[] }> = ({ rows }) => ( +
    + {rows.map((row) => ( +
  • + + {row.label} + + {row.pending ? ( + + ) : ( + + {row.value ?? "Unavailable"} + + )} +
  • + ))} +
+) + const Group: React.FC<{ group: CycleGroup }> = ({ group }) => (

{group.title}

-
    - {group.rows.map((row) => ( -
  • - - {row.label} - - {row.pending ? ( - - ) : ( - - {row.value ?? "Unavailable"} - - )} -
  • - ))} -
+ {group.addresses !== undefined && } - {group.actions} -
-) - -const conditionValue = (condition: Condition): string => { - if (condition.met === null) return "—" - return condition.detail ?? (condition.met ? "Met" : "Not met") -} - -const ActionField: React.FC<{ - action: CycleAction - field: ActionFieldSpec - onInput: ((action: CycleAction, value: string) => void) | undefined -}> = ({ action, field, onInput }) => { - const id = `cycle-${action.id}-field` - return ( -
- - onInput?.(action, event.target.value)} - placeholder={field.placeholder} - inputMode="decimal" - /> - {field.max !== undefined && ( - - )} -
- ) -} - -export const CycleActions: React.FC<{ - actions: CycleAction[] - onRun: (action: CycleAction) => void - onInput?: (action: CycleAction, value: string) => void -}> = ({ actions, onRun, onInput }) => ( -
- {actions.map((action) => ( -
- {action.field !== undefined && ( - - )} -
    - {action.conditions.map((condition) => ( -
  • - - {condition.label} - - - {conditionValue(condition)} - -
  • - ))} -
- {action.note !== undefined && ( -

- {action.note} -

- )} - {action.outcome !== null && ( -

- {action.outcome} -

- )} -
- -
- {action.unavailable !== undefined && ( -

- {action.unavailable} -

- )} -
- ))} + {group.limits !== undefined && ( + + + + )}
) @@ -291,29 +202,41 @@ const CycleSurface: React.FC = ({ unreadable, epochs, activity, + rail, + contracts, }) => ( -
- {unreadable ? ( -

Could not read the vault's cycle.

- ) : ( - groups.map((group) => ) - )} -
- {epochs.status === "loaded" && } -
-
- {activity.status === "loaded" && } -
+
+
+ +
+ {unreadable ? ( +

Could not read the vault's cycle.

+ ) : ( + groups.map((group) => ) + )} +
+ {epochs.status === "loaded" && } +
+
+ {activity.status === "loaded" && ( + + )} +
+ {contracts} +
+
) diff --git a/app/src/components/admin/Disclosure.module.css b/app/src/components/admin/Disclosure.module.css new file mode 100644 index 0000000..9091adc --- /dev/null +++ b/app/src/components/admin/Disclosure.module.css @@ -0,0 +1,17 @@ +.disclosure { + border-top: 1px solid var(--vault-border-hairline); + padding-top: 11px; +} + +.title { + cursor: pointer; + color: var(--vault-text-body); +} + +.title:hover { + color: var(--vault-ink); +} + +.body { + padding-top: 11px; +} diff --git a/app/src/components/admin/Disclosure.tsx b/app/src/components/admin/Disclosure.tsx new file mode 100644 index 0000000..833e1b8 --- /dev/null +++ b/app/src/components/admin/Disclosure.tsx @@ -0,0 +1,17 @@ +import type React from "react" +import typeStyles from "../../styles/type.module.css" +import styles from "./Disclosure.module.css" + +const Disclosure: React.FC<{ + title: string + children: React.ReactNode +}> = ({ title, children }) => ( +
+ + {title} + +
{children}
+
+) + +export default Disclosure diff --git a/app/src/components/admin/SurfaceTabs.module.css b/app/src/components/admin/SurfaceTabs.module.css new file mode 100644 index 0000000..dbceec3 --- /dev/null +++ b/app/src/components/admin/SurfaceTabs.module.css @@ -0,0 +1,33 @@ +.tabs { + display: flex; + gap: 10px; + margin-inline: -6px; + overflow-x: auto; + scrollbar-width: thin; + box-shadow: inset 0 -1px 0 var(--vault-border-hairline); +} + +.tab { + flex: none; + padding: 4px 6px 10px; + white-space: nowrap; + color: var(--vault-text-body); + text-decoration: none; + border-bottom: 2px solid transparent; +} + +.tab:hover { + color: var(--vault-ink); + border-bottom-color: var(--vault-border-inactive); +} + +.tab:focus-visible { + outline: 2px solid var(--vault-action); + outline-offset: -2px; +} + +.active, +.active:hover { + color: var(--vault-ink); + border-bottom-color: var(--vault-ink); +} diff --git a/app/src/components/admin/SurfaceTabs.test.tsx b/app/src/components/admin/SurfaceTabs.test.tsx new file mode 100644 index 0000000..b4a2aef --- /dev/null +++ b/app/src/components/admin/SurfaceTabs.test.tsx @@ -0,0 +1,41 @@ +import { render, screen, within } from "@testing-library/react" +import { MemoryRouter } from "react-router-dom" +import { describe, expect, it } from "vitest" +import SurfaceTabs from "./SurfaceTabs" + +const renderAt = (path: string) => + render( + + + , + ) + +describe("SurfaceTabs", () => { + it("links each given surface under the admin path, in the given order", () => { + renderAt("/admin/cycle") + + const nav = screen.getByRole("navigation", { name: "Admin surfaces" }) + const links = within(nav).getAllByRole("link") + expect(links.map((link) => link.textContent)).toEqual([ + "Cycle", + "Emergency", + ]) + expect(links.map((link) => link.getAttribute("href"))).toEqual([ + "/admin/cycle", + "/admin/emergency", + ]) + }) + + it("marks only the surface at the current path as the current page", () => { + renderAt("/admin/emergency") + + expect( + screen + .getByRole("link", { name: "Emergency" }) + .getAttribute("aria-current"), + ).toBe("page") + expect( + screen.getByRole("link", { name: "Cycle" }).getAttribute("aria-current"), + ).toBeNull() + }) +}) diff --git a/app/src/components/admin/SurfaceTabs.tsx b/app/src/components/admin/SurfaceTabs.tsx new file mode 100644 index 0000000..83605eb --- /dev/null +++ b/app/src/components/admin/SurfaceTabs.tsx @@ -0,0 +1,26 @@ +import React from "react" +import { NavLink } from "react-router-dom" +import { + type AdminSurface, + SURFACE_LABELS, +} from "../../hooks/useAdminAuthority" +import typeStyles from "../../styles/type.module.css" +import styles from "./SurfaceTabs.module.css" + +const SurfaceTabs: React.FC<{ surfaces: AdminSurface[] }> = ({ surfaces }) => ( + +) + +export default SurfaceTabs diff --git a/app/src/hooks/useCycleState.test.ts b/app/src/hooks/useCycleState.test.ts index 84336de..8490bed 100644 --- a/app/src/hooks/useCycleState.test.ts +++ b/app/src/hooks/useCycleState.test.ts @@ -363,6 +363,28 @@ describe("useCycleState", () => { } }) + it("stops refetching the cycle reads while inactive and resumes when active again", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + let active = false + const { result, rerender } = renderWithWallet(() => + useCycleState({ active }), + ) + await waitFor(() => expect(result.current.cycle.status).toBe("ready")) + const before = oracleMock.state.mock.calls.length + + await vi.advanceTimersByTimeAsync(31_000) + expect(oracleMock.state.mock.calls).toHaveLength(before) + + active = true + rerender() + await vi.advanceTimersByTimeAsync(16_000) + expect(oracleMock.state.mock.calls.length).toBeGreaterThan(before) + } finally { + vi.useRealTimers() + } + }) + it("reads no balance when no custodian is set", async () => { vaultMock.custodian.mockResolvedValue({ result: null }) diff --git a/app/src/hooks/useCycleState.ts b/app/src/hooks/useCycleState.ts index b88c2b7..677d51a 100644 --- a/app/src/hooks/useCycleState.ts +++ b/app/src/hooks/useCycleState.ts @@ -262,7 +262,9 @@ async function fetchCustodianBalance( return toAmount(balance) } -export function useCycleState(): { cycle: CycleState } { +export function useCycleState({ active = true }: { active?: boolean } = {}): { + cycle: CycleState +} { const { history } = useEpochHistory() const { figures, isPending: figuresPending } = useVaultFigures() const { pause } = useVaultPaused() @@ -271,7 +273,7 @@ export function useCycleState(): { cycle: CycleState } { queryKey: cycleReadsKey, queryFn: fetchCycleReads, staleTime: STALE_MS, - refetchInterval: REFRESH_MS, + refetchInterval: active ? REFRESH_MS : false, }) const custodian = authorities?.custodian ?? null const { data: custodianBalance, isPending: balancePending } = useQuery({ diff --git a/app/src/pages/AdminPanel.module.css b/app/src/pages/AdminPanel.module.css index 1354f08..36c99c0 100644 --- a/app/src/pages/AdminPanel.module.css +++ b/app/src/pages/AdminPanel.module.css @@ -5,49 +5,45 @@ padding: 32px 0; } -.cards { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 14px; - margin: 0; - padding: 0; - list-style: none; -} - -.card { +.pending { display: flex; flex-direction: column; - gap: 8px; - padding: 20px; - border: 1px solid var(--vault-ink); - background: var(--vault-surface-card); + gap: 14px; } -.cardWide { - grid-column: 1 / -1; +.pendingLine { + height: 1.2em; + width: 60%; } -.cardHeading { - color: var(--vault-ink); +.surfaces { + display: flex; + flex-direction: column; + gap: 18px; } -.cardNote { - color: var(--vault-text-muted); +.surface { + position: relative; + display: flex; + flex-direction: column; + gap: 14px; } -.cardSkeleton { - height: 1.2em; - width: 60%; +.srOnly { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + overflow: hidden; + white-space: nowrap; + clip: rect(0 0 0 0); + clip-path: inset(50%); } -.addresses { - display: flex; - flex-direction: column; - gap: 11px; - border-top: 1px solid var(--vault-border-hairline); - padding-top: 20px; +.srOnly:focus { + outline: none; } -.addressesTitle { - color: var(--vault-text-muted); +.surface[hidden] { + display: none; } diff --git a/app/src/pages/AdminPanel.test.tsx b/app/src/pages/AdminPanel.test.tsx index ad3ef9b..0ecd7a6 100644 --- a/app/src/pages/AdminPanel.test.tsx +++ b/app/src/pages/AdminPanel.test.tsx @@ -1,4 +1,12 @@ -import { render, screen, within } from "@testing-library/react" +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react" +import type React from "react" +import { MemoryRouter, Route, Routes } from "react-router-dom" import { beforeEach, describe, expect, it, vi } from "vitest" import type * as UseAdminAuthority from "../hooks/useAdminAuthority" import { type AdminAuthority, type Grant } from "../hooks/useAdminAuthority" @@ -17,8 +25,20 @@ vi.mock("../hooks/useAdminAuthority", async (importOriginal) => { vi.mock("../hooks/useWallet", () => ({ useWallet: useWalletMock })) vi.mock("../components/admin/CycleSection", () => ({ - default: ({ grants }: { grants: Grant[] }) => ( -
Cycle surface for {grants.map((grant) => grant.role).join(", ")}
+ default: ({ + grants, + contracts, + active, + }: { + grants: Grant[] + contracts: React.ReactNode + active: boolean + }) => ( +
+

Cycle surface for {grants.map((grant) => grant.role).join(", ")}

+ + {contracts} +
), })) @@ -50,14 +70,30 @@ const authority = (overrides: Partial): AdminAuthority => ({ ...overrides, }) +const routedPanel = (path: string) => ( + + + } /> + } /> + + +) + +const renderAt = (path: string) => render(routedPanel(path)) + +const currentTab = () => + screen + .getAllByRole("link") + .find((link) => link.getAttribute("aria-current") === "page")?.textContent + describe("AdminPanel", () => { beforeEach(() => { useWalletMock.mockReturnValue({ address: connectedAddress }) }) - it("tells a disconnected wallet to connect, with no cards and no identity line", () => { + it("tells a disconnected wallet to connect, with no tabs and no identity line", () => { useAdminAuthorityMock.mockReturnValue(authority({ status: "disconnected" })) - render() + renderAt("/admin") expect( screen.getByText("Connect a wallet to operate this vault."), @@ -68,7 +104,7 @@ describe("AdminPanel", () => { it("shows a pending skeleton while checking, with no identity line", () => { useAdminAuthorityMock.mockReturnValue(authority({ status: "checking" })) - render() + renderAt("/admin") expect(screen.getAllByRole("progressbar")).toHaveLength(2) expect(screen.queryByText(/GCON/)).toBeNull() @@ -76,7 +112,7 @@ describe("AdminPanel", () => { it("reports when the vault's authorities could not be read, with no identity line", () => { useAdminAuthorityMock.mockReturnValue(authority({ status: "unreadable" })) - render() + renderAt("/admin") expect( screen.getByText("Could not read the vault's authorities."), @@ -85,27 +121,58 @@ describe("AdminPanel", () => { }) it("tells an address with no role it holds no authority, listing just the shortened address", () => { - useAdminAuthorityMock.mockReturnValue( - authority({ - status: "ready", - addresses: { - oracle: "CORACLE", - shareToken: "CTOKEN", - identityVerifier: "CVERIFIER", - }, - }), - ) - render() + useAdminAuthorityMock.mockReturnValue(authority({ status: "ready" })) + renderAt("/admin") expect( screen.getByText("This address holds no authority on this vault."), ).toBeTruthy() - expect(screen.getByText("Addresses")).toBeTruthy() - expect(screen.getByText("Oracle")).toBeTruthy() expect(screen.getByText("GCON...7890")).toBeTruthy() + expect(screen.getByText("Contracts").closest("details")?.open).toBe(false) }) - it("renders a card per held surface, in Cycle · Compliance · Emergency · Configuration · Governance order", () => { + it.each(["/admin/cycle", "/admin/compliance"])( + "keeps the contract addresses in a closed Contracts disclosure on %s", + (path) => { + const manager = holderOf("vault manager") + const compliance = holderOf("share-token compliance") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["cycle", "compliance"]), + grantedBy: { + ...emptyGrantedBy, + cycle: [manager], + compliance: [compliance], + }, + grants: [manager, compliance], + addresses: { + oracle: "CORACLE", + shareToken: "CTOKEN", + identityVerifier: "CVERIFIER", + }, + }), + ) + renderAt(path) + + const disclosure = within(screen.getByRole("region")) + .getByText("Contracts") + .closest("details") + expect(disclosure?.open).toBe(false) + expect(within(disclosure!).getByText("Oracle")).toBeTruthy() + expect(within(disclosure!).getByText("Identity verifier")).toBeTruthy() + }, + ) + + it("keeps the page-level states free of surface tabs", () => { + useAdminAuthorityMock.mockReturnValue(authority({ status: "checking" })) + renderAt("/admin/cycle") + + expect(screen.queryByRole("navigation")).toBeNull() + expect(screen.queryAllByRole("heading", { level: 2 })).toHaveLength(0) + }) + + it("tabs only the held surfaces, in Cycle · Compliance · Emergency · Configuration · Governance order", () => { const manager = holderOf("vault manager") const guardian = holderOf("vault guardian") const oracleGuardian = holderOf("oracle guardian") @@ -123,17 +190,15 @@ describe("AdminPanel", () => { grants: [manager, guardian, oracleGuardian, governance], }), ) - render() + renderAt("/admin/emergency") - const headings = screen.getAllByRole("heading", { level: 2 }) - expect(headings.map((heading) => heading.textContent)).toEqual([ - "Cycle", - "Emergency", - "Governance", - ]) + const nav = screen.getByRole("navigation", { name: "Admin surfaces" }) + expect(nav.textContent).toBe("CycleEmergencyGovernance") + expect(currentTab()).toBe("Emergency") expect( - screen.getByText("Granted by vault guardian, oracle guardian"), - ).toBeTruthy() + screen.getAllByRole("heading", { level: 2 }).map((h) => h.textContent), + ).toEqual(["Emergency"]) + expect(screen.queryByText(/Granted by/)).toBeNull() expect( screen.queryByText("This address holds no authority on this vault."), ).toBeNull() @@ -144,7 +209,149 @@ describe("AdminPanel", () => { ).toBeTruthy() }) - it("names the signature standing on the identity line and on the cards", () => { + it("opens the first held surface at /admin", () => { + const guardian = holderOf("vault guardian") + const governance = holderOf("vault governance") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["governance", "emergency"]), + grantedBy: { + ...emptyGrantedBy, + emergency: [guardian], + governance: [governance], + }, + grants: [guardian, governance], + }), + ) + renderAt("/admin") + + expect(currentTab()).toBe("Emergency") + expect(screen.getByRole("heading", { level: 2 }).textContent).toBe( + "Emergency", + ) + }) + + it("redirects a surface the wallet does not hold to the first held one, showing nothing of it", () => { + const compliance = holderOf("share-token compliance") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["compliance"]), + grantedBy: { ...emptyGrantedBy, compliance: [compliance] }, + grants: [compliance], + }), + ) + renderAt("/admin/cycle") + + expect(currentTab()).toBe("Compliance") + expect(screen.queryByRole("heading", { name: "Cycle" })).toBeNull() + expect(screen.queryByText(/Cycle surface/)).toBeNull() + }) + + it("redirects an unknown surface to the first held one", () => { + const manager = holderOf("vault manager") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["cycle"]), + grantedBy: { ...emptyGrantedBy, cycle: [manager] }, + grants: [manager], + }), + ) + renderAt("/admin/treasury") + + expect(currentTab()).toBe("Cycle") + expect(screen.getByRole("heading", { level: 2 }).textContent).toBe("Cycle") + }) + + it("moves focus to the surface heading when the tab changes, not on first load", () => { + const manager = holderOf("vault manager") + const compliance = holderOf("share-token compliance") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["cycle", "compliance"]), + grantedBy: { + ...emptyGrantedBy, + cycle: [manager], + compliance: [compliance], + }, + grants: [manager, compliance], + }), + ) + renderAt("/admin") + + expect(document.activeElement).not.toBe( + screen.getByRole("heading", { name: "Cycle" }), + ) + + fireEvent.click(screen.getByRole("link", { name: "Compliance" })) + + expect(currentTab()).toBe("Compliance") + expect(document.activeElement).toBe( + screen.getByRole("heading", { name: "Compliance" }), + ) + }) + + it("leaves focus alone when losing a surface redirects to another", () => { + const manager = holderOf("vault manager") + const compliance = holderOf("share-token compliance") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["cycle", "compliance"]), + grantedBy: { + ...emptyGrantedBy, + cycle: [manager], + compliance: [compliance], + }, + grants: [manager, compliance], + }), + ) + const { rerender } = renderAt("/admin/cycle") + + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["compliance"]), + grantedBy: { ...emptyGrantedBy, compliance: [compliance] }, + grants: [compliance], + }), + ) + rerender(routedPanel("/admin/cycle")) + + expect(currentTab()).toBe("Compliance") + expect(document.activeElement).not.toBe( + screen.getByRole("heading", { name: "Compliance" }), + ) + }) + + it("marks the cycle surface inactive while another tab is shown", () => { + const manager = holderOf("vault manager") + const compliance = holderOf("share-token compliance") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["cycle", "compliance"]), + grantedBy: { + ...emptyGrantedBy, + cycle: [manager], + compliance: [compliance], + }, + grants: [manager, compliance], + }), + ) + + renderAt("/admin/cycle") + expect(screen.getByTestId("cycle-surface").dataset.active).toBe("true") + cleanup() + + renderAt("/admin/compliance") + expect(screen.getByTestId("cycle-surface").dataset.active).toBe("false") + }) + + it("names the signature standing of every grant on the identity line", () => { const governance: Grant = { role: "vault governance", authority: "GGOVERNANCEMULTISIG", @@ -173,27 +380,16 @@ describe("AdminPanel", () => { grants: [attester, compliance, governance], }), ) - render() + renderAt("/admin") expect( screen.getByText( "GCON...7890 · oracle attester (1 of 2 signatures) · share-token compliance (weight 1 of threshold 3) · vault governance (signs alone)", ), ).toBeTruthy() - expect( - screen.getByText("Granted by vault governance — signs alone"), - ).toBeTruthy() - expect( - screen.getByText("Granted by oracle attester — 1 of 2 signatures"), - ).toBeTruthy() - expect( - screen.getByText( - "Granted by share-token compliance — weight 1 of threshold 3", - ), - ).toBeTruthy() }) - it("hands the Cycle card every grant that opened it, so its actions follow the wallet's roles", () => { + it("hands the Cycle surface every grant that opened it, so its actions follow the wallet's roles", () => { const manager = holderOf("vault manager") const treasury = holderOf("vault treasury") useAdminAuthorityMock.mockReturnValue( @@ -204,7 +400,7 @@ describe("AdminPanel", () => { grants: [manager, treasury], }), ) - render() + renderAt("/admin/cycle") expect( screen.getByText("Cycle surface for vault manager, vault treasury"), @@ -221,9 +417,8 @@ describe("AdminPanel", () => { grants: [manager], }), ) - render() + renderAt("/admin") - expect(screen.getByText("Granted by vault manager")).toBeTruthy() expect(screen.getByText("GCON...7890 · vault manager")).toBeTruthy() expect(screen.queryByText("Signer lookup unavailable.")).toBeNull() }) @@ -242,7 +437,7 @@ describe("AdminPanel", () => { grants: [governance], }), ) - render() + renderAt("/admin") expect( screen.getByText("GCON...7890 · vault governance (1 of 2 signatures)"), @@ -253,12 +448,12 @@ describe("AdminPanel", () => { useAdminAuthorityMock.mockReturnValue( authority({ status: "ready", signersUnknown: true }), ) - render() + renderAt("/admin") expect(screen.getAllByText("Signer lookup unavailable.")).toHaveLength(1) }) - it("shows the cycle surface inside the Cycle card, and only there", () => { + it("shows the cycle surface only on the Cycle tab", () => { const manager = holderOf("vault manager") const compliance = holderOf("share-token compliance") useAdminAuthorityMock.mockReturnValue( @@ -273,30 +468,41 @@ describe("AdminPanel", () => { grants: [manager, compliance], }), ) - render() + renderAt("/admin/compliance") - const cards = screen.getAllByRole("listitem") - const cycleCard = cards.find( - (card) => within(card).queryByRole("heading", { name: "Cycle" }) !== null, - ) - expect( - within(cycleCard!).getByText("Cycle surface for vault manager"), - ).toBeTruthy() - expect(screen.getAllByText(/Cycle surface/)).toHaveLength(1) + expect(screen.queryByRole("region", { name: "Cycle" })).toBeNull() + + fireEvent.click(screen.getByRole("link", { name: "Cycle" })) + + expect(screen.getByRole("region", { name: "Cycle" })).toBeTruthy() + expect(screen.queryByRole("region", { name: "Compliance" })).toBeNull() }) - it("shows no cycle surface to a wallet with no Cycle grant", () => { + it("keeps work in progress on a surface when switching tabs and back", () => { + const manager = holderOf("vault manager") const compliance = holderOf("share-token compliance") useAdminAuthorityMock.mockReturnValue( authority({ status: "ready", - surfaces: new Set(["compliance"]), - grantedBy: { ...emptyGrantedBy, compliance: [compliance] }, - grants: [compliance], + surfaces: new Set(["cycle", "compliance"]), + grantedBy: { + ...emptyGrantedBy, + cycle: [manager], + compliance: [compliance], + }, + grants: [manager, compliance], }), ) - render() + renderAt("/admin/cycle") - expect(screen.queryByText("Cycle surface")).toBeNull() + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: "250" }, + }) + fireEvent.click(screen.getByRole("link", { name: "Compliance" })) + fireEvent.click(screen.getByRole("link", { name: "Cycle" })) + + expect((screen.getByLabelText("Amount") as HTMLInputElement).value).toBe( + "250", + ) }) }) diff --git a/app/src/pages/AdminPanel.tsx b/app/src/pages/AdminPanel.tsx index f1c558a..e52dad2 100644 --- a/app/src/pages/AdminPanel.tsx +++ b/app/src/pages/AdminPanel.tsx @@ -1,11 +1,15 @@ import { shortAddress } from "@stellar-scaffold/app-lib" -import React from "react" +import React, { useEffect, useRef } from "react" +import { Navigate, useParams } from "react-router-dom" import CycleSection from "../components/admin/CycleSection" +import Disclosure from "../components/admin/Disclosure" +import SurfaceTabs from "../components/admin/SurfaceTabs" import Skeleton from "../components/Skeleton" import AddressRows, { type AddressRow } from "../components/vault/AddressRows" import { vaultContractId } from "../config/contracts" import { type AdminAddresses, + type AdminSurface, type Grant, type Standing, SURFACE_LABELS, @@ -37,26 +41,76 @@ const describeGrant = (grant: Grant, wallet: string): string => ? grant.role : `${grant.role} (${describeStanding(grant.standing)})` -const describeCardGrant = (grant: Grant, wallet: string): string => - isSingleKey(grant, wallet) - ? grant.role - : `${grant.role} — ${describeStanding(grant.standing)}` - -const AddressesBlock: React.FC<{ addresses: AdminAddresses }> = ({ - addresses, -}) => ( -
- - Addresses - +const Contracts: React.FC<{ addresses: AdminAddresses }> = ({ addresses }) => ( + -
+ ) +const HeldSurfaces: React.FC<{ + held: AdminSurface[] + grantedBy: Record + wallet: string + addresses: AdminAddresses +}> = ({ held, grantedBy, wallet, addresses }) => { + const { surface } = useParams() + const active = held.find((candidate) => candidate === surface) + const headingRef = useRef(null) + const shownSurface = useRef(undefined) + + useEffect(() => { + if (active === undefined) return + const previous = shownSurface.current + if ( + previous !== undefined && + previous !== active && + held.includes(previous) + ) { + headingRef.current?.focus() + } + shownSurface.current = active + }, [active, held]) + + if (active === undefined) return + + return ( +
+ + {held.map((surface) => ( + + ))} +
+ ) +} + const AdminPanel: React.FC = () => { const { status, surfaces, grantedBy, grants, signersUnknown, addresses } = useAdminAuthority() const { address } = useWallet() + const held = SURFACE_ORDER.filter((surface) => surfaces.has(surface)) return (
@@ -82,14 +136,10 @@ const AdminPanel: React.FC = () => { )} {status === "checking" && ( -
    -
  • - -
  • -
  • - -
  • -
+
+ + +
)} {status === "unreadable" && ( @@ -98,44 +148,23 @@ const AdminPanel: React.FC = () => {

)} - {status === "ready" && surfaces.size === 0 && ( -

- This address holds no authority on this vault. -

+ {status === "ready" && held.length === 0 && ( + <> +

+ This address holds no authority on this vault. +

+ + )} - {status === "ready" && surfaces.size > 0 && ( -
    - {SURFACE_ORDER.filter((surface) => surfaces.has(surface)).map( - (surface) => ( -
  • -

    - {SURFACE_LABELS[surface]} -

    - - Granted by{" "} - {grantedBy[surface] - .map((grant) => describeCardGrant(grant, address ?? "")) - .join(", ")} - - {surface === "cycle" && ( - - )} -
  • - ), - )} -
+ {status === "ready" && held.length > 0 && ( + )} - - {status === "ready" && }
) } diff --git a/app/src/pages/cycle.test.ts b/app/src/pages/cycle.test.ts index a0f171f..946d8dc 100644 --- a/app/src/pages/cycle.test.ts +++ b/app/src/pages/cycle.test.ts @@ -28,6 +28,8 @@ import { type WindDownAction, formatDuration, formatTimestamp, + readiness, + readyCount, toActivityList, toCycleActions, toCycleRows, @@ -126,8 +128,12 @@ const ready = (overrides: Partial) => const groupOf = (groups: CycleGroup[], title: string) => groups.find((group) => group.title === title) -const valueOf = (groups: CycleGroup[], title: string, label: string) => - groupOf(groups, title)?.rows.find((row) => row.label === label)?.value +const valueOf = (groups: CycleGroup[], title: string, label: string) => { + const group = groupOf(groups, title) + return [...(group?.rows ?? []), ...(group?.limits ?? [])].find( + (row) => row.label === label, + )?.value +} const tileOf = (groups: CycleGroup[], title: string, label: string) => groupOf(groups, title)?.tiles.find((tile) => tile.label === label) @@ -162,8 +168,20 @@ describe("toCycleRows", () => { ]) expect(groups.map((group) => group.rows.map((row) => row.label))).toEqual([ ["Vault", "Notice period", "Sealed at", "Notice"], + ["Ripcord"], + ["Committed", "Liquid reserve"], + ["Activates at", "Delay", "Supply"], + ]) + }) + + it("keeps the static limits apart from the live rows, in Price and Reserve only", () => { + const groups = toCycleRows(ready({})) + + expect( + groups.map((group) => group.limits?.map((row) => row.label)), + ).toEqual([ + undefined, [ - "Ripcord", "Freshness window", "Cooldown", "Largest rise", @@ -171,8 +189,8 @@ describe("toCycleRows", () => { "Price floor", "Price ceiling", ], - ["Committed", "Liquid reserve", "Deposit cap"], - ["Activates at", "Delay", "Supply"], + ["Deposit cap"], + undefined, ]) }) @@ -183,6 +201,7 @@ describe("toCycleRows", () => { groups.map((group) => [ group.tiles.map((tile) => tile.label), group.rows.map((row) => row.label), + group.limits?.map((row) => row.label), ]) expect(labelsOf(pending)).toEqual(labelsOf(labelled)) @@ -190,7 +209,7 @@ describe("toCycleRows", () => { pending.every( (group) => group.tiles.every((tile) => tile.pending) && - group.rows.every((row) => row.pending), + [...group.rows, ...(group.limits ?? [])].every((row) => row.pending), ), ).toBe(true) }) @@ -1678,3 +1697,55 @@ describe("toCycleActions, winding down", () => { expect(finalize?.enabled).toBe(false) }) }) + +const actionWith = (overrides: Partial): TreasuryAction => ({ + id: "fund", + group: "Reserve", + label: "Fund the reserve", + conditions: [], + outcome: null, + enabled: false, + amount: null, + ...overrides, +}) + +describe("readiness", () => { + it("reads an enabled action as ready", () => { + expect(readiness(actionWith({ enabled: true }))).toEqual({ kind: "ready" }) + }) + + it("counts the conditions not met, an undecided one included", () => { + const action = actionWith({ + conditions: [ + { label: "Amount above zero", met: false }, + { label: "Within the balance", met: null }, + { label: "Wind-down not active", met: true }, + ], + }) + + expect(readiness(action)).toEqual({ kind: "unmet", count: 2 }) + }) + + it("reads an action the wallet cannot sign as unavailable, keeping the reason", () => { + const reason = "Needs 2 signatures; collecting them is not supported yet." + const action = actionWith({ + conditions: [{ label: "Amount above zero", met: false }], + unavailable: reason, + }) + + expect(readiness(action)).toEqual({ kind: "unavailable", reason }) + }) +}) + +describe("readyCount", () => { + it("counts the enabled actions", () => { + expect( + readyCount([ + actionWith({ enabled: true }), + actionWith({}), + actionWith({ enabled: true }), + ]), + ).toBe(2) + expect(readyCount([])).toBe(0) + }) +}) diff --git a/app/src/pages/cycle.ts b/app/src/pages/cycle.ts index 641fecf..f033ecc 100644 --- a/app/src/pages/cycle.ts +++ b/app/src/pages/cycle.ts @@ -38,7 +38,12 @@ type ReadyState = Extract const ACTIVITY_LIMIT = 10 const NOT_APPLICABLE = "—" -const GROUP_LAYOUT: { title: string; tiles: string[]; rows: string[] }[] = [ +const GROUP_LAYOUT: { + title: string + tiles: string[] + rows: string[] + limits?: string[] +}[] = [ { title: "Epoch", tiles: [ @@ -52,8 +57,8 @@ const GROUP_LAYOUT: { title: string; tiles: string[]; rows: string[] }[] = [ { title: "Price", tiles: ["Share price", "Attested", "Valid until"], - rows: [ - "Ripcord", + rows: ["Ripcord"], + limits: [ "Freshness window", "Cooldown", "Largest rise", @@ -65,7 +70,8 @@ const GROUP_LAYOUT: { title: string; tiles: string[]; rows: string[] }[] = [ { title: "Reserve", tiles: ["Free", "Uncovered", "Net deployed", "Custodian balance"], - rows: ["Committed", "Liquid reserve", "Deposit cap"], + rows: ["Committed", "Liquid reserve"], + limits: ["Deposit cap"], }, { title: "Wind-down", @@ -240,6 +246,8 @@ function priceGroup(oracle: CycleOracle): CycleGroup { ? "Raised" : "Not raised", ), + ], + limits: [ row("Freshness window", limits && formatDuration(limits.freshness)), row("Cooldown", limits && formatDuration(limits.cooldown)), row("Largest rise", limits && formatPercent(limits.maxUpBps)), @@ -287,8 +295,8 @@ function reserveGroup(state: ReadyState): CycleGroup { rows: [ row("Committed", formatAmountValue(reserve.committed)), row("Liquid reserve", formatAmountValue(reserve.liquid)), - row("Deposit cap", depositCapValue(reserve.depositCap)), ], + limits: [row("Deposit cap", depositCapValue(reserve.depositCap))], addresses: [{ label: "Custodian", address: reserve.custodian }], } } @@ -816,6 +824,25 @@ function toWindDownActions(state: ReadyState): WindDownAction[] { return actions } +export type Readiness = + | { kind: "ready" } + | { kind: "unmet"; count: number } + | { kind: "unavailable"; reason: string } + +export const readiness = (action: CycleAction): Readiness => { + if (action.enabled) return { kind: "ready" } + if (action.unavailable !== undefined) + return { kind: "unavailable", reason: action.unavailable } + return { + kind: "unmet", + count: action.conditions.filter((condition) => condition.met !== true) + .length, + } +} + +export const readyCount = (actions: CycleAction[]): number => + actions.filter((action) => action.enabled).length + export function toCycleActions( state: CycleState, grants: Grant[], @@ -843,10 +870,12 @@ export function toCycleActions( export function toCycleRows(state: CycleState): CycleGroup[] { if (state.status === "unreadable") return [] if (state.status === "checking") { - return GROUP_LAYOUT.map(({ title, tiles, rows }) => ({ + const pending = (label: string) => ({ label, value: null, pending: true }) + return GROUP_LAYOUT.map(({ title, tiles, rows, limits }) => ({ title, - tiles: tiles.map((label) => ({ label, value: null, pending: true })), - rows: rows.map((label) => ({ label, value: null, pending: true })), + tiles: tiles.map(pending), + rows: rows.map(pending), + ...(limits === undefined ? {} : { limits: limits.map(pending) }), })) }