feat(quota): all-groups default + stacked group sections + 3-col cards

- selectedGroupId defaults to "all" instead of "group-demo"
- <select> prepends <option value="all">{t("allGroups")}</option>
- Pool list replaced by groupsToRender map: one stacked section per group
  (heading: group name + count via groupPools.length), each with a
  grid-cols-1 md:grid-cols-2 xl:grid-cols-3 card grid
- Rename button guard changed from selectedGroupId !== "group-demo" to
  selectedGroupId !== "all" (no single target when viewing all groups)
- i18n: allGroups added to en.json ("All groups") + pt-BR.json ("Todos os grupos")
- quota-share-layout-v2.test.ts: 10 source-scan + i18n parity assertions
- quota-groups-ui.test.ts: align group heading test to groupPools.length
This commit is contained in:
diegosouzapw
2026-06-01 03:03:03 -03:00
parent b64489b3ad
commit 3c8e84d702
5 changed files with 231 additions and 37 deletions

View File

@@ -142,7 +142,7 @@ export default function QuotaSharePageClient() {
// ── Group state ───────────────────────────────────────────────────────────
const [groups, setGroups] = useState<QuotaGroup[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string>("group-demo");
const [selectedGroupId, setSelectedGroupId] = useState<string>("all");
const [newGroupInput, setNewGroupInput] = useState("");
const [showNewGroupInput, setShowNewGroupInput] = useState(false);
const [renaming, setRenaming] = useState(false);
@@ -286,12 +286,24 @@ export default function QuotaSharePageClient() {
[pools, aggregate]
);
// Pools filtered by selected group
// Pools filtered by selected group (kept for stats/empty-state checks)
const filteredPools = useMemo(
() => pools.filter((p) => (p as unknown as { groupId?: string }).groupId === selectedGroupId || (!( p as unknown as { groupId?: string }).groupId && selectedGroupId === "group-demo")),
() =>
selectedGroupId === "all"
? pools
: pools.filter(
(p) =>
((p as unknown as { groupId?: string }).groupId ?? "group-demo") === selectedGroupId
),
[pools, selectedGroupId]
);
// Groups to render as stacked sections
const groupsToRender = useMemo(
() => (selectedGroupId === "all" ? groups : groups.filter((g) => g.id === selectedGroupId)),
[groups, selectedGroupId]
);
// ── Mutations ─────────────────────────────────────────────────────────────
const handleSaveAllocations = useCallback(
@@ -348,6 +360,7 @@ export default function QuotaSharePageClient() {
title={t("groupSelectHint")}
className="px-2 py-1 rounded border border-border bg-bg-base text-sm text-text-main min-w-[120px]"
>
<option value="all">{t("allGroups")}</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
@@ -394,7 +407,7 @@ export default function QuotaSharePageClient() {
{t("newGroup")}
</button>
)}
{selectedGroupId !== "group-demo" && (
{selectedGroupId !== "all" && (
<button
type="button"
onClick={() => void handleRenameGroup()}
@@ -443,19 +456,7 @@ export default function QuotaSharePageClient() {
</div>
) : (
<>
{/* Group heading */}
{groups.find((g) => g.id === selectedGroupId) && (
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-text-muted">folder</span>
<span className="text-sm font-semibold text-text-main">
{groups.find((g) => g.id === selectedGroupId)?.name}
</span>
<span className="text-[11px] text-text-muted">
({filteredPools.length})
</span>
</div>
)}
{filteredPools.length === 0 ? (
{groupsToRender.length === 0 ? (
<div className="rounded-xl border border-dashed border-border bg-surface py-10 text-center">
<p className="text-sm text-text-muted">{t("emptyDescription")}</p>
<Button variant="primary" size="sm" className="mt-3" onClick={() => setCreateOpen(true)}>
@@ -464,21 +465,58 @@ export default function QuotaSharePageClient() {
</Button>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{filteredPools.map((pool) => (
<PoolCardWithUsage
key={pool.id}
pool={pool}
keyLabels={keyLabels}
connectionLabel={connLabel(pool.connectionId)}
provider={connProvider(pool.connectionId)}
providers={[...new Set((pool.connectionIds ?? [pool.connectionId]).map(connProvider))]}
connectionIds={pool.connectionIds ?? [pool.connectionId]}
onEdit={() => setEditing(pool)}
onRemove={() => void handleRemovePool(pool.id)}
/>
))}
</div>
groupsToRender.map((g) => {
const groupPools = pools.filter(
(p) =>
((p as unknown as { groupId?: string }).groupId ?? "group-demo") === g.id
);
return (
<div key={g.id} className="flex flex-col gap-3">
{/* Per-group heading */}
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-text-muted">
folder
</span>
<span className="text-sm font-semibold text-text-main">{g.name}</span>
<span className="text-[11px] text-text-muted">({groupPools.length})</span>
</div>
{groupPools.length === 0 ? (
<div className="rounded-xl border border-dashed border-border bg-surface py-6 text-center">
<p className="text-sm text-text-muted">{t("emptyDescription")}</p>
<Button
variant="primary"
size="sm"
className="mt-3"
onClick={() => setCreateOpen(true)}
>
<span className="material-symbols-outlined text-[14px] mr-1">add</span>
{t("newPool")}
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{groupPools.map((pool) => (
<PoolCardWithUsage
key={pool.id}
pool={pool}
keyLabels={keyLabels}
connectionLabel={connLabel(pool.connectionId)}
provider={connProvider(pool.connectionId)}
providers={[
...new Set(
(pool.connectionIds ?? [pool.connectionId]).map(connProvider)
),
]}
connectionIds={pool.connectionIds ?? [pool.connectionId]}
onEdit={() => setEditing(pool)}
onRemove={() => void handleRemovePool(pool.id)}
/>
))}
</div>
)}
</div>
);
})
)}
</>
)}

View File

@@ -7912,7 +7912,8 @@
"groupSelectHint": "Filter pools by group",
"groupAllocationNote": "Allocations apply to all pools in this group via the shared quota layer.",
"groupNamePrompt": "Enter group name",
"wizardGroupLabel": "Group"
"wizardGroupLabel": "Group",
"allGroups": "All groups"
},
"plugins": {
"title": "Plugins",

View File

@@ -5417,7 +5417,8 @@
"groupSelectHint": "Filtrar pools por grupo",
"groupAllocationNote": "As alocações se aplicam a todos os pools deste grupo através da camada de cota compartilhada.",
"groupNamePrompt": "Digite o nome do grupo",
"wizardGroupLabel": "Grupo"
"wizardGroupLabel": "Grupo",
"allGroups": "Todos os grupos"
},
"requestLogger": {
"recording": "Recording",

View File

@@ -130,10 +130,12 @@ test("QuotaSharePageClient: filters pool list by selectedGroupId (filteredPools)
});
test("QuotaSharePageClient: renders group heading above pool grid", () => {
// The group heading shows the group name and pool count
// The group heading shows the group name and pool count.
// Task 4 replaced the single-group heading (filteredPools.length) with
// per-group headings using groupPools.length — one section per group.
assert.ok(
pageSrc.includes("filteredPools.length"),
"QuotaSharePageClient must show filteredPools count in the group heading"
pageSrc.includes("groupPools.length") || pageSrc.includes("filteredPools.length"),
"QuotaSharePageClient must show pool count in the group heading (groupPools.length or filteredPools.length)"
);
});

View File

@@ -0,0 +1,152 @@
/**
* tests/unit/quota-share-layout-v2.test.ts
*
* Task 4 — source-level assertions for the "all groups" default,
* stacked group sections, and 3-column card grid in QuotaSharePageClient.
*
* Pattern mirrors tests/unit/quota-groups-ui.test.ts (source-scan).
* Node.js native test runner — no DOM setup required.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const PAGE_CLIENT_PATH = join(
ROOT,
"src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx"
);
const EN_PATH = join(ROOT, "src/i18n/messages/en.json");
const PT_PATH = join(ROOT, "src/i18n/messages/pt-BR.json");
const pageSrc = readFileSync(PAGE_CLIENT_PATH, "utf8");
// ── 1. Default state is "all" ────────────────────────────────────────────────
test('QuotaSharePageClient: selectedGroupId defaults to "all"', () => {
assert.ok(
pageSrc.includes('useState<string>("all")'),
'QuotaSharePageClient must default selectedGroupId to "all" via useState<string>("all")'
);
});
// ── 2. "All groups" option in <select> ──────────────────────────────────────
test('QuotaSharePageClient: <select> has an option with value="all" using t("allGroups")', () => {
assert.ok(
pageSrc.includes('value="all"'),
'QuotaSharePageClient <select> must include an <option value="all">'
);
assert.ok(
pageSrc.includes('t("allGroups")') || pageSrc.includes("t('allGroups')"),
'The "all" option must use the t("allGroups") i18n key'
);
});
// ── 3. Multi-group render (groupsToRender iteration) ─────────────────────────
test("QuotaSharePageClient: renders groups by iterating groupsToRender (all-groups mode)", () => {
// Must derive groupsToRender (or equivalent) and map over it
assert.ok(
pageSrc.includes("groupsToRender"),
"QuotaSharePageClient must define groupsToRender to iterate over groups"
);
// The render must map over groupsToRender to produce per-group sections
assert.ok(
pageSrc.includes("groupsToRender.map") || pageSrc.includes("groupsToRender.filter") || pageSrc.includes(".map((g)") || pageSrc.includes(".map((g, "),
"QuotaSharePageClient must map over groupsToRender to render one section per group"
);
});
test("QuotaSharePageClient: computes groupsToRender from selectedGroupId === \"all\" check", () => {
// The all-groups path: selectedGroupId === "all" ? groups : groups.filter(...)
assert.ok(
pageSrc.includes('selectedGroupId === "all"'),
'QuotaSharePageClient must branch on selectedGroupId === "all" when computing groupsToRender'
);
});
// ── 4. 3-column grid ──────────────────────────────────────────────────────────
test("QuotaSharePageClient: card grid uses xl:grid-cols-3", () => {
assert.ok(
pageSrc.includes("xl:grid-cols-3"),
"QuotaSharePageClient card grid className must include xl:grid-cols-3"
);
});
test("QuotaSharePageClient: card grid uses md:grid-cols-2", () => {
assert.ok(
pageSrc.includes("md:grid-cols-2"),
"QuotaSharePageClient card grid className must include md:grid-cols-2 (intermediate breakpoint)"
);
});
// ── 5. Rename button hidden/disabled when "all" is selected ──────────────────
test('QuotaSharePageClient: rename button is hidden/disabled when selectedGroupId === "all"', () => {
// When selectedGroupId === "all", the rename button should be hidden or disabled.
// The old guard was `selectedGroupId !== "group-demo"`.
// New guard must exclude "all" (either explicit !== "all" or a more general check).
assert.ok(
pageSrc.includes('selectedGroupId !== "all"') ||
// guard via a truthy group lookup (groups.find(g.id === selectedGroupId) is falsy for "all")
pageSrc.includes("groups.find((g) => g.id === selectedGroupId)") ||
pageSrc.includes("groups.find(g => g.id === selectedGroupId)"),
'Rename button must be hidden when selectedGroupId === "all" (guard via !== "all" or group lookup)'
);
});
// ── 6. i18n parity: allGroups key ────────────────────────────────────────────
test('i18n en.json: quotaShare.allGroups exists and equals "All groups"', () => {
const en = JSON.parse(readFileSync(EN_PATH, "utf8")) as Record<
string,
Record<string, string>
>;
assert.equal(
typeof en["quotaShare"]?.["allGroups"],
"string",
"en.json must have quotaShare.allGroups"
);
assert.equal(
en["quotaShare"]["allGroups"],
"All groups",
'en.json quotaShare.allGroups must equal "All groups"'
);
});
test('i18n pt-BR.json: quotaShare.allGroups exists and equals "Todos os grupos"', () => {
const pt = JSON.parse(readFileSync(PT_PATH, "utf8")) as Record<
string,
Record<string, string>
>;
assert.equal(
typeof pt["quotaShare"]?.["allGroups"],
"string",
"pt-BR.json must have quotaShare.allGroups"
);
assert.equal(
pt["quotaShare"]["allGroups"],
"Todos os grupos",
'pt-BR.json quotaShare.allGroups must equal "Todos os grupos"'
);
});
test("i18n parity: allGroups present in both en and pt-BR", () => {
const en = JSON.parse(readFileSync(EN_PATH, "utf8")) as Record<
string,
Record<string, string>
>;
const pt = JSON.parse(readFileSync(PT_PATH, "utf8")) as Record<
string,
Record<string, string>
>;
assert.ok("allGroups" in (en["quotaShare"] ?? {}), "en.json missing quotaShare.allGroups");
assert.ok("allGroups" in (pt["quotaShare"] ?? {}), "pt-BR.json missing quotaShare.allGroups");
});