diff --git a/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-branch-validation.txt b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-branch-validation.txt new file mode 100644 index 000000000..1d3d8e86c --- /dev/null +++ b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-branch-validation.txt @@ -0,0 +1,6 @@ +Branch Validation: PASS + +PASS - Current branch: pr/26174-ALFA-009-game-hub-parent-child-table-layout. +PASS - Stack base: pr/26174-ALFA-008-alpha-stack-final-validation. +PASS - Changes are scoped to Game Hub Open Games parent/child table layout, targeted tests, and required reports. +PASS - No merge to main performed. diff --git a/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-manual-validation-notes.txt b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-manual-validation-notes.txt new file mode 100644 index 000000000..60594770a --- /dev/null +++ b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-manual-validation-notes.txt @@ -0,0 +1,6 @@ +Manual Validation Notes: PASS + +PASS - Confirmed Open Games renders as a parent table with expected headers. +PASS - Confirmed Demo Game expands to a child Game Summary table and collapses again. +PASS - Confirmed Open Game buttons still open games through the existing repository path. +PASS - Confirmed empty and unavailable Open Games states still render without game rows. diff --git a/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-requirement-checklist.txt b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-requirement-checklist.txt new file mode 100644 index 000000000..a26e9d858 --- /dev/null +++ b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-requirement-checklist.txt @@ -0,0 +1,10 @@ +Requirement Checklist: PASS + +PASS - Parent table is Open Games. +PASS - Each parent row represents one game. +PASS - Game rows can expand and collapse. +PASS - Used the existing parent-row plus expanded child-row table pattern. +PASS - Preserved existing Local API/service contract. +PASS - No browser-owned project data was added. +PASS - No page-local project arrays were added. +PASS - Empty and unavailable states remain explicit; no silent fallbacks were added. diff --git a/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-validation-lane.txt b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-validation-lane.txt new file mode 100644 index 000000000..d38885c3a --- /dev/null +++ b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-validation-lane.txt @@ -0,0 +1,4 @@ +Validation Lane: PASS + +PASS - npx playwright test tests/playwright/tools/GameHubMockRepository.spec.mjs -g "Game Hub creates, opens, and deletes mock games" +PASS - npx playwright test tests/playwright/tools/GameHubMockRepository.spec.mjs -g "Game Hub shows a creator-safe empty state|Game Hub shows a creator-safe unavailable state" diff --git a/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout.md b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout.md new file mode 100644 index 000000000..851c1a19e --- /dev/null +++ b/docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout.md @@ -0,0 +1,17 @@ +# PR_26174_ALFA_009-game-hub-parent-child-table-layout + +## Purpose + +Convert Game Hub Open Games into a reusable parent-table / child-table structure. + +## Summary + +- Rendered Open Games as a parent data table with one row per game. +- Added expand/collapse controls for each game row. +- Added a nested Game Summary child table in the expanded row using the existing parent-row plus expanded-child-row pattern. +- Preserved existing Local API/service repository calls and Open button behavior. + +## Validation + +PASS - `npx playwright test tests/playwright/tools/GameHubMockRepository.spec.mjs -g "Game Hub creates, opens, and deletes mock games"` +PASS - `npx playwright test tests/playwright/tools/GameHubMockRepository.spec.mjs -g "Game Hub shows a creator-safe empty state|Game Hub shows a creator-safe unavailable state"` diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt index 95d9ae9f4..c4c8e6cf9 100644 --- a/docs_build/dev/reports/codex_changed_files.txt +++ b/docs_build/dev/reports/codex_changed_files.txt @@ -1,8 +1,9 @@ +toolbox/game-hub/game-hub.js +tests/playwright/tools/GameHubMockRepository.spec.mjs docs_build/dev/reports/codex_review.diff docs_build/dev/reports/codex_changed_files.txt -docs_build/dev/reports/PR_26174_ALFA_008-alpha-stack-final-validation.md -docs_build/dev/reports/PR_26174_ALFA_008-alpha-stack-final-validation-final-stack-report.md -docs_build/dev/reports/PR_26174_ALFA_008-alpha-stack-final-validation-branch-validation.txt -docs_build/dev/reports/PR_26174_ALFA_008-alpha-stack-final-validation-requirement-checklist.txt -docs_build/dev/reports/PR_26174_ALFA_008-alpha-stack-final-validation-validation-lane.txt -docs_build/dev/reports/PR_26174_ALFA_008-alpha-stack-final-validation-manual-validation-notes.txt +docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout.md +docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-branch-validation.txt +docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-requirement-checklist.txt +docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-validation-lane.txt +docs_build/dev/reports/PR_26174_ALFA_009-game-hub-parent-child-table-layout-manual-validation-notes.txt diff --git a/docs_build/dev/reports/codex_review.diff b/docs_build/dev/reports/codex_review.diff index 7a8ae6ec0..8b90ad570 100644 --- a/docs_build/dev/reports/codex_review.diff +++ b/docs_build/dev/reports/codex_review.diff @@ -1 +1,195 @@ -No executable code changes in PR_26174_ALFA_008; final validation and reporting only. +diff --git a/tests/playwright/tools/GameHubMockRepository.spec.mjs b/tests/playwright/tools/GameHubMockRepository.spec.mjs +index e11631269..695b0a609 100644 +--- a/tests/playwright/tools/GameHubMockRepository.spec.mjs ++++ b/tests/playwright/tools/GameHubMockRepository.spec.mjs +@@ -264,10 +264,32 @@ test("Game Hub creates, opens, and deletes mock games", async ({ page }) => { + await expect(page.locator("[data-game-list]")).toContainText("Gravity Demo"); + await expect(page.locator("[data-game-list]")).toContainText("Collision Demo"); + await expect(page.locator("[data-game-list]")).toContainText("Camera Follow Demo"); ++ await expect(page.locator("[data-game-parent-table='open-games']")).toHaveAttribute("aria-label", "Open Games"); ++ await expect(page.locator("[data-game-parent-table='open-games'] caption")).toHaveText("Open Games"); ++ await expect(page.locator("[data-game-parent-table='open-games'] thead th")).toHaveText([ ++ "Game", ++ "Purpose", ++ "Status", ++ "Owner", ++ "Actions", ++ ]); + const demoGameRow = page.locator("[data-game-row='demo-game']"); + await expect(demoGameRow.locator("> .status")).toHaveCount(0); ++ await expect(demoGameRow.locator("[data-game-toggle='demo-game']")).toHaveAttribute("aria-expanded", "false"); + await expect(demoGameRow.getByRole("button", { name: "Open Demo Game (Active)" })).toHaveClass(/primary/); + await expect(demoGameRow.getByRole("button", { name: "Open Demo Game (Active)" })).toHaveAttribute("aria-current", "true"); ++ await demoGameRow.locator("[data-game-toggle='demo-game']").click(); ++ await expect(demoGameRow.locator("[data-game-toggle='demo-game']")).toHaveAttribute("aria-expanded", "true"); ++ await expect(page.locator("[data-game-row='demo-game'] + [data-game-expanded-row='demo-game']")).toHaveCount(1); ++ await expect(page.locator("[data-game-expanded-row='demo-game'] [data-game-child-table='summary']")).toContainText("Game Summary"); ++ await expect(page.locator("[data-game-expanded-row='demo-game'] [data-game-child-table='summary'] tbody tr")).toHaveText([ ++ "ProjectDemo Game", ++ "PurposeGame", ++ "StatusUnder Construction", ++ "OwnerUser 1", ++ ]); ++ await demoGameRow.locator("[data-game-toggle='demo-game']").click(); ++ await expect(page.locator("[data-game-expanded-row='demo-game']")).toHaveCount(0); + + await page.getByLabel("Game Name").fill("Launch Test Game"); + await page.getByRole("button", { name: "Create Game" }).click(); +diff --git a/toolbox/game-hub/game-hub.js b/toolbox/game-hub/game-hub.js +index 2d6f321e4..121e82562 100644 +--- a/toolbox/game-hub/game-hub.js ++++ b/toolbox/game-hub/game-hub.js +@@ -39,6 +39,10 @@ const elements = { + tableCounts: document.querySelector("[data-game-table-counts]"), + }; + ++const state = { ++ expandedGameId: "", ++}; ++ + function setText(element, value) { + if (element && typeof element.forEach === "function" && !element.nodeType) { + element.forEach((item) => { +@@ -225,6 +229,90 @@ function createGameListStatus(message, state) { + return emptyState; + } + ++function createCell(value, tagName = "td") { ++ const cell = document.createElement(tagName); ++ cell.textContent = value; ++ return cell; ++} ++ ++function createGameToggleButton(game, expanded) { ++ const button = document.createElement("button"); ++ button.className = expanded ? "btn btn--compact primary" : "btn btn--compact"; ++ button.type = "button"; ++ button.dataset.gameToggle = game.id; ++ button.setAttribute("aria-expanded", String(expanded)); ++ button.setAttribute("aria-controls", `game-child-${game.id}`); ++ button.textContent = game.name; ++ return button; ++} ++ ++function renderGameSummaryChildTable(parent, game) { ++ const wrapper = document.createElement("div"); ++ wrapper.className = "table-wrapper"; ++ const table = document.createElement("table"); ++ table.className = "data-table data-table--fixed"; ++ table.dataset.gameChildTable = "summary"; ++ table.setAttribute("aria-label", `${game.name} game summary`); ++ table.innerHTML = "Game SummaryFieldValue"; ++ const body = document.createElement("tbody"); ++ [ ++ ["Project", game.name], ++ ["Purpose", game.purpose], ++ ["Status", game.status], ++ ["Owner", game.ownerDisplayName], ++ ].forEach(([label, value]) => { ++ const row = document.createElement("tr"); ++ row.append(createCell(label, "th"), createCell(value || "Not set")); ++ row.firstElementChild.scope = "row"; ++ body.append(row); ++ }); ++ table.append(body); ++ wrapper.append(table); ++ parent.append(wrapper); ++} ++ ++function renderExpandedGameRow(tbody, game) { ++ const row = document.createElement("tr"); ++ row.dataset.gameExpandedRow = game.id; ++ row.id = `game-child-${game.id}`; ++ const content = document.createElement("td"); ++ content.colSpan = 5; ++ const stack = document.createElement("div"); ++ stack.className = "content-stack content-stack--compact"; ++ renderGameSummaryChildTable(stack, game); ++ content.append(stack); ++ row.append(content); ++ tbody.append(row); ++} ++ ++function renderGameParentRow(tbody, game, activeGame) { ++ const expanded = state.expandedGameId === game.id; ++ const row = document.createElement("tr"); ++ row.dataset.gameRow = game.id; ++ if (activeGame?.id === game.id) { ++ row.dataset.gameActive = "true"; ++ } ++ ++ const nameCell = document.createElement("th"); ++ nameCell.scope = "row"; ++ nameCell.append(createGameToggleButton(game, expanded)); ++ row.append( ++ nameCell, ++ createCell(game.purpose || "Game"), ++ createCell(game.status || "No status"), ++ createCell(game.ownerDisplayName || "No owner"), ++ ); ++ ++ const actions = document.createElement("td"); ++ actions.append(createGameButton(game, activeGame?.id === game.id)); ++ row.append(actions); ++ tbody.append(row); ++ ++ if (expanded) { ++ renderExpandedGameRow(tbody, game); ++ } ++} ++ + function renderProjectInformation(activeGame, currentMember, progress) { + if (!elements.projectRecordsTable) { + return; +@@ -276,25 +364,18 @@ function renderGameList() { + return; + } + +- listResult.forEach((game) => { +- const row = document.createElement("article"); +- row.className = "callout"; +- row.dataset.gameRow = game.id; +- +- const title = document.createElement("h4"); +- title.textContent = game.name; +- +- const meta = document.createElement("p"); +- meta.className = "eyebrow"; +- meta.textContent = `${game.purpose} | ${game.status} | ${game.ownerDisplayName}`; +- +- const isActive = activeGame?.id === game.id; +- const action = createGameButton(game, isActive); +- +- row.append(title, meta, action); +- +- elements.gameList.append(row); +- }); ++ const wrapper = document.createElement("div"); ++ wrapper.className = "table-wrapper"; ++ const table = document.createElement("table"); ++ table.className = "data-table data-table--fixed"; ++ table.dataset.gameParentTable = "open-games"; ++ table.setAttribute("aria-label", "Open Games"); ++ table.innerHTML = "Open GamesGamePurposeStatusOwnerActions"; ++ const body = document.createElement("tbody"); ++ listResult.forEach((game) => renderGameParentRow(body, game, activeGame)); ++ table.append(body); ++ wrapper.append(table); ++ elements.gameList.append(wrapper); + } + + function renderMembersTable(activeGame) { +@@ -471,6 +552,13 @@ elements.form?.addEventListener("submit", (event) => { + }); + + elements.gameList?.addEventListener("click", (event) => { ++ const toggle = event.target.closest("[data-game-toggle]"); ++ if (toggle) { ++ state.expandedGameId = state.expandedGameId === toggle.dataset.gameToggle ? "" : toggle.dataset.gameToggle; ++ renderWorkspace(); ++ return; ++ } ++ + const button = event.target.closest("[data-game-open]"); + + if (!button) { diff --git a/tests/playwright/tools/GameHubMockRepository.spec.mjs b/tests/playwright/tools/GameHubMockRepository.spec.mjs index e11631269..695b0a609 100644 --- a/tests/playwright/tools/GameHubMockRepository.spec.mjs +++ b/tests/playwright/tools/GameHubMockRepository.spec.mjs @@ -264,10 +264,32 @@ test("Game Hub creates, opens, and deletes mock games", async ({ page }) => { await expect(page.locator("[data-game-list]")).toContainText("Gravity Demo"); await expect(page.locator("[data-game-list]")).toContainText("Collision Demo"); await expect(page.locator("[data-game-list]")).toContainText("Camera Follow Demo"); + await expect(page.locator("[data-game-parent-table='open-games']")).toHaveAttribute("aria-label", "Open Games"); + await expect(page.locator("[data-game-parent-table='open-games'] caption")).toHaveText("Open Games"); + await expect(page.locator("[data-game-parent-table='open-games'] thead th")).toHaveText([ + "Game", + "Purpose", + "Status", + "Owner", + "Actions", + ]); const demoGameRow = page.locator("[data-game-row='demo-game']"); await expect(demoGameRow.locator("> .status")).toHaveCount(0); + await expect(demoGameRow.locator("[data-game-toggle='demo-game']")).toHaveAttribute("aria-expanded", "false"); await expect(demoGameRow.getByRole("button", { name: "Open Demo Game (Active)" })).toHaveClass(/primary/); await expect(demoGameRow.getByRole("button", { name: "Open Demo Game (Active)" })).toHaveAttribute("aria-current", "true"); + await demoGameRow.locator("[data-game-toggle='demo-game']").click(); + await expect(demoGameRow.locator("[data-game-toggle='demo-game']")).toHaveAttribute("aria-expanded", "true"); + await expect(page.locator("[data-game-row='demo-game'] + [data-game-expanded-row='demo-game']")).toHaveCount(1); + await expect(page.locator("[data-game-expanded-row='demo-game'] [data-game-child-table='summary']")).toContainText("Game Summary"); + await expect(page.locator("[data-game-expanded-row='demo-game'] [data-game-child-table='summary'] tbody tr")).toHaveText([ + "ProjectDemo Game", + "PurposeGame", + "StatusUnder Construction", + "OwnerUser 1", + ]); + await demoGameRow.locator("[data-game-toggle='demo-game']").click(); + await expect(page.locator("[data-game-expanded-row='demo-game']")).toHaveCount(0); await page.getByLabel("Game Name").fill("Launch Test Game"); await page.getByRole("button", { name: "Create Game" }).click(); diff --git a/toolbox/game-hub/game-hub.js b/toolbox/game-hub/game-hub.js index 2d6f321e4..121e82562 100644 --- a/toolbox/game-hub/game-hub.js +++ b/toolbox/game-hub/game-hub.js @@ -39,6 +39,10 @@ const elements = { tableCounts: document.querySelector("[data-game-table-counts]"), }; +const state = { + expandedGameId: "", +}; + function setText(element, value) { if (element && typeof element.forEach === "function" && !element.nodeType) { element.forEach((item) => { @@ -225,6 +229,90 @@ function createGameListStatus(message, state) { return emptyState; } +function createCell(value, tagName = "td") { + const cell = document.createElement(tagName); + cell.textContent = value; + return cell; +} + +function createGameToggleButton(game, expanded) { + const button = document.createElement("button"); + button.className = expanded ? "btn btn--compact primary" : "btn btn--compact"; + button.type = "button"; + button.dataset.gameToggle = game.id; + button.setAttribute("aria-expanded", String(expanded)); + button.setAttribute("aria-controls", `game-child-${game.id}`); + button.textContent = game.name; + return button; +} + +function renderGameSummaryChildTable(parent, game) { + const wrapper = document.createElement("div"); + wrapper.className = "table-wrapper"; + const table = document.createElement("table"); + table.className = "data-table data-table--fixed"; + table.dataset.gameChildTable = "summary"; + table.setAttribute("aria-label", `${game.name} game summary`); + table.innerHTML = "Game SummaryFieldValue"; + const body = document.createElement("tbody"); + [ + ["Project", game.name], + ["Purpose", game.purpose], + ["Status", game.status], + ["Owner", game.ownerDisplayName], + ].forEach(([label, value]) => { + const row = document.createElement("tr"); + row.append(createCell(label, "th"), createCell(value || "Not set")); + row.firstElementChild.scope = "row"; + body.append(row); + }); + table.append(body); + wrapper.append(table); + parent.append(wrapper); +} + +function renderExpandedGameRow(tbody, game) { + const row = document.createElement("tr"); + row.dataset.gameExpandedRow = game.id; + row.id = `game-child-${game.id}`; + const content = document.createElement("td"); + content.colSpan = 5; + const stack = document.createElement("div"); + stack.className = "content-stack content-stack--compact"; + renderGameSummaryChildTable(stack, game); + content.append(stack); + row.append(content); + tbody.append(row); +} + +function renderGameParentRow(tbody, game, activeGame) { + const expanded = state.expandedGameId === game.id; + const row = document.createElement("tr"); + row.dataset.gameRow = game.id; + if (activeGame?.id === game.id) { + row.dataset.gameActive = "true"; + } + + const nameCell = document.createElement("th"); + nameCell.scope = "row"; + nameCell.append(createGameToggleButton(game, expanded)); + row.append( + nameCell, + createCell(game.purpose || "Game"), + createCell(game.status || "No status"), + createCell(game.ownerDisplayName || "No owner"), + ); + + const actions = document.createElement("td"); + actions.append(createGameButton(game, activeGame?.id === game.id)); + row.append(actions); + tbody.append(row); + + if (expanded) { + renderExpandedGameRow(tbody, game); + } +} + function renderProjectInformation(activeGame, currentMember, progress) { if (!elements.projectRecordsTable) { return; @@ -276,25 +364,18 @@ function renderGameList() { return; } - listResult.forEach((game) => { - const row = document.createElement("article"); - row.className = "callout"; - row.dataset.gameRow = game.id; - - const title = document.createElement("h4"); - title.textContent = game.name; - - const meta = document.createElement("p"); - meta.className = "eyebrow"; - meta.textContent = `${game.purpose} | ${game.status} | ${game.ownerDisplayName}`; - - const isActive = activeGame?.id === game.id; - const action = createGameButton(game, isActive); - - row.append(title, meta, action); - - elements.gameList.append(row); - }); + const wrapper = document.createElement("div"); + wrapper.className = "table-wrapper"; + const table = document.createElement("table"); + table.className = "data-table data-table--fixed"; + table.dataset.gameParentTable = "open-games"; + table.setAttribute("aria-label", "Open Games"); + table.innerHTML = "Open GamesGamePurposeStatusOwnerActions"; + const body = document.createElement("tbody"); + listResult.forEach((game) => renderGameParentRow(body, game, activeGame)); + table.append(body); + wrapper.append(table); + elements.gameList.append(wrapper); } function renderMembersTable(activeGame) { @@ -471,6 +552,13 @@ elements.form?.addEventListener("submit", (event) => { }); elements.gameList?.addEventListener("click", (event) => { + const toggle = event.target.closest("[data-game-toggle]"); + if (toggle) { + state.expandedGameId = state.expandedGameId === toggle.dataset.gameToggle ? "" : toggle.dataset.gameToggle; + renderWorkspace(); + return; + } + const button = event.target.closest("[data-game-open]"); if (!button) {