fix(dashboard): make quota cards container responsive (#7027)

* fix(dashboard): make quota cards container responsive

* test(dashboard): assert #7072 mobile guard behaviorally, not by literal token

The #7072 regression guard asserted the literal `grid-cols-1` class token,
which only fits a breakpoint-driven grid. This PR switched the per-group
card grid to a container-driven `auto-fit`/`minmax()` template, which the
guard doesn't recognize even though it also guarantees a single column on
mobile-width viewports.

Rewrite the guard to assert the underlying behavior — single column on
mobile — accepting either the breakpoint model (unprefixed grid-cols-1) or
the auto-fit model (a minmax() track wide enough that two columns can't
fit on a phone viewport). Reverting to the pre-#7072 forced grid-cols-2
still fails the guard.

* fix(dashboard): keep stale quota rows during refresh

Refreshing a quota card replaced its entire quota section with a loading
placeholder. The card height collapsed and then grew back when data
arrived, and each height change rebalanced the outer 2xl CSS multi-column
layout, making provider groups visibly jump between columns (flash).

Show the loading placeholder only on the initial load (nothing to display
yet). During a refresh the stale rows stay rendered while the refresh
button icon spins, and the UI swaps in new data once it arrives. Also
keep the expand/collapse button visible during refresh so its row does
not add another height change.
This commit is contained in:
Xiangzhe
2026-07-20 21:07:42 +08:00
committed by GitHub
parent 2cd22633d9
commit 601470b894
5 changed files with 128 additions and 28 deletions

View File

@@ -58,7 +58,7 @@ export default function QuotaCardGrid({
({conns.length} account{conns.length !== 1 ? "s" : ""})
</span>
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-3">
{conns.map((conn) => (
<QuotaCard
key={conn.id}

View File

@@ -49,6 +49,23 @@ export function getVisibleQuotas(sortedQuotas: any[], expanded: boolean): any[]
return expanded ? sortedQuotas : sortedQuotas.slice(0, DEFAULT_VISIBLE_ROWS);
}
/**
* Pure helper — the loading placeholder replaces the entire quota section
* with a spinner. Swapping rows for a spinner mid-refresh collapses the card
* height, which rebalances the outer CSS multi-column layout (provider groups
* visibly jump between columns). Only the initial load (nothing to display
* yet) may show the placeholder; a refresh keeps the stale rows rendered
* while the refresh button icon spins, and the UI updates once new data
* arrives.
*/
export function shouldShowLoadingPlaceholder(
loading: boolean,
quotaCount: number,
message?: string | null
): boolean {
return loading && quotaCount === 0 && !message;
}
interface Props {
quotas: any[];
providerId?: string;
@@ -233,7 +250,7 @@ export default function QuotaCardExpanded({
return (
<div className="border-t border-border bg-bg-subtle/30 px-3 py-2.5 flex flex-col gap-1.5">
{loading ? (
{shouldShowLoadingPlaceholder(loading, sortedQuotas.length, message) ? (
<div className="text-[11px] text-text-muted flex items-center gap-1.5">
<span className="material-symbols-outlined animate-spin text-[13px]">
progress_activity
@@ -264,7 +281,7 @@ export default function QuotaCardExpanded({
</div>
)}
{!loading && !error && sortedQuotas.length > DEFAULT_VISIBLE_ROWS && (
{!error && sortedQuotas.length > DEFAULT_VISIBLE_ROWS && (
<button
type="button"
onClick={(e) => {

View File

@@ -0,0 +1,28 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { shouldShowLoadingPlaceholder } from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded";
// The loading placeholder replaces the entire quota section with a spinner.
// Swapping rows for a spinner mid-refresh collapses the card height, which
// rebalances the outer CSS multi-column layout (provider groups visibly jump
// between columns). During a refresh the existing rows must stay rendered —
// the refresh button icon already spins — so only the initial load (nothing
// to display yet) may show the placeholder.
test("initial load with no quota data shows the placeholder", () => {
assert.equal(shouldShowLoadingPlaceholder(true, 0), true);
});
test("refresh with existing quota rows keeps them rendered", () => {
assert.equal(shouldShowLoadingPlaceholder(true, 3), false);
assert.equal(shouldShowLoadingPlaceholder(true, 1), false);
});
test("refresh with only a provider message keeps it rendered", () => {
assert.equal(shouldShowLoadingPlaceholder(true, 0, "re-authenticate this account."), false);
});
test("not loading never shows the placeholder", () => {
assert.equal(shouldShowLoadingPlaceholder(false, 0), false);
assert.equal(shouldShowLoadingPlaceholder(false, 3), false);
});

View File

@@ -1,16 +1,14 @@
// #3520 — Provider Quota page should use horizontal whitespace better.
//
// QuotaCardGrid previously stacked provider groups vertically via a single
// `flex flex-col` container and kept cards to a conservative 1/2/3/4-column
// breakpoint ladder starting at `grid-cols-1`. This regression guard asserts
// the shipped JSX structure and grouping logic directly:
// `flex flex-col` container and sized card columns from fixed viewport
// breakpoints. This regression guard asserts the shipped JSX structure and
// grouping logic directly:
// 1. Grouping still produces one header per distinct provider with the
// correct account count ("N account(s)").
// 2. The per-group card grid keeps a single-column mobile fallback
// (`grid-cols-1`) below the `sm:` breakpoint (restored by #7072 after
// PR #6815 dropped it and clipped labels on phone-width viewports),
// while still going multi-column (`sm:grid-cols-2`) from `sm:` up so
// cards fill horizontal space sooner on larger screens.
// 2. Each provider group's cards auto-fit into as many 280px columns as that
// group's actual width supports, while a card can shrink to the group's
// width when its container is narrower than 280px.
// 3. Provider groups themselves flow into multiple columns on wide screens
// (`columns-*`) instead of an unconditional vertical `flex flex-col`
// stack.
@@ -107,14 +105,19 @@ test("QuotaCardGrid (#3520) — outer container flows groups into multiple colum
assert.notEqual(outerClassName, "flex flex-col gap-6");
});
test("QuotaCardGrid (#3520/#7072) — per-group card grid keeps a mobile grid-cols-1 fallback and goes multi-column from sm: up", () => {
test("QuotaCardGrid (#3520) — cards follow actual group width with a narrow-container fallback", () => {
const classNames = extractDivClassNames(COMPONENT_PATH);
const cardGridClassName = classNames.find(
(c) => /\bgrid\b/.test(c) && /grid-cols-/.test(c)
const cardGridClassName = classNames.find((c) => /\bgrid\b/.test(c) && /grid-cols-/.test(c));
assert.ok(cardGridClassName, "expected to find the actual-width per-group card grid's className");
assert.match(
cardGridClassName,
/(?:^|\s)grid-cols-\[repeat\(auto-fit,minmax\(min\(100%,280px\),1fr\)\)\](?:\s|$)/,
"expected 280px auto-fit columns that can shrink to 100% in a narrower group container"
);
assert.doesNotMatch(
cardGridClassName,
/(?:^|\s)(?:grid-cols-2|md:grid-cols-3|xl:grid-cols-4)(?:\s|$)/
);
assert.ok(cardGridClassName, "expected to find the per-group card grid's className");
assert.match(cardGridClassName!, /\bgrid-cols-1\b/);
assert.match(cardGridClassName!, /\bsm:grid-cols-2\b/);
});
test("QuotaCardGrid (#3520) — early-returns null when there are no connections", () => {

View File

@@ -10,9 +10,17 @@
// the overflowing button/label text is clipped instead of wrapping.
//
// This regression guard parses QuotaCardGrid.tsx's JSX via the TypeScript
// compiler API and asserts the per-group card grid's className restores an
// unprefixed `grid-cols-1` mobile fallback, while keeping the #6815 density
// gains (grid-cols-2 at `sm:` and up).
// compiler API and asserts the per-group card grid's className guarantees a
// single column on mobile-width viewports — the BEHAVIOR that fixes #7072 —
// rather than one specific token. Two implementations satisfy this:
// 1. Breakpoint-driven (#7194): an unprefixed `grid-cols-1` token forces a
// single column below `sm:`, widening at larger breakpoints.
// 2. Container-driven (#7027): an arbitrary-value
// `grid-cols-[repeat(auto-fit,minmax(min(100%,Npx),1fr))]` template
// where the minimum track width is wide enough that a second track
// cannot fit on any realistic phone viewport.
// Either way, reverting to the pre-#7072 forced `grid-cols-2` (no
// unprefixed 1-column fallback, no auto-fit) must fail this guard.
import { test } from "node:test";
import assert from "node:assert/strict";
@@ -63,17 +71,61 @@ function extractDivClassNames(sourcePath: string): string[] {
return classNames;
}
// Minimum track width (px) below which two auto-fit columns could plausibly
// fit side by side on a real phone viewport (narrowest common width ~320px).
// Requiring the min track to clear this bar keeps auto-fit implementations
// honest about the same guarantee the breakpoint model gives explicitly.
const MIN_MOBILE_SAFE_TRACK_PX = 200;
function assertSingleColumnMobileFallback(cardGridClassName: string): void {
const tokens = cardGridClassName.split(/\s+/);
// Breakpoint-driven layout (#7194 style): an unprefixed `grid-cols-N`
// token controls the base (mobile-first, <640px) column count directly.
const unprefixedGridCols = tokens.find((t) => /^grid-cols-\d+$/.test(t));
if (unprefixedGridCols) {
assert.equal(
unprefixedGridCols,
"grid-cols-1",
`breakpoint-driven grid must keep an unprefixed grid-cols-1 mobile fallback, got className="${cardGridClassName}"`
);
return;
}
// Container-driven layout (#7027 style): an arbitrary-value auto-fit grid
// template computes column count from available width, not a breakpoint.
const autoFitToken = tokens.find(
(t) => t.startsWith("grid-cols-[") && t.includes("auto-fit") && t.includes("minmax(")
);
assert.ok(
autoFitToken,
`expected either an unprefixed grid-cols-1 mobile fallback or a repeat(auto-fit, minmax(...)) ` +
`container grid, got className="${cardGridClassName}"`
);
const minTrackMatch =
autoFitToken!.match(/minmax\(min\(100%,(\d+)px\)/) ?? autoFitToken!.match(/minmax\((\d+)px/);
assert.ok(
minTrackMatch,
`expected the auto-fit grid's minmax() to specify a numeric minimum track width, got "${autoFitToken}"`
);
const minTrackPx = Number(minTrackMatch![1]);
// A second card-wide column can only appear once the container is at
// least 2x the minimum track width. Requiring the track minimum to clear
// MIN_MOBILE_SAFE_TRACK_PX guards against reintroducing #7072's forced
// 2-column-on-mobile clipping via an undersized auto-fit track.
assert.ok(
minTrackPx >= MIN_MOBILE_SAFE_TRACK_PX,
`auto-fit min track width too small (${minTrackPx}px) — two columns could fit on a mobile ` +
`viewport, reintroducing the #7072 clipping regression`
);
}
test("QuotaCardGrid (#7072) — per-group card grid keeps a single-column mobile fallback", () => {
const classNames = extractDivClassNames(COMPONENT_PATH);
const cardGridClassName = classNames.find((c) => /\bgrid\b/.test(c) && /grid-cols-/.test(c));
assert.ok(cardGridClassName, "expected to find the per-group card grid's className");
const tokens = cardGridClassName!.split(/\s+/);
const unprefixedGridCols = tokens.find((t) => /^grid-cols-\d+$/.test(t));
assert.equal(
unprefixedGridCols,
"grid-cols-1",
`expected unprefixed grid-cols-1 (mobile fallback), got className="${cardGridClassName}"`
);
assert.match(cardGridClassName!, /\bsm:grid-cols-2\b/, "expected sm:grid-cols-2 to be preserved");
assertSingleColumnMobileFallback(cardGridClassName!);
});