mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
28
tests/unit/quota-card-expanded-loading-placeholder.test.ts
Normal file
28
tests/unit/quota-card-expanded-loading-placeholder.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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!);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user