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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { mock } from "vitest-mock-extended";

import type { DeploymentCost } from "../useDeploymentCost/useDeploymentCost";
import type { DeploymentFlow, DeploymentFlowActions } from "../useDeploymentFlow/useDeploymentFlow";
import type { QuoteExpiry } from "../useQuoteExpiry/useQuoteExpiry";
import type { DEPENDENCIES } from "./ConfigureDeploymentHeader";
import { ConfigureDeploymentHeader } from "./ConfigureDeploymentHeader";

Expand Down Expand Up @@ -56,9 +57,10 @@ describe(ConfigureDeploymentHeader.name, () => {
expect(screen.getByRole("button", { name: /request quotes/i })).toBeInTheDocument();
});

it("keeps the Requesting CTA while closing", () => {
it("shows a Cancelling CTA while closing", () => {
setup({ phase: "closing" });
expect(screen.getByRole("button", { name: /requesting/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /cancelling/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /requesting/i })).not.toBeInTheDocument();
});

it("restores Request quotes after an error so the spec can be retried", () => {
Expand Down Expand Up @@ -130,6 +132,52 @@ describe(ConfigureDeploymentHeader.name, () => {
expect(useDeploymentCost).toHaveBeenCalledWith(expect.objectContaining({ sdl: "the-sdl", selections: { p1: "akash1a/1/1/1" } }));
});

it("shows the quote-expiry countdown once bids arrive", () => {
setup({ phase: "quoting", cost: { minPerBlock: 5, maxPerBlock: 5, denom: "uakt" }, expiry: { secondsLeft: 165, isExpired: false } });
expect(screen.getByTestId("quote-expiry")).toHaveTextContent("expires in 2:45");
expect(screen.getByTestId("quote-expiry")).toHaveClass("text-muted-foreground");
});

it("marks the countdown red in the final minute", () => {
setup({ phase: "quoting", cost: { minPerBlock: 5, maxPerBlock: 5, denom: "uakt" }, expiry: { secondsLeft: 45, isExpired: false } });
expect(screen.getByTestId("quote-expiry")).toHaveTextContent("expires in 0:45");
expect(screen.getByTestId("quote-expiry")).toHaveClass("text-destructive");
});

it("hides the countdown until the first bid arrives", () => {
setup({ phase: "quoting", expiry: null });
expect(screen.queryByTestId("quote-expiry")).not.toBeInTheDocument();
});

it('keeps the expiry line as "expired" once the window elapses rather than hiding it or showing 0:00', () => {
setup({ phase: "quoting", cost: null, expiry: { secondsLeft: 0, isExpired: true } });
const line = screen.getByTestId("quote-expiry");
expect(line).toHaveTextContent("expired");
expect(line).not.toHaveTextContent("0:00");
expect(line).toHaveClass("text-destructive");
});

it("does not offer Close and Edit while open bids remain, even after the indicative timer elapses", () => {
setup({
phase: "quoting",
allPlacementsHaveBids: true,
placements: [{ id: "p1" }],
selections: { p1: "akash1a/1/1/1" },
cost: { minPerBlock: 5, maxPerBlock: 5, denom: "uakt" },
expiry: { secondsLeft: 0, isExpired: true }
});
expect(screen.queryByRole("button", { name: "Close and Edit" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Deploy" })).toBeInTheDocument();
});

it("flips the CTA to Close and Edit once the window elapses and no open bids remain, and runs cancelAndEdit", async () => {
const cancelAndEdit = vi.fn();
setup({ phase: "quoting", cost: null, expiry: { secondsLeft: 0, isExpired: true }, cancelAndEdit });
expect(screen.queryByRole("button", { name: /requesting/i })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Close and Edit" }));
expect(cancelAndEdit).toHaveBeenCalled();
});

function setup(input: {
phase: DeploymentFlow["phase"];
requestQuotes?: (sdl: string) => void;
Expand All @@ -142,12 +190,18 @@ describe(ConfigureDeploymentHeader.name, () => {
deploy?: () => void;
cost?: DeploymentCost | null;
sdl?: string;
expiry?: QuoteExpiry | null;
cancelAndEdit?: () => void;
}) {
const flow = mock<DeploymentFlow>({
phase: input.phase,
dseq: null,
deployError: input.deployError,
actions: mock<DeploymentFlowActions>({ requestQuotes: input.requestQuotes ?? vi.fn(), deploy: input.deploy ?? vi.fn() })
actions: mock<DeploymentFlowActions>({
requestQuotes: input.requestQuotes ?? vi.fn(),
deploy: input.deploy ?? vi.fn(),
cancelAndEdit: input.cancelAndEdit ?? vi.fn()
})
});
flow.selections = input.selections ?? {};
const enqueueSnackbar = vi.fn();
Expand All @@ -159,7 +213,9 @@ describe(ConfigureDeploymentHeader.name, () => {
generateSdl: () => GENERATED_SDL,
validateGeneratedSdl: () => input.validationErrors ?? [],
useDeploymentCost: useDeploymentCost as typeof DEPENDENCIES.useDeploymentCost,
PriceValue: ({ value }) => <span data-testid="price">{String(value)}</span>
PriceValue: ({ value }) => <span data-testid="price">{String(value)}</span>,
useQuoteExpiry: () => input.expiry ?? null,
CustomTooltip: ({ children }) => <>{children}</>
};
render(
<Wrapper placements={input.placements}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { FC, ReactNode } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { Button, Snackbar } from "@akashnetwork/ui/components";
import { Button, CustomTooltip, Snackbar } from "@akashnetwork/ui/components";
import { cn } from "@akashnetwork/ui/utils";
import { Send } from "iconoir-react";
import { LoaderCircle } from "lucide-react";
import { Clock, LoaderCircle } from "lucide-react";
import { useSnackbar } from "notistack";

import { PriceValue } from "@src/components/shared/PriceValue";
Expand All @@ -14,8 +15,20 @@ import { useDeploymentResourceSummary } from "../DeploymentResourceSummary/useDe
import type { DeploymentCost } from "../useDeploymentCost/useDeploymentCost";
import { useDeploymentCost } from "../useDeploymentCost/useDeploymentCost";
import type { DeploymentFlow } from "../useDeploymentFlow/useDeploymentFlow";
import type { QuoteExpiry } from "../useQuoteExpiry/useQuoteExpiry";
import { useQuoteExpiry } from "../useQuoteExpiry/useQuoteExpiry";

export const DEPENDENCIES = { useDeploymentResourceSummary, useSnackbar, Snackbar, generateSdl, validateGeneratedSdl, useDeploymentCost, PriceValue };
export const DEPENDENCIES = {
useDeploymentResourceSummary,
useSnackbar,
Snackbar,
generateSdl,
validateGeneratedSdl,
useDeploymentCost,
PriceValue,
useQuoteExpiry,
CustomTooltip
};

type Props = { flow: DeploymentFlow; sdl: string; onDeploy: () => void; allPlacementsHaveBids: boolean; dependencies?: typeof DEPENDENCIES };

Expand All @@ -25,13 +38,23 @@ export const ConfigureDeploymentHeader: FC<Props> = ({ flow, sdl, onDeploy, allP
const { enqueueSnackbar } = d.useSnackbar();
const placements = useWatch({ control, name: "placements" });
const cost = d.useDeploymentCost({ dseq: flow.dseq, sdl, placements, selections: flow.selections });
const expiry = d.useQuoteExpiry({ dseq: flow.dseq, enabled: flow.phase === "quoting" });

const isEditable = flow.phase === "configuring" || flow.phase === "error";
/** Deploy only takes over from the loading CTA once every placement has bids; until then quoting still reads as "Requesting…". */
const showDeployCta = flow.phase === "quoting" && allPlacementsHaveBids;
const allPlacementsSelected = placements.length > 0 && placements.every(placement => !!flow.selections[placement.id]);
/** A failed deploy returns to quoting with the error set; the CTA then re-fires the same request rather than re-opening review. */
const hasDeployError = !!flow.deployError;
const quotesExpired = !!expiry?.isExpired;
/**
* The timer is only indicative — providers can close their bids a little earlier or later — so we switch to
* "Close and Edit" only once the bids are actually gone (no placement has an open bid, hence no cost), not the
* moment the timer elapses. While any open bid remains the user can still deploy.
*/
const hasOpenBids = !!cost;
/** Closing (after Close and Edit) reuses the disabled loading CTA, but labelled to match the action in flight. */
const isClosing = flow.phase === "closing";

/**
* Request quotes runs the zod form validation first, then regenerates the SDL from the values
Expand Down Expand Up @@ -74,14 +97,23 @@ export const ConfigureDeploymentHeader: FC<Props> = ({ flow, sdl, onDeploy, allP
</div>

<div className="flex shrink-0 items-center gap-3 md:gap-6">
<DeploymentSummaryBlock label="Your deployment" value={deploymentSummary} />
<div className="hidden h-12 w-px self-stretch bg-border md:block" aria-hidden="true" />
<DeploymentSummaryBlock label="Deployment cost" value={<CostValue cost={cost} PriceValue={d.PriceValue} />} suffix={cost ? "/hr" : undefined} />
<div className="flex items-start gap-3 md:gap-6">
<DeploymentSummaryBlock label="Your deployment" value={deploymentSummary} />
<div className="hidden h-12 w-px self-stretch bg-border md:block" aria-hidden="true" />
<div className="flex flex-col items-end gap-0.5">
<DeploymentSummaryBlock label="Deployment cost" value={<CostValue cost={cost} PriceValue={d.PriceValue} />} suffix={cost ? "/hr" : undefined} />
<div className="h-4">{expiry ? <QuoteExpiryLine expiry={expiry} CustomTooltip={d.CustomTooltip} /> : null}</div>
</div>
</div>
{isEditable ? (
<Button type="button" onClick={onRequestQuotes} className="h-9 shrink-0 px-3 md:h-10 md:px-8">
<Send className="h-4 w-4 md:hidden" aria-label="Request quotes" />
<span className="hidden md:inline">Request quotes</span>
</Button>
) : quotesExpired && !hasOpenBids ? (
<Button type="button" onClick={flow.actions.cancelAndEdit} className="h-9 shrink-0 px-3 md:h-10 md:px-8">
Close and Edit
</Button>
) : showDeployCta ? (
<Button
type="button"
Expand All @@ -93,9 +125,9 @@ export const ConfigureDeploymentHeader: FC<Props> = ({ flow, sdl, onDeploy, allP
{hasDeployError ? "Retry" : "Deploy"}
</Button>
) : (
<Button type="button" disabled aria-label="Requesting" className="h-9 shrink-0 gap-2 px-3 md:h-10 md:px-8">
<Button type="button" disabled aria-label={isClosing ? "Cancelling" : "Requesting"} className="h-9 shrink-0 gap-2 px-3 md:h-10 md:px-8">
<LoaderCircle className="h-4 w-4 animate-spin text-current" aria-hidden="true" />
<span className="hidden md:inline">Requesting…</span>
<span className="hidden md:inline">{isClosing ? "Cancelling…" : "Requesting…"}</span>
</Button>
)}
</div>
Expand Down Expand Up @@ -139,3 +171,37 @@ function CostValue({ cost, PriceValue }: CostValueProps) {
</>
);
}

/**
* The bid-expiry countdown shown under the cost: muted while counting, red in the final minute. Once the window
* elapses it stays put, dropping the timer for a plain "expired" rather than showing 0:00. The `m:ss` sits in a
* fixed-width, right-aligned slot with tabular figures so the ticking digits never reflow the label or icon. A
* tooltip flags that the countdown is only indicative — bids can close a little earlier or later.
*/
function QuoteExpiryLine({ expiry, CustomTooltip }: { expiry: QuoteExpiry; CustomTooltip: typeof DEPENDENCIES.CustomTooltip }) {
const minutes = Math.floor(expiry.secondsLeft / 60);
const seconds = String(expiry.secondsLeft % 60).padStart(2, "0");
return (
<CustomTooltip title="This countdown is only indicative — providers may close their bids a little earlier or later.">
<div
data-testid="quote-expiry"
className={cn(
"flex cursor-help items-center gap-1 font-mono text-[10px] md:text-xs",
expiry.isExpired || expiry.secondsLeft < 60 ? "text-destructive" : "text-muted-foreground"
)}
>
<Clock className="h-3 w-3" aria-hidden="true" />
{expiry.isExpired ? (
<span>expired</span>
) : (
<span>
expires in{" "}
<span className="inline-block w-[4ch] text-right tabular-nums">
{minutes}:{seconds}
</span>
</span>
)}
</div>
</CustomTooltip>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mock } from "vitest-mock-extended";

import type { DEPENDENCIES } from "./useQuoteExpiry";
import { useQuoteExpiry } from "./useQuoteExpiry";

import { act, renderHook } from "@testing-library/react";

/** Fixed "now" so the local per-second tick is deterministic. */
const NOW = 1_780_000_000_000;

type ListBidsResult = ReturnType<typeof DEPENDENCIES.useListBids>;
type BidEntry = NonNullable<ListBidsResult["data"]>["data"][number];
type BidState = BidEntry["bid"]["state"];

describe("useQuoteExpiry", () => {
it("returns null when disabled", () => {
const { result } = setup({ enabled: false, bids: [{ height: 1000 }], currentHeight: 1010 });
expect(result.current).toBeNull();
});

it("returns null before any bid exists", () => {
const { result } = setup({ enabled: true, bids: [], currentHeight: 1010 });
expect(result.current).toBeNull();
});

it("returns null before the latest block loads", () => {
const { result } = setup({ enabled: true, bids: [{ height: 1000 }], currentHeight: null });
expect(result.current).toBeNull();
});

it("counts down from the earliest bid's creation block measured against the current block", () => {
const { result } = setup({ enabled: true, bids: [{ height: 1000 }], currentHeight: 1010 });
expect(result.current).toEqual({ secondsLeft: 259, isExpired: false });
});

it("anchors on the earliest bid when providers bid at different blocks", () => {
const { result } = setup({ enabled: true, bids: [{ height: 1010 }, { height: 1000 }, { height: 1005 }], currentHeight: 1010 });
expect(result.current?.secondsLeft).toBe(259);
});

it("derives the countdown from bids, not the dseq which may be a block height rather than a timestamp", () => {
const { result } = setup({ enabled: true, dseq: "20123456", bids: [{ height: 1000 }], currentHeight: 1010 });
expect(result.current).toEqual({ secondsLeft: 259, isExpired: false });
});

it("is expired once the block window has elapsed", () => {
const { result } = setup({ enabled: true, bids: [{ height: 1000 }], currentHeight: 1100 });
expect(result.current).toEqual({ secondsLeft: 0, isExpired: true });
});

it("expires as soon as the bids all close, even with time left on the block estimate", () => {
const { result } = setup({ enabled: true, bids: [{ height: 1000, state: "closed" }], currentHeight: 1010 });
expect(result.current).toEqual({ secondsLeft: 0, isExpired: true });
});

it("keeps counting while at least one bid is still open", () => {
const { result } = setup({
enabled: true,
bids: [
{ height: 1000, state: "open" },
{ height: 1000, state: "closed" }
],
currentHeight: 1010
});
expect(result.current).toEqual({ secondsLeft: 259, isExpired: false });
});

it("does not flash expired on the render where the deadline first appears", () => {
const { result, rerender, renders } = setup({ enabled: true, dseq: "d1", bids: [], currentHeight: 1010 });
expect(result.current).toBeNull();
rerender({ enabled: true, dseq: "d1", bids: [{ height: 1000 }], currentHeight: 1010 });
expect(result.current).toEqual({ secondsLeft: 259, isExpired: false });
expect(renders.some(value => value?.isExpired)).toBe(false);
});

it("ticks down once a second between block refetches", () => {
const { result } = setup({ enabled: true, bids: [{ height: 1000 }], currentHeight: 1010 });
expect(result.current?.secondsLeft).toBe(259);
act(() => {
vi.advanceTimersByTime(2000);
});
expect(result.current?.secondsLeft).toBe(257);
});

it("stays expired after the closed bids drop out of the query", () => {
const { result, rerender } = setup({ enabled: true, dseq: "d1", bids: [{ height: 1000, state: "closed" }], currentHeight: 1010 });
expect(result.current).toEqual({ secondsLeft: 0, isExpired: true });
rerender({ enabled: true, dseq: "d1", bids: [], currentHeight: 1010 });
expect(result.current).toEqual({ secondsLeft: 0, isExpired: true });
});

it("re-anchors when the deployment changes", () => {
const { result, rerender } = setup({ enabled: true, dseq: "d1", bids: [{ height: 1000 }], currentHeight: 1100 });
expect(result.current?.isExpired).toBe(true);
rerender({ enabled: true, dseq: "d2", bids: [{ height: 1090 }], currentHeight: 1100 });
expect(result.current).toEqual({ secondsLeft: 259, isExpired: false });
});

afterEach(() => {
vi.useRealTimers();
});

function setup(input: SetupInput) {
vi.useFakeTimers();
vi.setSystemTime(NOW);
const renders: Array<ReturnType<typeof useQuoteExpiry>> = [];
const rendered = renderHook(
(props: SetupInput) => {
const bids = props.bids?.map(bid => mock<BidEntry>({ bid: mock<BidEntry["bid"]>({ created_at: String(bid.height), state: bid.state ?? "open" }) }));
const bidsData = props.bids === null ? undefined : mock<NonNullable<ListBidsResult["data"]>>({ data: bids });
const dependencies: typeof DEPENDENCIES = {
useListBids: () => mock<ListBidsResult>({ data: bidsData }),
useBlock: () =>
mock<ReturnType<typeof DEPENDENCIES.useBlock>>({
data: props.currentHeight === null ? undefined : { block: { header: { height: props.currentHeight } } }
})
};
const value = useQuoteExpiry({ dseq: props.dseq ?? "any-dseq", enabled: props.enabled }, dependencies);
renders.push(value);
return value;
},
{ initialProps: input }
);
return { ...rendered, renders };
}
});

interface SetupInput {
enabled: boolean;
dseq?: string | null;
bids: Array<{ height: number; state?: BidState }> | null;
currentHeight: number | null;
}
Loading