BigQuery Query Console Redesign โ Implementation Plan โ
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the current Query Console layout (/admin/analytics/bigquery/console) with a tabbed-rail + work-surface structure: Schema/Saved/History tabs in a left rail, editor-over-results vertical split in the work surface, and proper result presentation (row-expand, column resize, CSV export, fullscreen).
Architecture: Extract console-related pieces from BigQueryWorkspace.jsx (~1530 lines) into a focused apps/admin/src/admin/analytics/console/ module. New orchestrator (ConsolePanel) composes a toolbar, tabbed rail, editor, meta strip, and results component. State is centralized in three hooks: useConsoleState (current SQL + last estimate/job/error), useConsoleHistory (localStorage-backed run history), useConsolePersistence (rail width/collapsed/active-tab + editor split height).
Tech Stack: React + Vite + Vitest + Testing Library (existing). New dependency: sql-formatter (BigQuery dialect) for the Format button.
Spec: docs/superpowers/specs/2026-05-03-bq-query-console-redesign-design.md
Phase 0 โ Setup โ
Task 0: Add sql-formatter dependency โ
Files:
Modify:
apps/admin/package.jsonModify:
package-lock.json(top-level workspace lockfile)[ ] Step 1: Install the package
npm install --workspace=apps/admin sql-formatterExpected: package.json gains "sql-formatter": "^15.x" under dependencies; lockfile updated.
- [ ] Step 2: Verify import works
Run from repo root:
node -e "console.log(require('./apps/admin/node_modules/sql-formatter').format('select 1', { language: 'bigquery' }))"Expected: prints formatted SELECT 1 (no error).
- [ ] Step 3: Commit
git add apps/admin/package.json package-lock.json
git commit -m "chore(admin): add sql-formatter for BQ console Format button"Phase 1 โ Pre-refactor: extract shared components without behavior change โ
The current BigQueryWorkspace.jsx defines SchemaBrowser, SavedQueriesPanel, RawSqlEditor, ErrorCard, ResultMetaFooter, SectionHeader, formatBytes, structuredError inline. Moving them into console/ first lets later tasks depend on stable imports and keeps each task tiny.
Task 1: Create console/ directory and move helpers โ
Files:
Create:
apps/admin/src/admin/analytics/console/utils.jsModify:
apps/admin/src/admin/analytics/BigQueryWorkspace.jsx[ ] Step 1: Create the new utils file
Create apps/admin/src/admin/analytics/console/utils.js:
export function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit += 1
}
return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`
}
export function structuredError(err, fallbackCode = 'REQUEST_FAILED') {
if (!err) return { code: fallbackCode, message: 'Unknown error' }
if (typeof err === 'string') return { code: fallbackCode, message: err }
return {
code: err.code || err.status || fallbackCode,
message: err.message || String(err),
details: err.details,
}
}(Copy the EXACT bodies of formatBytes at line 629 and structuredError at line 662 of the current BigQueryWorkspace.jsx if they differ โ read first, paste verbatim.)
- [ ] Step 2: Read the originals to verify they match
sed -n '629,635p;662,672p' apps/admin/src/admin/analytics/BigQueryWorkspace.jsxIf any difference vs Step 1: update console/utils.js to match the original bodies exactly.
- [ ] Step 3: Update
BigQueryWorkspace.jsxto import from new location
In apps/admin/src/admin/analytics/BigQueryWorkspace.jsx:
Add at the top of the imports:
jsimport { formatBytes, structuredError } from './console/utils'Delete the inline
function formatBytesandfunction structuredErrordefinitions.[ ] Step 4: Run the existing test suite
npm run test --workspace=apps/admin -- src/admin/analyticsExpected: PASS โ same as before.
- [ ] Step 5: Commit
git add apps/admin/src/admin/analytics/console/utils.js apps/admin/src/admin/analytics/BigQueryWorkspace.jsx
git commit -m "refactor(admin/bq): extract formatBytes & structuredError into console/utils"Task 2: Move ErrorCard, ResultMetaFooter, SectionHeader to console/ โ
Files:
Create:
apps/admin/src/admin/analytics/console/atoms.jsxModify:
apps/admin/src/admin/analytics/BigQueryWorkspace.jsx[ ] Step 1: Read the three component bodies
sed -n '637,696p' apps/admin/src/admin/analytics/BigQueryWorkspace.jsxCopy the bodies of ErrorCard (line 637), ResultMetaFooter (line 674), SectionHeader (line 686) verbatim.
- [ ] Step 2: Create
console/atoms.jsxwith the three components
import React from 'react'
import { formatBytes } from './utils'
export function ErrorCard(props) {
// PASTE the exact body of ErrorCard from BigQueryWorkspace.jsx here.
}
export function ResultMetaFooter(props) {
// PASTE the exact body of ResultMetaFooter from BigQueryWorkspace.jsx here.
}
export function SectionHeader(props) {
// PASTE the exact body of SectionHeader from BigQueryWorkspace.jsx here.
}If ErrorCard or ResultMetaFooter reference formatBytes, the import above covers it. If they reference any other inline helper still in BigQueryWorkspace.jsx, leave the helper there for now and import it via a relative path โ but check first; expectation is none.
[ ] Step 3: Update
BigQueryWorkspace.jsxAdd to imports:
jsimport { ErrorCard, ResultMetaFooter, SectionHeader } from './console/atoms'Delete the inline
function ErrorCard,function ResultMetaFooter,function SectionHeaderdefinitions.[ ] Step 4: Run the tests
npm run test --workspace=apps/admin -- src/admin/analyticsExpected: PASS.
- [ ] Step 5: Commit
git add apps/admin/src/admin/analytics/console/atoms.jsx apps/admin/src/admin/analytics/BigQueryWorkspace.jsx
git commit -m "refactor(admin/bq): extract ErrorCard, ResultMetaFooter, SectionHeader into console/atoms"Task 3: Move RawSqlEditor to console/ โ
Files:
Create:
apps/admin/src/admin/analytics/console/RawSqlEditor.jsxModify:
apps/admin/src/admin/analytics/BigQueryWorkspace.jsx[ ] Step 1: Read the existing component (lines 698โ940)
sed -n '698,941p' apps/admin/src/admin/analytics/BigQueryWorkspace.jsxCopy the entire RawSqlEditor component verbatim, including any useState, useRef, useEffect, forwardRef, useImperativeHandle usage and inline handleSaveSubmit, clearOutputs, handleEstimate, handleRun helpers it owns.
- [ ] Step 2: Create the new file
apps/admin/src/admin/analytics/console/RawSqlEditor.jsx:
import React, { forwardRef, useState, useRef, useEffect, useImperativeHandle, useCallback } from 'react'
import {
runBqQueryRaw,
estimateBqQueryRaw,
} from '../../../shared/lib/analyticsApi'
import { saveQuery as saveAdminQuery } from '../savedQueries.service'
import QueryResultTable from '../QueryResultTable'
import { ErrorCard, ResultMetaFooter, SectionHeader } from './atoms'
import { formatBytes, structuredError } from './utils'
// PASTE the exact body of RawSqlEditor (and any locally-scoped helpers) here.
export default RawSqlEditorAdjust imports to include exactly what the pasted body uses โ read the body first to determine which symbols are needed.
[ ] Step 3: Update
BigQueryWorkspace.jsxAdd import:
jsimport RawSqlEditor from './console/RawSqlEditor'Delete the inline
RawSqlEditordefinition (entire block fromconst RawSqlEditor = forwardRef(...)through closing})).Remove now-unused imports from
BigQueryWorkspace.jsx(vitest will catch any that turn out to still be needed).[ ] Step 4: Run tests
npm run test --workspace=apps/admin -- src/admin/analyticsExpected: PASS.
- [ ] Step 5: Commit
git add apps/admin/src/admin/analytics/console/RawSqlEditor.jsx apps/admin/src/admin/analytics/BigQueryWorkspace.jsx
git commit -m "refactor(admin/bq): move RawSqlEditor into console/"Task 4: Move SchemaBrowser (and SchemaTableRow) to console/ โ
Files:
Create:
apps/admin/src/admin/analytics/console/SchemaBrowser.jsxModify:
apps/admin/src/admin/analytics/BigQueryWorkspace.jsx[ ] Step 1: Read the existing components
sed -n '942,1136p' apps/admin/src/admin/analytics/BigQueryWorkspace.jsxCopy SchemaTableRow (line 942) and SchemaBrowser (line 994) verbatim. Note: SchemaBrowser uses SectionHeader and ErrorCard (now imported from atoms) and getBqSchema from analyticsApi.
- [ ] Step 2: Create
console/SchemaBrowser.jsx
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { getBqSchema } from '../../../shared/lib/analyticsApi'
import { ErrorCard, SectionHeader } from './atoms'
import { structuredError } from './utils'
function SchemaTableRow(props) {
// PASTE exact body of SchemaTableRow here.
}
export default function SchemaBrowser(props) {
// PASTE exact body of SchemaBrowser here.
}[ ] Step 3: Update
BigQueryWorkspace.jsxAdd import:
jsimport SchemaBrowser from './console/SchemaBrowser'Delete the inline
SchemaTableRowandSchemaBrowserdefinitions.[ ] Step 4: Run tests
npm run test --workspace=apps/admin -- src/admin/analyticsExpected: PASS.
- [ ] Step 5: Commit
git add apps/admin/src/admin/analytics/console/SchemaBrowser.jsx apps/admin/src/admin/analytics/BigQueryWorkspace.jsx
git commit -m "refactor(admin/bq): move SchemaBrowser into console/"Task 5: Move SavedQueriesPanel (with formatRelativeTime) to console/ โ
Files:
Create:
apps/admin/src/admin/analytics/console/SavedQueriesPanel.jsxModify:
apps/admin/src/admin/analytics/BigQueryWorkspace.jsx[ ] Step 1: Read existing
sed -n '1138,1268p' apps/admin/src/admin/analytics/BigQueryWorkspace.jsxCopy formatRelativeTime (line 1138) and SavedQueriesPanel (line 1151) verbatim.
- [ ] Step 2: Create the file
import React, { useCallback, useEffect, useState } from 'react'
import {
subscribeToSavedQueries,
deleteSavedQuery,
} from '../savedQueries.service'
import { auth } from '../../../firebase'
import { ErrorCard, SectionHeader } from './atoms'
import { structuredError } from './utils'
function formatRelativeTime(date) {
// PASTE exact body.
}
export default function SavedQueriesPanel({ onLoad, onCountChange }) {
// PASTE exact body.
// Add one new behavior: whenever the subscription's onChange fires, after
// setState, also call onCountChange?.(queries.length). Only one call site โ
// the existing subscribeToSavedQueries(onChange) callback.
}The new onCountChange prop is what the rail's Saved tab will use to render its badge count. Existing call sites pass onLoad only โ onCountChange is optional, so no consumer breaks.
[ ] Step 3: Update
BigQueryWorkspace.jsxAdd:
import SavedQueriesPanel from './console/SavedQueriesPanel'Delete inline
formatRelativeTimeandSavedQueriesPanel.[ ] Step 4: Run tests
npm run test --workspace=apps/admin -- src/admin/analyticsExpected: PASS.
- [ ] Step 5: Commit
git add apps/admin/src/admin/analytics/console/SavedQueriesPanel.jsx apps/admin/src/admin/analytics/BigQueryWorkspace.jsx
git commit -m "refactor(admin/bq): move SavedQueriesPanel into console/, add onCountChange"Task 6: Move the inline ConsolePanel and SavedQueryCard to console/ (still old layout) โ
This is the last extraction step before we start replacing behavior. Move the components so the old console keeps working from the new location.
Files:
Create:
apps/admin/src/admin/analytics/console/ConsolePanelLegacy.jsxModify:
apps/admin/src/admin/analytics/BigQueryWorkspace.jsx[ ] Step 1: Read existing
SavedQueryCardandConsolePanel
sed -n '594,627p;1270,1342p' apps/admin/src/admin/analytics/BigQueryWorkspace.jsx- [ ] Step 2: Create
console/ConsolePanelLegacy.jsx
import React, { useCallback, useRef, useState } from 'react'
import { runBqQuery } from '../../../shared/lib/analyticsApi'
import QueryResultTable from '../QueryResultTable'
import { SAVED_QUERIES } from '../savedQueriesManifest'
import RawSqlEditor from './RawSqlEditor'
import SchemaBrowser from './SchemaBrowser'
import SavedQueriesPanel from './SavedQueriesPanel'
import { ErrorCard, ResultMetaFooter, SectionHeader } from './atoms'
import { structuredError } from './utils'
function SavedQueryCard(props) {
// PASTE exact body of SavedQueryCard.
}
export default function ConsolePanel() {
// PASTE exact body of ConsolePanel.
}[ ] Step 3: Update
BigQueryWorkspace.jsxReplace any internal call to the deleted
ConsolePanelwith:jsimport ConsolePanel from './console/ConsolePanelLegacy'Delete inline
SavedQueryCardandConsolePaneldefinitions.Remove now-unused imports flagged by your editor / lint.
[ ] Step 4: Run tests
npm run test --workspace=apps/admin -- src/admin/analyticsExpected: PASS.
- [ ] Step 5: Verify in browser
npm run dev --workspace=apps/adminVisit /admin/analytics/bigquery/console. Confirm: schema browser loads, can run raw SQL, saved queries list, built-in reports section still rendering. Same as before.
- [ ] Step 6: Commit
git add apps/admin/src/admin/analytics/console/ConsolePanelLegacy.jsx apps/admin/src/admin/analytics/BigQueryWorkspace.jsx
git commit -m "refactor(admin/bq): move ConsolePanel into console/ (legacy, unchanged)"Phase 2 โ New persistence and state hooks (TDD) โ
Task 7: useConsolePersistence hook โ
Stores rail width, rail collapsed, active tab, editor split height in localStorage keyed by admin id (when available).
Files:
Create:
apps/admin/src/admin/analytics/console/useConsolePersistence.jsTest:
apps/admin/src/admin/analytics/console/__tests__/useConsolePersistence.test.js[ ] Step 1: Write the failing tests
apps/admin/src/admin/analytics/console/__tests__/useConsolePersistence.test.js:
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useConsolePersistence } from '../useConsolePersistence'
describe('useConsolePersistence', () => {
beforeEach(() => {
localStorage.clear()
})
it('returns defaults when no stored values', () => {
const { result } = renderHook(() => useConsolePersistence('admin-1'))
expect(result.current.railWidth).toBe(280)
expect(result.current.railCollapsed).toBe(false)
expect(result.current.activeTab).toBe('schema')
expect(result.current.editorHeight).toBe(180)
})
it('reads previously stored values', () => {
localStorage.setItem('bq-console:admin-1:rail:width', '320')
localStorage.setItem('bq-console:admin-1:rail:collapsed', 'true')
localStorage.setItem('bq-console:admin-1:rail:active-tab', 'saved')
localStorage.setItem('bq-console:admin-1:editor:height', '240')
const { result } = renderHook(() => useConsolePersistence('admin-1'))
expect(result.current.railWidth).toBe(320)
expect(result.current.railCollapsed).toBe(true)
expect(result.current.activeTab).toBe('saved')
expect(result.current.editorHeight).toBe(240)
})
it('persists updates', () => {
const { result } = renderHook(() => useConsolePersistence('admin-1'))
act(() => result.current.setRailWidth(320))
act(() => result.current.setActiveTab('history'))
expect(localStorage.getItem('bq-console:admin-1:rail:width')).toBe('320')
expect(localStorage.getItem('bq-console:admin-1:rail:active-tab')).toBe('history')
})
it('falls back to "global" namespace when adminId is empty', () => {
const { result } = renderHook(() => useConsolePersistence(null))
act(() => result.current.setRailWidth(310))
expect(localStorage.getItem('bq-console:global:rail:width')).toBe('310')
})
it('survives unparseable values by falling back to defaults', () => {
localStorage.setItem('bq-console:admin-1:rail:width', 'not-a-number')
const { result } = renderHook(() => useConsolePersistence('admin-1'))
expect(result.current.railWidth).toBe(280)
})
})- [ ] Step 2: Run the tests
npm run test --workspace=apps/admin -- console/__tests__/useConsolePersistence.test.jsExpected: FAIL โ module not found.
- [ ] Step 3: Implement the hook
apps/admin/src/admin/analytics/console/useConsolePersistence.js:
import { useCallback, useState } from 'react'
const DEFAULTS = {
railWidth: 280,
railCollapsed: false,
activeTab: 'schema',
editorHeight: 180,
}
const ALLOWED_TABS = new Set(['schema', 'saved', 'history'])
function key(adminId, suffix) {
return `bq-console:${adminId || 'global'}:${suffix}`
}
function readNumber(k, fallback) {
const raw = localStorage.getItem(k)
if (raw === null) return fallback
const n = Number(raw)
return Number.isFinite(n) ? n : fallback
}
function readBool(k, fallback) {
const raw = localStorage.getItem(k)
if (raw === null) return fallback
return raw === 'true'
}
function readTab(k, fallback) {
const raw = localStorage.getItem(k)
return ALLOWED_TABS.has(raw) ? raw : fallback
}
export function useConsolePersistence(adminId) {
const [railWidth, _setRailWidth] = useState(() =>
readNumber(key(adminId, 'rail:width'), DEFAULTS.railWidth),
)
const [railCollapsed, _setRailCollapsed] = useState(() =>
readBool(key(adminId, 'rail:collapsed'), DEFAULTS.railCollapsed),
)
const [activeTab, _setActiveTab] = useState(() =>
readTab(key(adminId, 'rail:active-tab'), DEFAULTS.activeTab),
)
const [editorHeight, _setEditorHeight] = useState(() =>
readNumber(key(adminId, 'editor:height'), DEFAULTS.editorHeight),
)
const setRailWidth = useCallback((v) => {
_setRailWidth(v)
localStorage.setItem(key(adminId, 'rail:width'), String(v))
}, [adminId])
const setRailCollapsed = useCallback((v) => {
_setRailCollapsed(v)
localStorage.setItem(key(adminId, 'rail:collapsed'), v ? 'true' : 'false')
}, [adminId])
const setActiveTab = useCallback((v) => {
if (!ALLOWED_TABS.has(v)) return
_setActiveTab(v)
localStorage.setItem(key(adminId, 'rail:active-tab'), v)
}, [adminId])
const setEditorHeight = useCallback((v) => {
_setEditorHeight(v)
localStorage.setItem(key(adminId, 'editor:height'), String(v))
}, [adminId])
return {
railWidth, setRailWidth,
railCollapsed, setRailCollapsed,
activeTab, setActiveTab,
editorHeight, setEditorHeight,
}
}- [ ] Step 4: Run tests
npm run test --workspace=apps/admin -- console/__tests__/useConsolePersistence.test.jsExpected: PASS (5 tests).
- [ ] Step 5: Commit
git add apps/admin/src/admin/analytics/console/useConsolePersistence.js apps/admin/src/admin/analytics/console/__tests__/useConsolePersistence.test.js
git commit -m "feat(admin/bq): add useConsolePersistence hook"Task 8: useConsoleHistory hook โ
localStorage-backed list of recent successful runs, capped at 25.
Files:
Create:
apps/admin/src/admin/analytics/console/useConsoleHistory.jsTest:
apps/admin/src/admin/analytics/console/__tests__/useConsoleHistory.test.js[ ] Step 1: Write the failing tests
apps/admin/src/admin/analytics/console/__tests__/useConsoleHistory.test.js:
import { describe, it, expect, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useConsoleHistory } from '../useConsoleHistory'
describe('useConsoleHistory', () => {
beforeEach(() => {
localStorage.clear()
})
it('starts empty when nothing stored', () => {
const { result } = renderHook(() => useConsoleHistory('admin-1'))
expect(result.current.entries).toEqual([])
})
it('records a run and persists it', () => {
const { result } = renderHook(() => useConsoleHistory('admin-1'))
act(() => result.current.record({ sql: 'SELECT 1', scanBytes: 100, durationMs: 50 }))
expect(result.current.entries).toHaveLength(1)
expect(result.current.entries[0]).toMatchObject({ sql: 'SELECT 1', scanBytes: 100, durationMs: 50 })
expect(typeof result.current.entries[0].ts).toBe('number')
const stored = JSON.parse(localStorage.getItem('bq-console:admin-1:history'))
expect(stored).toHaveLength(1)
})
it('newest entries are first', () => {
const { result } = renderHook(() => useConsoleHistory('admin-1'))
act(() => result.current.record({ sql: 'SELECT 1', scanBytes: 0, durationMs: 0 }))
act(() => result.current.record({ sql: 'SELECT 2', scanBytes: 0, durationMs: 0 }))
expect(result.current.entries.map((e) => e.sql)).toEqual(['SELECT 2', 'SELECT 1'])
})
it('caps at 25 entries', () => {
const { result } = renderHook(() => useConsoleHistory('admin-1'))
for (let i = 0; i < 30; i++) {
act(() => result.current.record({ sql: `SELECT ${i}`, scanBytes: 0, durationMs: 0 }))
}
expect(result.current.entries).toHaveLength(25)
expect(result.current.entries[0].sql).toBe('SELECT 29')
expect(result.current.entries[24].sql).toBe('SELECT 5')
})
it('clear() empties the list and storage', () => {
const { result } = renderHook(() => useConsoleHistory('admin-1'))
act(() => result.current.record({ sql: 'SELECT 1', scanBytes: 0, durationMs: 0 }))
act(() => result.current.clear())
expect(result.current.entries).toEqual([])
expect(localStorage.getItem('bq-console:admin-1:history')).toBeNull()
})
it('recovers silently from corrupted JSON', () => {
localStorage.setItem('bq-console:admin-1:history', '{not json')
const { result } = renderHook(() => useConsoleHistory('admin-1'))
expect(result.current.entries).toEqual([])
})
})- [ ] Step 2: Run the tests
npm run test --workspace=apps/admin -- console/__tests__/useConsoleHistory.test.jsExpected: FAIL โ module not found.
- [ ] Step 3: Implement
apps/admin/src/admin/analytics/console/useConsoleHistory.js:
import { useCallback, useState } from 'react'
const CAP = 25
function storageKey(adminId) {
return `bq-console:${adminId || 'global'}:history`
}
function readEntries(adminId) {
try {
const raw = localStorage.getItem(storageKey(adminId))
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function writeEntries(adminId, entries) {
if (entries.length === 0) {
localStorage.removeItem(storageKey(adminId))
return
}
localStorage.setItem(storageKey(adminId), JSON.stringify(entries))
}
export function useConsoleHistory(adminId) {
const [entries, setEntries] = useState(() => readEntries(adminId))
const record = useCallback(({ sql, scanBytes, durationMs }) => {
setEntries((prev) => {
const entry = { sql, scanBytes, durationMs, ts: Date.now() }
const next = [entry, ...prev].slice(0, CAP)
writeEntries(adminId, next)
return next
})
}, [adminId])
const clear = useCallback(() => {
setEntries([])
writeEntries(adminId, [])
}, [adminId])
return { entries, record, clear }
}- [ ] Step 4: Run tests
npm run test --workspace=apps/admin -- console/__tests__/useConsoleHistory.test.jsExpected: PASS (6 tests).
- [ ] Step 5: Commit
git add apps/admin/src/admin/analytics/console/useConsoleHistory.js apps/admin/src/admin/analytics/console/__tests__/useConsoleHistory.test.js
git commit -m "feat(admin/bq): add useConsoleHistory hook"Task 9: useConsoleState hook (work-surface state hub) โ
Owns the editor SQL, last estimate, last job result, current error, and a flag for isRunning. Provides actions (run, estimate, setSql, clear). Internally calls runBqQueryRaw / estimateBqQueryRaw and routes results into hooks. History is recorded on successful run.
Files:
Create:
apps/admin/src/admin/analytics/console/useConsoleState.jsTest:
apps/admin/src/admin/analytics/console/__tests__/useConsoleState.test.js[ ] Step 1: Write failing tests
apps/admin/src/admin/analytics/console/__tests__/useConsoleState.test.js:
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
vi.mock('../../../../shared/lib/analyticsApi', () => ({
runBqQueryRaw: vi.fn(),
estimateBqQueryRaw: vi.fn(),
}))
import { runBqQueryRaw, estimateBqQueryRaw } from '../../../../shared/lib/analyticsApi'
import { useConsoleState } from '../useConsoleState'
describe('useConsoleState', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts with empty SQL and no result', () => {
const { result } = renderHook(() => useConsoleState({ onRunSuccess: () => {} }))
expect(result.current.sql).toBe('')
expect(result.current.lastJob).toBeNull()
expect(result.current.lastEstimate).toBeNull()
expect(result.current.error).toBeNull()
expect(result.current.isRunning).toBe(false)
})
it('setSql updates current SQL', () => {
const { result } = renderHook(() => useConsoleState({ onRunSuccess: () => {} }))
act(() => result.current.setSql('SELECT 1'))
expect(result.current.sql).toBe('SELECT 1')
})
it('estimate stores result on success', async () => {
estimateBqQueryRaw.mockResolvedValue({ scanBytes: 1024, costUsd: 0.000001 })
const { result } = renderHook(() => useConsoleState({ onRunSuccess: () => {} }))
act(() => result.current.setSql('SELECT 1'))
await act(() => result.current.estimate())
expect(result.current.lastEstimate).toEqual({ scanBytes: 1024, costUsd: 0.000001 })
expect(result.current.error).toBeNull()
})
it('estimate stores structured error on failure', async () => {
estimateBqQueryRaw.mockRejectedValue(new Error('Permission denied'))
const { result } = renderHook(() => useConsoleState({ onRunSuccess: () => {} }))
act(() => result.current.setSql('SELECT 1'))
await act(() => result.current.estimate())
expect(result.current.error).toMatchObject({ message: 'Permission denied' })
})
it('run stores job result and calls onRunSuccess on success', async () => {
runBqQueryRaw.mockResolvedValue({
rows: [{ a: 1 }],
schema: [{ name: 'a', type: 'INT64' }],
jobMeta: { scanBytes: 200, durationMs: 50, cacheHit: false },
})
const onRunSuccess = vi.fn()
const { result } = renderHook(() => useConsoleState({ onRunSuccess }))
act(() => result.current.setSql('SELECT 1 AS a'))
await act(() => result.current.run())
expect(result.current.lastJob).not.toBeNull()
expect(result.current.lastJob.rows).toEqual([{ a: 1 }])
expect(onRunSuccess).toHaveBeenCalledWith(
expect.objectContaining({ sql: 'SELECT 1 AS a', scanBytes: 200, durationMs: 50 }),
)
})
it('run sets isRunning during the call', async () => {
let resolve
runBqQueryRaw.mockReturnValue(new Promise((r) => { resolve = r }))
const { result } = renderHook(() => useConsoleState({ onRunSuccess: () => {} }))
act(() => result.current.setSql('SELECT 1'))
act(() => { result.current.run() })
expect(result.current.isRunning).toBe(true)
await act(async () => {
resolve({ rows: [], schema: [], jobMeta: { scanBytes: 0, durationMs: 0 } })
})
await waitFor(() => expect(result.current.isRunning).toBe(false))
})
it('run does not call onRunSuccess on failure', async () => {
runBqQueryRaw.mockRejectedValue(new Error('Bad SQL'))
const onRunSuccess = vi.fn()
const { result } = renderHook(() => useConsoleState({ onRunSuccess }))
act(() => result.current.setSql('SELECT bad'))
await act(() => result.current.run())
expect(onRunSuccess).not.toHaveBeenCalled()
expect(result.current.error).toMatchObject({ message: 'Bad SQL' })
})
it('clear empties sql, lastJob, lastEstimate, error', async () => {
runBqQueryRaw.mockResolvedValue({ rows: [{}], schema: [], jobMeta: { scanBytes: 0, durationMs: 0 } })
const { result } = renderHook(() => useConsoleState({ onRunSuccess: () => {} }))
act(() => result.current.setSql('SELECT 1'))
await act(() => result.current.run())
act(() => result.current.clear())
expect(result.current.sql).toBe('')
expect(result.current.lastJob).toBeNull()
expect(result.current.error).toBeNull()
})
})- [ ] Step 2: Run the tests
npm run test --workspace=apps/admin -- console/__tests__/useConsoleState.test.jsExpected: FAIL โ module not found.
- [ ] Step 3: Implement
apps/admin/src/admin/analytics/console/useConsoleState.js:
import { useCallback, useState } from 'react'
import { runBqQueryRaw, estimateBqQueryRaw } from '../../../shared/lib/analyticsApi'
import { structuredError } from './utils'
export function useConsoleState({ onRunSuccess }) {
const [sql, setSql] = useState('')
const [lastEstimate, setLastEstimate] = useState(null)
const [lastJob, setLastJob] = useState(null)
const [error, setError] = useState(null)
const [isRunning, setIsRunning] = useState(false)
const estimate = useCallback(async () => {
setError(null)
try {
const res = await estimateBqQueryRaw({ sql })
setLastEstimate(res)
} catch (err) {
setError(structuredError(err))
}
}, [sql])
const run = useCallback(async () => {
setError(null)
setIsRunning(true)
try {
const res = await runBqQueryRaw({ sql })
setLastJob(res)
onRunSuccess({
sql,
scanBytes: res?.jobMeta?.scanBytes ?? 0,
durationMs: res?.jobMeta?.durationMs ?? 0,
})
} catch (err) {
setError(structuredError(err))
} finally {
setIsRunning(false)
}
}, [sql, onRunSuccess])
const clear = useCallback(() => {
setSql('')
setLastEstimate(null)
setLastJob(null)
setError(null)
}, [])
return {
sql, setSql,
lastEstimate, lastJob,
error, isRunning,
estimate, run, clear,
}
}- [ ] Step 4: Run tests
npm run test --workspace=apps/admin -- console/__tests__/useConsoleState.test.jsExpected: PASS (8 tests).
- [ ] Step 5: Commit
git add apps/admin/src/admin/analytics/console/useConsoleState.js apps/admin/src/admin/analytics/console/__tests__/useConsoleState.test.js
git commit -m "feat(admin/bq): add useConsoleState orchestrator hook"Phase 3 โ New components (TDD, presentational where possible) โ
Task 10: ConsoleToolbar component โ
Renders Run, Estimate, Save, Format, Clear buttons, the limits pill, and the fullscreen toggle. Receives all callbacks via props (no state).
Files:
Create:
apps/admin/src/admin/analytics/console/ConsoleToolbar.jsxTest:
apps/admin/src/admin/analytics/console/__tests__/ConsoleToolbar.test.jsx[ ] Step 1: Write failing tests
apps/admin/src/admin/analytics/console/__tests__/ConsoleToolbar.test.jsx:
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import ConsoleToolbar from '../ConsoleToolbar'
const noop = () => {}
const baseProps = {
isRunning: false,
isFullscreen: false,
onRun: noop,
onEstimate: noop,
onSave: noop,
onFormat: noop,
onClear: noop,
onToggleFullscreen: noop,
}
describe('ConsoleToolbar', () => {
it('renders all five primary buttons and the limits pill', () => {
render(<ConsoleToolbar {...baseProps} />)
expect(screen.getByRole('button', { name: /^run$/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /estimate/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^save$/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /format/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /clear/i })).toBeInTheDocument()
expect(screen.getByText(/SELECT only ยท 1 GB ยท 5k rows/i)).toBeInTheDocument()
})
it('Run button calls onRun', () => {
const onRun = vi.fn()
render(<ConsoleToolbar {...baseProps} onRun={onRun} />)
fireEvent.click(screen.getByRole('button', { name: /^run$/i }))
expect(onRun).toHaveBeenCalledTimes(1)
})
it('disables Run/Estimate while isRunning', () => {
render(<ConsoleToolbar {...baseProps} isRunning />)
expect(screen.getByRole('button', { name: /^run$/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /estimate/i })).toBeDisabled()
})
it('Format calls onFormat', () => {
const onFormat = vi.fn()
render(<ConsoleToolbar {...baseProps} onFormat={onFormat} />)
fireEvent.click(screen.getByRole('button', { name: /format/i }))
expect(onFormat).toHaveBeenCalledTimes(1)
})
it('fullscreen toggle calls onToggleFullscreen', () => {
const onToggleFullscreen = vi.fn()
render(<ConsoleToolbar {...baseProps} onToggleFullscreen={onToggleFullscreen} />)
fireEvent.click(screen.getByRole('button', { name: /fullscreen/i }))
expect(onToggleFullscreen).toHaveBeenCalledTimes(1)
})
it('shows "Exit fullscreen" label when isFullscreen', () => {
render(<ConsoleToolbar {...baseProps} isFullscreen />)
expect(screen.getByRole('button', { name: /exit fullscreen/i })).toBeInTheDocument()
})
})- [ ] Step 2: Run tests โ Expected: FAIL.
npm run test --workspace=apps/admin -- console/__tests__/ConsoleToolbar.test.jsx- [ ] Step 3: Implement
apps/admin/src/admin/analytics/console/ConsoleToolbar.jsx:
import React from 'react'
import { Play, Calculator, Save, Wand2, Trash2, Maximize2, Minimize2 } from 'lucide-react'
export default function ConsoleToolbar({
isRunning,
isFullscreen,
onRun,
onEstimate,
onSave,
onFormat,
onClear,
onToggleFullscreen,
}) {
return (
<div className="bq-console-toolbar" role="toolbar" aria-label="Query console toolbar">
<button
type="button"
className="bq-btn bq-btn--primary"
onClick={onRun}
disabled={isRunning}
>
<Play size={14} /> Run
</button>
<button
type="button"
className="bq-btn"
onClick={onEstimate}
disabled={isRunning}
>
<Calculator size={14} /> Estimate
</button>
<button type="button" className="bq-btn" onClick={onSave}>
<Save size={14} /> Save
</button>
<button type="button" className="bq-btn bq-btn--ghost" onClick={onFormat}>
<Wand2 size={14} /> Format
</button>
<button type="button" className="bq-btn bq-btn--ghost" onClick={onClear}>
<Trash2 size={14} /> Clear
</button>
<span className="bq-console-toolbar__spacer" />
<span className="bq-console-toolbar__limits-pill" title="Server enforces SELECT-only queries, 1 GB scan ceiling, 5,000 row cap">
SELECT only ยท 1 GB ยท 5k rows
</span>
<button
type="button"
className="bq-btn bq-btn--ghost"
onClick={onToggleFullscreen}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
{isFullscreen ? ' Exit fullscreen' : ' Fullscreen'}
</button>
</div>
)
}[ ] Step 4: Run tests โ Expected: PASS (6 tests).
[ ] Step 5: Commit
git add apps/admin/src/admin/analytics/console/ConsoleToolbar.jsx apps/admin/src/admin/analytics/console/__tests__/ConsoleToolbar.test.jsx
git commit -m "feat(admin/bq): add ConsoleToolbar component"Task 11: ConsoleEditor component โ
Wraps RawSqlEditor for SQL input and renders a drag handle below the editor that resizes the editor height. The editor will surface the SQL via an onChange(sql) prop and accept value/sql to drive it.
Note: the existing
RawSqlEditoris aforwardRefthat exposesinsertAtCursor(text)andloadSql(sql). It owns its own internal SQL state, run/estimate logic, and result rendering โ which we need to decouple. Step 1 below tells you how.
Files:
Modify:
apps/admin/src/admin/analytics/console/RawSqlEditor.jsxโ strip its run/estimate/result rendering responsibilities; turn it into a pure editor that emits SQL viaonChangeand exposesinsertAtCursor+setSqlvia ref.Create:
apps/admin/src/admin/analytics/console/ConsoleEditor.jsxTest:
apps/admin/src/admin/analytics/console/__tests__/ConsoleEditor.test.jsx[ ] Step 1: Reduce
RawSqlEditorresponsibilities
In console/RawSqlEditor.jsx:
- Add props:
value(controlled SQL string) andonChange(sql)(called on user input). - Remove these from inside the component:
handleSaveSubmit,clearOutputs,handleEstimate,handleRun, the result/error/estimate state, the<SectionHeader>/<ResultMetaFooter>/<ErrorCard>rendering, the section wrapper, and any save-form state. - Keep: the
<textarea>(or whatever editor primitive it uses),forwardRefexposinginsertAtCursor(text)(which now needs to callprops.onChange(newValue)after computing the new string). - Remove imports that are no longer used (e.g.
runBqQueryRaw,estimateBqQueryRaw,saveAdminQuery,QueryResultTable,ErrorCard, etc.). - The default export remains a
forwardRefthat renders the textarea bound tovalueand callsonChangeon user typing.
The save-form UX (name + description + submit) does NOT move into ConsoleEditor โ it becomes a separate SaveQueryDialog component owned by ConsolePanel (Task 15b below).
After this change, the legacy ConsolePanelLegacy wired through this component will break. We'll fix it in Task 16 by retiring the legacy panel โ leave it broken until then; the test suite for the new components is what we run going forward.
- [ ] Step 2: Add a unit test for the simplified
RawSqlEditor
apps/admin/src/admin/analytics/console/__tests__/RawSqlEditor.test.jsx:
import React, { useRef } from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import RawSqlEditor from '../RawSqlEditor'
describe('RawSqlEditor (simplified)', () => {
it('renders the controlled value', () => {
render(<RawSqlEditor value="SELECT 1" onChange={() => {}} />)
expect(screen.getByRole('textbox')).toHaveValue('SELECT 1')
})
it('calls onChange as user types', () => {
const onChange = vi.fn()
render(<RawSqlEditor value="" onChange={onChange} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'SELECT 2' } })
expect(onChange).toHaveBeenCalledWith('SELECT 2')
})
it('exposes insertAtCursor via ref', () => {
function Harness() {
const ref = useRef()
const [sql, setSql] = React.useState('AB')
return (
<>
<RawSqlEditor ref={ref} value={sql} onChange={setSql} />
<button onClick={() => ref.current.insertAtCursor('C')}>insert</button>
</>
)
}
render(<Harness />)
fireEvent.click(screen.getByRole('button', { name: /insert/i }))
// insertAtCursor inserts at the current selection โ for default selection (end), result is 'ABC'
expect(screen.getByRole('textbox')).toHaveValue('ABC')
})
})- [ ] Step 3: Run โ Expected: PASS (3 tests).
npm run test --workspace=apps/admin -- console/__tests__/RawSqlEditor.test.jsxIf a test fails because insertAtCursor doesn't update through the controlled onChange, update insertAtCursor to call props.onChange(newValue) after computing the new string.
- [ ] Step 4: Write failing tests for
ConsoleEditor
apps/admin/src/admin/analytics/console/__tests__/ConsoleEditor.test.jsx:
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import ConsoleEditor from '../ConsoleEditor'
describe('ConsoleEditor', () => {
it('renders the SQL text', () => {
render(<ConsoleEditor sql="SELECT 1" onSqlChange={() => {}} height={200} onHeightChange={() => {}} />)
expect(screen.getByRole('textbox')).toHaveValue('SELECT 1')
})
it('calls onSqlChange when user types', () => {
const onSqlChange = vi.fn()
render(<ConsoleEditor sql="" onSqlChange={onSqlChange} height={200} onHeightChange={() => {}} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'SELECT 2' } })
expect(onSqlChange).toHaveBeenCalledWith('SELECT 2')
})
it('exposes a drag handle for resizing', () => {
render(<ConsoleEditor sql="" onSqlChange={() => {}} height={200} onHeightChange={() => {}} />)
expect(screen.getByTestId('console-editor-resize-handle')).toBeInTheDocument()
})
it('forwards ref methods to RawSqlEditor', () => {
const ref = React.createRef()
render(<ConsoleEditor ref={ref} sql="AB" onSqlChange={() => {}} height={200} onHeightChange={() => {}} />)
expect(typeof ref.current.insertAtCursor).toBe('function')
})
})[ ] Step 5: Run โ Expected: FAIL (module missing).
[ ] Step 6: Implement
apps/admin/src/admin/analytics/console/ConsoleEditor.jsx:
import React, { forwardRef, useCallback, useRef } from 'react'
import RawSqlEditor from './RawSqlEditor'
const MIN_HEIGHT = 80
const MAX_HEIGHT = 600
const ConsoleEditor = forwardRef(function ConsoleEditor(
{ sql, onSqlChange, height, onHeightChange },
ref,
) {
const dragStateRef = useRef(null)
const onMouseDown = useCallback((e) => {
e.preventDefault()
dragStateRef.current = { startY: e.clientY, startHeight: height }
function onMove(ev) {
const { startY, startHeight } = dragStateRef.current
const next = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, startHeight + (ev.clientY - startY)))
onHeightChange(next)
}
function onUp() {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
dragStateRef.current = null
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}, [height, onHeightChange])
return (
<div className="bq-console-editor" style={{ height }}>
<RawSqlEditor ref={ref} value={sql} onChange={onSqlChange} />
<div
className="bq-console-editor__resize-handle"
data-testid="console-editor-resize-handle"
onMouseDown={onMouseDown}
role="separator"
aria-orientation="horizontal"
aria-label="Resize editor"
/>
</div>
)
})
export default ConsoleEditor- [ ] Step 7: Run all tests โ Expected: PASS.
npm run test --workspace=apps/admin -- console/__tests__/RawSqlEditor.test.jsx console/__tests__/ConsoleEditor.test.jsx- [ ] Step 8: Commit
git add apps/admin/src/admin/analytics/console/RawSqlEditor.jsx apps/admin/src/admin/analytics/console/ConsoleEditor.jsx apps/admin/src/admin/analytics/console/__tests__/RawSqlEditor.test.jsx apps/admin/src/admin/analytics/console/__tests__/ConsoleEditor.test.jsx
git commit -m "feat(admin/bq): simplify RawSqlEditor + add ConsoleEditor with resize handle"Task 12: ConsoleMetaStrip component โ
Renders last estimate, last job meta, and inline error.
Files:
Create:
apps/admin/src/admin/analytics/console/ConsoleMetaStrip.jsxTest:
apps/admin/src/admin/analytics/console/__tests__/ConsoleMetaStrip.test.jsx[ ] Step 1: Write failing tests
apps/admin/src/admin/analytics/console/__tests__/ConsoleMetaStrip.test.jsx:
import React from 'react'
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import ConsoleMetaStrip from '../ConsoleMetaStrip'
describe('ConsoleMetaStrip', () => {
it('shows placeholder when no estimate or job', () => {
render(<ConsoleMetaStrip lastEstimate={null} lastJob={null} error={null} />)
expect(screen.getByText(/no query run yet/i)).toBeInTheDocument()
})
it('shows estimate when present', () => {
render(
<ConsoleMetaStrip
lastEstimate={{ scanBytes: 1024 * 1024, costUsd: 0.0001 }}
lastJob={null}
error={null}
/>,
)
expect(screen.getByText(/estimate/i)).toBeInTheDocument()
expect(screen.getByText(/1 MB/)).toBeInTheDocument()
expect(screen.getByText(/\$0\.0001/)).toBeInTheDocument()
})
it('shows job meta when present', () => {
render(
<ConsoleMetaStrip
lastEstimate={null}
lastJob={{ jobMeta: { scanBytes: 2048, durationMs: 1234, cacheHit: false } }}
error={null}
/>,
)
expect(screen.getByText(/1\.2s|1234 ms/i)).toBeInTheDocument()
expect(screen.getByText(/cache miss/i)).toBeInTheDocument()
})
it('shows error inline when present', () => {
render(
<ConsoleMetaStrip
lastEstimate={null}
lastJob={null}
error={{ code: 'BAD_SQL', message: 'syntax error near FROM' }}
/>,
)
expect(screen.getByText(/syntax error near FROM/)).toBeInTheDocument()
expect(screen.getByRole('alert')).toBeInTheDocument()
})
})[ ] Step 2: Run โ Expected: FAIL.
[ ] Step 3: Implement
apps/admin/src/admin/analytics/console/ConsoleMetaStrip.jsx:
import React from 'react'
import { AlertTriangle, CheckCircle2 } from 'lucide-react'
import { formatBytes } from './utils'
function formatDuration(ms) {
if (!Number.isFinite(ms)) return 'โ'
if (ms < 1000) return `${ms} ms`
return `${(ms / 1000).toFixed(1)}s`
}
export default function ConsoleMetaStrip({ lastEstimate, lastJob, error }) {
if (error) {
return (
<div className="bq-console-meta bq-console-meta--error" role="alert">
<AlertTriangle size={14} />
<span>{error.message || 'Query failed'}</span>
{error.code && <span className="bq-console-meta__code">[{error.code}]</span>}
</div>
)
}
if (!lastEstimate && !lastJob) {
return (
<div className="bq-console-meta bq-console-meta--empty">
No query run yet.
</div>
)
}
return (
<div className="bq-console-meta">
{lastEstimate && (
<span className="bq-console-meta__est">
<CheckCircle2 size={12} /> Last estimate: {formatBytes(lastEstimate.scanBytes)} ยท ${lastEstimate.costUsd?.toFixed(6) ?? '0.000000'}
</span>
)}
{lastJob?.jobMeta && (
<span className="bq-console-meta__job">
Job: {formatDuration(lastJob.jobMeta.durationMs)} ยท {formatBytes(lastJob.jobMeta.scanBytes)} ยท {lastJob.jobMeta.cacheHit ? 'cache hit' : 'cache miss'}
</span>
)}
</div>
)
}[ ] Step 4: Run โ Expected: PASS (4 tests).
[ ] Step 5: Commit
git add apps/admin/src/admin/analytics/console/ConsoleMetaStrip.jsx apps/admin/src/admin/analytics/console/__tests__/ConsoleMetaStrip.test.jsx
git commit -m "feat(admin/bq): add ConsoleMetaStrip component"Task 13: ConsoleResults component (with row filter, CSV, row-expand) โ
Files:
Create:
apps/admin/src/admin/analytics/console/ConsoleResults.jsxTest:
apps/admin/src/admin/analytics/console/__tests__/ConsoleResults.test.jsx[ ] Step 1: Write failing tests
apps/admin/src/admin/analytics/console/__tests__/ConsoleResults.test.jsx:
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, within } from '@testing-library/react'
import ConsoleResults from '../ConsoleResults'
const sampleJob = {
rows: [
{ id: 'row-aaaaaaaaaaaa-1', name: 'apple' },
{ id: 'row-bbbbbbbbbbbb-2', name: 'banana' },
{ id: 'row-cccccccccccc-3', name: 'cherry' },
],
schema: [
{ name: 'id', type: 'STRING' },
{ name: 'name', type: 'STRING' },
],
jobMeta: { scanBytes: 100, durationMs: 50 },
}
describe('ConsoleResults', () => {
beforeEach(() => {
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue() } })
})
it('renders empty state when no job', () => {
render(<ConsoleResults job={null} isRunning={false} />)
expect(screen.getByText(/run a query to see results/i)).toBeInTheDocument()
})
it('renders rows when job present', () => {
render(<ConsoleResults job={sampleJob} isRunning={false} />)
expect(screen.getByText('apple')).toBeInTheDocument()
expect(screen.getByText('banana')).toBeInTheDocument()
expect(screen.getByText('cherry')).toBeInTheDocument()
})
it('filters rows via the filter input', () => {
render(<ConsoleResults job={sampleJob} isRunning={false} />)
fireEvent.change(screen.getByPlaceholderText(/filter rows/i), { target: { value: 'banana' } })
expect(screen.queryByText('apple')).not.toBeInTheDocument()
expect(screen.getByText('banana')).toBeInTheDocument()
})
it('expands a row on click and shows full values', () => {
render(<ConsoleResults job={sampleJob} isRunning={false} />)
fireEvent.click(screen.getByText('apple'))
const expandedPanel = screen.getByTestId('console-row-expand')
expect(within(expandedPanel).getByText('row-aaaaaaaaaaaa-1')).toBeInTheDocument()
})
it('Copy CSV writes a CSV string to the clipboard', () => {
render(<ConsoleResults job={sampleJob} isRunning={false} />)
fireEvent.click(screen.getByRole('button', { name: /copy csv/i }))
const written = navigator.clipboard.writeText.mock.calls[0][0]
expect(written.split('\n')[0]).toBe('id,name')
expect(written).toContain('row-aaaaaaaaaaaa-1,apple')
})
it('shows skeleton state when isRunning', () => {
render(<ConsoleResults job={null} isRunning />)
expect(screen.getByTestId('console-results-skeleton')).toBeInTheDocument()
})
})[ ] Step 2: Run โ Expected: FAIL.
[ ] Step 3: Implement
apps/admin/src/admin/analytics/console/ConsoleResults.jsx:
import React, { useMemo, useState } from 'react'
import { ClipboardCopy, Download } from 'lucide-react'
function toCsv(schema, rows) {
const header = schema.map((c) => c.name).join(',')
const body = rows.map((row) =>
schema.map((c) => {
const v = row[c.name]
if (v === null || v === undefined) return ''
const s = String(v)
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
}).join(','),
).join('\n')
return body ? `${header}\n${body}` : header
}
function downloadCsv(filename, text) {
const blob = new Blob([text], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
export default function ConsoleResults({ job, isRunning }) {
const [filter, setFilter] = useState('')
const [expandedIndex, setExpandedIndex] = useState(null)
const filtered = useMemo(() => {
if (!job) return []
if (!filter.trim()) return job.rows
const needle = filter.toLowerCase()
return job.rows.filter((row) =>
job.schema.some((c) => String(row[c.name] ?? '').toLowerCase().includes(needle)),
)
}, [job, filter])
if (isRunning) {
return (
<div className="bq-console-results bq-console-results--loading" data-testid="console-results-skeleton">
<div className="bq-skeleton-row" />
<div className="bq-skeleton-row" />
<div className="bq-skeleton-row" />
</div>
)
}
if (!job) {
return (
<div className="bq-console-results bq-console-results--empty">
Run a query to see results. <kbd>Cmd/Ctrl + Enter</kbd>
</div>
)
}
return (
<div className="bq-console-results">
<div className="bq-console-results__toolbar">
<input
type="text"
className="bq-console-results__filter"
placeholder="Filter rowsโฆ"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<span className="bq-console-results__count">{filtered.length} of {job.rows.length}</span>
<span className="bq-console-toolbar__spacer" />
<button
type="button"
className="bq-btn bq-btn--ghost"
onClick={() => navigator.clipboard.writeText(toCsv(job.schema, filtered))}
>
<ClipboardCopy size={14} /> Copy CSV
</button>
<button
type="button"
className="bq-btn bq-btn--ghost"
onClick={() => downloadCsv('query-results.csv', toCsv(job.schema, filtered))}
>
<Download size={14} /> Download
</button>
</div>
<table className="bq-console-results__table">
<thead>
<tr>{job.schema.map((c) => <th key={c.name}>{c.name}</th>)}</tr>
</thead>
<tbody>
{filtered.map((row, i) => (
<React.Fragment key={i}>
<tr onClick={() => setExpandedIndex(expandedIndex === i ? null : i)}>
{job.schema.map((c) => <td key={c.name}>{String(row[c.name] ?? '')}</td>)}
</tr>
{expandedIndex === i && (
<tr>
<td colSpan={job.schema.length}>
<dl className="bq-console-results__expand" data-testid="console-row-expand">
{job.schema.map((c) => (
<React.Fragment key={c.name}>
<dt>{c.name}</dt>
<dd>{String(row[c.name] ?? '')}</dd>
</React.Fragment>
))}
</dl>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
)
}[ ] Step 4: Run โ Expected: PASS (6 tests).
[ ] Step 5: Commit
git add apps/admin/src/admin/analytics/console/ConsoleResults.jsx apps/admin/src/admin/analytics/console/__tests__/ConsoleResults.test.jsx
git commit -m "feat(admin/bq): add ConsoleResults with filter, expand, CSV"Task 14: HistoryPanel component (rail tab) โ
Files:
Create:
apps/admin/src/admin/analytics/console/HistoryPanel.jsxTest:
apps/admin/src/admin/analytics/console/__tests__/HistoryPanel.test.jsx[ ] Step 1: Write failing tests
apps/admin/src/admin/analytics/console/__tests__/HistoryPanel.test.jsx:
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import HistoryPanel from '../HistoryPanel'
const entries = [
{ sql: 'SELECT 1', ts: Date.now() - 60_000, scanBytes: 200, durationMs: 50 },
{ sql: 'SELECT * FROM events LIMIT 10', ts: Date.now() - 5_000, scanBytes: 711680, durationMs: 1200 },
]
describe('HistoryPanel', () => {
it('shows empty state when no entries', () => {
render(<HistoryPanel entries={[]} onLoad={() => {}} onClear={() => {}} />)
expect(screen.getByText(/no recent queries/i)).toBeInTheDocument()
})
it('renders an entry per item, newest visible', () => {
render(<HistoryPanel entries={entries} onLoad={() => {}} onClear={() => {}} />)
expect(screen.getByText('SELECT 1')).toBeInTheDocument()
expect(screen.getByText(/SELECT \* FROM events LIMIT 10/)).toBeInTheDocument()
})
it('calls onLoad with the SQL when an entry is clicked', () => {
const onLoad = vi.fn()
render(<HistoryPanel entries={entries} onLoad={onLoad} onClear={() => {}} />)
fireEvent.click(screen.getByText('SELECT 1'))
expect(onLoad).toHaveBeenCalledWith('SELECT 1')
})
it('calls onClear when "Clear history" is clicked', () => {
const onClear = vi.fn()
render(<HistoryPanel entries={entries} onLoad={() => {}} onClear={onClear} />)
fireEvent.click(screen.getByRole('button', { name: /clear history/i }))
expect(onClear).toHaveBeenCalledTimes(1)
})
})[ ] Step 2: Run โ Expected: FAIL.
[ ] Step 3: Implement
apps/admin/src/admin/analytics/console/HistoryPanel.jsx:
import React from 'react'
import { formatBytes } from './utils'
function formatRelative(ts) {
const diffMs = Date.now() - ts
if (diffMs < 60_000) return `${Math.round(diffMs / 1000)}s ago`
if (diffMs < 3_600_000) return `${Math.round(diffMs / 60_000)}m ago`
return new Date(ts).toLocaleString()
}
function formatDuration(ms) {
if (ms < 1000) return `${ms} ms`
return `${(ms / 1000).toFixed(1)}s`
}
export default function HistoryPanel({ entries, onLoad, onClear }) {
if (entries.length === 0) {
return (
<div className="bq-console-history bq-console-history--empty">
No recent queries. Run a query and it'll appear here.
</div>
)
}
return (
<div className="bq-console-history">
<div className="bq-console-history__header">
<span>{entries.length} recent</span>
<button type="button" className="bq-btn bq-btn--ghost bq-btn--small" onClick={onClear}>
Clear history
</button>
</div>
<ul className="bq-console-history__list">
{entries.map((e, i) => (
<li key={i} className="bq-console-history__item">
<button
type="button"
className="bq-console-history__item-button"
onClick={() => onLoad(e.sql)}
title={e.sql}
>
<span className="bq-console-history__sql">{e.sql.length > 80 ? `${e.sql.slice(0, 77)}โฆ` : e.sql}</span>
<span className="bq-console-history__meta">
{formatRelative(e.ts)} ยท {formatBytes(e.scanBytes)} ยท {formatDuration(e.durationMs)}
</span>
</button>
</li>
))}
</ul>
</div>
)
}[ ] Step 4: Run โ Expected: PASS (4 tests).
[ ] Step 5: Commit
git add apps/admin/src/admin/analytics/console/HistoryPanel.jsx apps/admin/src/admin/analytics/console/__tests__/HistoryPanel.test.jsx
git commit -m "feat(admin/bq): add HistoryPanel component"Task 15: ConsoleRail component (tabbed left rail) โ
Files:
Create:
apps/admin/src/admin/analytics/console/ConsoleRail.jsxTest:
apps/admin/src/admin/analytics/console/__tests__/ConsoleRail.test.jsx[ ] Step 1: Write failing tests
apps/admin/src/admin/analytics/console/__tests__/ConsoleRail.test.jsx:
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import ConsoleRail from '../ConsoleRail'
vi.mock('../SchemaBrowser', () => ({
default: () => <div data-testid="schema-browser-stub">schema</div>,
}))
vi.mock('../SavedQueriesPanel', () => ({
default: ({ onLoad }) => (
<button data-testid="saved-stub" onClick={() => onLoad('SELECT 1')}>saved</button>
),
}))
const baseProps = {
activeTab: 'schema',
onTabChange: () => {},
width: 280,
onWidthChange: () => {},
collapsed: false,
onToggleCollapsed: () => {},
history: [],
onLoadHistory: () => {},
onClearHistory: () => {},
savedCount: 0,
onSchemaInsert: () => {},
onSavedLoad: () => {},
}
describe('ConsoleRail', () => {
it('renders three tabs: Schema, Saved, History', () => {
render(<ConsoleRail {...baseProps} />)
expect(screen.getByRole('tab', { name: /schema/i })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /saved/i })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /history/i })).toBeInTheDocument()
})
it('shows the saved count in the saved tab label when > 0', () => {
render(<ConsoleRail {...baseProps} savedCount={3} />)
expect(screen.getByRole('tab', { name: /saved \(3\)/i })).toBeInTheDocument()
})
it('calls onTabChange when a tab is clicked', () => {
const onTabChange = vi.fn()
render(<ConsoleRail {...baseProps} onTabChange={onTabChange} />)
fireEvent.click(screen.getByRole('tab', { name: /history/i }))
expect(onTabChange).toHaveBeenCalledWith('history')
})
it('renders the active tab content (schema by default)', () => {
render(<ConsoleRail {...baseProps} />)
expect(screen.getByTestId('schema-browser-stub')).toBeInTheDocument()
})
it('renders saved tab content when activeTab=saved', () => {
render(<ConsoleRail {...baseProps} activeTab="saved" />)
expect(screen.getByTestId('saved-stub')).toBeInTheDocument()
})
it('renders history tab content when activeTab=history', () => {
render(<ConsoleRail {...baseProps} activeTab="history" />)
expect(screen.getByText(/no recent queries/i)).toBeInTheDocument()
})
it('renders only the toggle when collapsed', () => {
render(<ConsoleRail {...baseProps} collapsed />)
expect(screen.queryByRole('tab', { name: /schema/i })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /expand rail/i })).toBeInTheDocument()
})
})[ ] Step 2: Run โ Expected: FAIL.
[ ] Step 3: Implement
apps/admin/src/admin/analytics/console/ConsoleRail.jsx:
import React, { useCallback, useRef } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import SchemaBrowser from './SchemaBrowser'
import SavedQueriesPanel from './SavedQueriesPanel'
import HistoryPanel from './HistoryPanel'
const MIN_WIDTH = 200
const MAX_WIDTH = 480
const COLLAPSED_WIDTH = 32
const TABS = [
{ id: 'schema', label: 'Schema' },
{ id: 'saved', label: 'Saved' },
{ id: 'history', label: 'History' },
]
export default function ConsoleRail({
activeTab,
onTabChange,
width,
onWidthChange,
collapsed,
onToggleCollapsed,
history,
onLoadHistory,
onClearHistory,
savedCount,
onSchemaInsert,
onSavedLoad,
onSavedCountChange,
}) {
const dragStateRef = useRef(null)
const onMouseDown = useCallback((e) => {
e.preventDefault()
dragStateRef.current = { startX: e.clientX, startWidth: width }
function onMove(ev) {
const { startX, startWidth } = dragStateRef.current
const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + (ev.clientX - startX)))
onWidthChange(next)
}
function onUp() {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
dragStateRef.current = null
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}, [width, onWidthChange])
if (collapsed) {
return (
<aside className="bq-console-rail bq-console-rail--collapsed" style={{ width: COLLAPSED_WIDTH }}>
<button
type="button"
className="bq-console-rail__toggle"
onClick={onToggleCollapsed}
aria-label="Expand rail"
>
<ChevronRight size={14} />
</button>
</aside>
)
}
return (
<aside className="bq-console-rail" style={{ width }}>
<div className="bq-console-rail__header">
<div className="bq-console-rail__tabs" role="tablist">
{TABS.map((t) => {
const label = t.id === 'saved' && savedCount > 0 ? `${t.label} (${savedCount})` : t.label
const isActive = activeTab === t.id
return (
<button
key={t.id}
role="tab"
aria-selected={isActive}
className={`bq-console-rail__tab${isActive ? ' active' : ''}`}
onClick={() => onTabChange(t.id)}
>
{label}
</button>
)
})}
</div>
<button
type="button"
className="bq-console-rail__toggle"
onClick={onToggleCollapsed}
aria-label="Collapse rail"
>
<ChevronLeft size={14} />
</button>
</div>
<div className="bq-console-rail__body">
{activeTab === 'schema' && <SchemaBrowser onInsert={onSchemaInsert} />}
{activeTab === 'saved' && <SavedQueriesPanel onLoad={onSavedLoad} onCountChange={onSavedCountChange} />}
{activeTab === 'history' && (
<HistoryPanel entries={history} onLoad={onLoadHistory} onClear={onClearHistory} />
)}
</div>
<div
className="bq-console-rail__resize-handle"
onMouseDown={onMouseDown}
role="separator"
aria-orientation="vertical"
aria-label="Resize rail"
/>
</aside>
)
}[ ] Step 4: Run โ Expected: PASS (7 tests).
[ ] Step 5: Commit
git add apps/admin/src/admin/analytics/console/ConsoleRail.jsx apps/admin/src/admin/analytics/console/__tests__/ConsoleRail.test.jsx
git commit -m "feat(admin/bq): add ConsoleRail tabbed component"Task 15b: SaveQueryDialog component โ
Owns the save-form UX that previously lived inside RawSqlEditor. Controlled by ConsolePanel (open/close + which SQL to save).
Files:
Create:
apps/admin/src/admin/analytics/console/SaveQueryDialog.jsxTest:
apps/admin/src/admin/analytics/console/__tests__/SaveQueryDialog.test.jsx[ ] Step 1: Write failing tests
apps/admin/src/admin/analytics/console/__tests__/SaveQueryDialog.test.jsx:
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
vi.mock('../../savedQueries.service', () => ({
saveQuery: vi.fn().mockResolvedValue({ id: 'new-id' }),
}))
import { saveQuery } from '../../savedQueries.service'
import SaveQueryDialog from '../SaveQueryDialog'
describe('SaveQueryDialog', () => {
it('renders nothing when closed', () => {
const { container } = render(
<SaveQueryDialog open={false} sql="SELECT 1" onClose={() => {}} onSaved={() => {}} />,
)
expect(container.firstChild).toBeNull()
})
it('renders form fields when open', () => {
render(
<SaveQueryDialog open sql="SELECT 1" onClose={() => {}} onSaved={() => {}} />,
)
expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
expect(screen.getByLabelText(/description/i)).toBeInTheDocument()
})
it('submits with name + description + sql and calls onSaved', async () => {
const onSaved = vi.fn()
const onClose = vi.fn()
render(
<SaveQueryDialog open sql="SELECT 1" onClose={onClose} onSaved={onSaved} />,
)
fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'my query' } })
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'a test' } })
fireEvent.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => expect(saveQuery).toHaveBeenCalledWith({
name: 'my query', description: 'a test', sql: 'SELECT 1',
}))
await waitFor(() => expect(onSaved).toHaveBeenCalledWith({ id: 'new-id' }))
await waitFor(() => expect(onClose).toHaveBeenCalled())
})
it('shows an error when save fails and does not close', async () => {
saveQuery.mockRejectedValueOnce(new Error('Permission denied'))
const onClose = vi.fn()
render(
<SaveQueryDialog open sql="SELECT 1" onClose={onClose} onSaved={() => {}} />,
)
fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'X' } })
fireEvent.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => expect(screen.getByText(/permission denied/i)).toBeInTheDocument())
expect(onClose).not.toHaveBeenCalled()
})
it('Cancel calls onClose without saving', () => {
const onClose = vi.fn()
render(
<SaveQueryDialog open sql="SELECT 1" onClose={onClose} onSaved={() => {}} />,
)
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
expect(onClose).toHaveBeenCalledTimes(1)
expect(saveQuery).not.toHaveBeenCalled()
})
})[ ] Step 2: Run โ Expected: FAIL (module missing).
[ ] Step 3: Implement
apps/admin/src/admin/analytics/console/SaveQueryDialog.jsx:
import React, { useState } from 'react'
import { saveQuery } from '../savedQueries.service'
import { structuredError } from './utils'
export default function SaveQueryDialog({ open, sql, onClose, onSaved }) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [error, setError] = useState(null)
const [submitting, setSubmitting] = useState(false)
if (!open) return null
async function handleSubmit(e) {
e?.preventDefault?.()
setError(null)
setSubmitting(true)
try {
const res = await saveQuery({ name: name.trim(), description: description.trim(), sql })
onSaved(res)
setName('')
setDescription('')
onClose()
} catch (err) {
setError(structuredError(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="bq-save-dialog" role="dialog" aria-label="Save query">
<form onSubmit={handleSubmit} className="bq-save-dialog__form">
<label>
Name
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</label>
<label>
Description
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
/>
</label>
<pre className="bq-save-dialog__sql-preview">{sql}</pre>
{error && <div className="bq-save-dialog__error" role="alert">{error.message}</div>}
<div className="bq-save-dialog__actions">
<button type="button" className="bq-btn bq-btn--ghost" onClick={onClose} disabled={submitting}>
Cancel
</button>
<button type="submit" className="bq-btn bq-btn--primary" disabled={submitting || !name.trim()}>
{submitting ? 'Savingโฆ' : 'Save'}
</button>
</div>
</form>
</div>
)
}[ ] Step 4: Run โ Expected: PASS (5 tests).
[ ] Step 5: Commit
git add apps/admin/src/admin/analytics/console/SaveQueryDialog.jsx apps/admin/src/admin/analytics/console/__tests__/SaveQueryDialog.test.jsx
git commit -m "feat(admin/bq): add SaveQueryDialog component"Task 16: ConsolePanel orchestrator + retire legacy โ
This task replaces ConsolePanelLegacy with the new orchestrator and removes the built-in reports section.
Files:
Create:
apps/admin/src/admin/analytics/console/ConsolePanel.jsxTest:
apps/admin/src/admin/analytics/console/__tests__/ConsolePanel.test.jsxModify:
apps/admin/src/admin/analytics/BigQueryWorkspace.jsxโ changeConsolePanelLegacyimport toConsolePanelDelete:
apps/admin/src/admin/analytics/console/ConsolePanelLegacy.jsxModify: existing
BigQueryWorkspace.test.jsxโ adjust assertions for the new layout[ ] Step 1: Write failing tests for
ConsolePanel
apps/admin/src/admin/analytics/console/__tests__/ConsolePanel.test.jsx:
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
vi.mock('../../../../shared/lib/analyticsApi', () => ({
runBqQueryRaw: vi.fn(),
estimateBqQueryRaw: vi.fn(),
getBqSchema: vi.fn().mockResolvedValue({ datasets: [] }),
}))
vi.mock('../savedQueries.service', () => ({
subscribeToSavedQueries: vi.fn((onChange) => { onChange([]); return () => {} }),
saveQuery: vi.fn(),
deleteSavedQuery: vi.fn(),
}))
vi.mock('../../../../firebase', () => ({
db: {},
auth: { currentUser: { uid: 'admin-1', email: 'me@example.com' } },
}))
import { runBqQueryRaw } from '../../../../shared/lib/analyticsApi'
import ConsolePanel from '../ConsolePanel'
describe('ConsolePanel', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
})
it('renders toolbar, rail, editor, meta strip, and results region', () => {
render(<ConsolePanel />)
expect(screen.getByRole('toolbar', { name: /query console toolbar/i })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /schema/i })).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByText(/no query run yet/i)).toBeInTheDocument()
expect(screen.getByText(/run a query to see results/i)).toBeInTheDocument()
})
it('Run button executes the query and surfaces results', async () => {
runBqQueryRaw.mockResolvedValue({
rows: [{ a: 1 }],
schema: [{ name: 'a', type: 'INT64' }],
jobMeta: { scanBytes: 100, durationMs: 25, cacheHit: false },
})
render(<ConsolePanel />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'SELECT 1 AS a' } })
fireEvent.click(screen.getByRole('button', { name: /^run$/i }))
await waitFor(() => expect(screen.getByText('1')).toBeInTheDocument())
})
it('switching to History tab shows empty state then populated entry after a run', async () => {
runBqQueryRaw.mockResolvedValue({
rows: [{ a: 1 }],
schema: [{ name: 'a', type: 'INT64' }],
jobMeta: { scanBytes: 100, durationMs: 25, cacheHit: false },
})
render(<ConsolePanel />)
fireEvent.click(screen.getByRole('tab', { name: /history/i }))
expect(screen.getByText(/no recent queries/i)).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: /schema/i }))
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'SELECT 1 AS a' } })
fireEvent.click(screen.getByRole('button', { name: /^run$/i }))
await waitFor(() => expect(screen.getByText('1')).toBeInTheDocument())
fireEvent.click(screen.getByRole('tab', { name: /history/i }))
expect(screen.getByText('SELECT 1 AS a')).toBeInTheDocument()
})
it('clicking a history entry loads it into the editor', async () => {
runBqQueryRaw.mockResolvedValue({
rows: [{ a: 1 }],
schema: [{ name: 'a', type: 'INT64' }],
jobMeta: { scanBytes: 100, durationMs: 25 },
})
render(<ConsolePanel />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'SELECT history' } })
fireEvent.click(screen.getByRole('button', { name: /^run$/i }))
await waitFor(() => expect(screen.getByText('1')).toBeInTheDocument())
fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } })
fireEvent.click(screen.getByRole('tab', { name: /history/i }))
fireEvent.click(screen.getByText('SELECT history'))
expect(screen.getByRole('textbox')).toHaveValue('SELECT history')
})
it('Clear button empties the editor', () => {
render(<ConsolePanel />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'SELECT 1' } })
fireEvent.click(screen.getByRole('button', { name: /clear/i }))
expect(screen.getByRole('textbox')).toHaveValue('')
})
it('Format button reformats SQL via sql-formatter', () => {
render(<ConsolePanel />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'select 1' } })
fireEvent.click(screen.getByRole('button', { name: /format/i }))
expect(screen.getByRole('textbox').value).toMatch(/SELECT/)
})
it('toggling fullscreen adds the fullscreen class', () => {
const { container } = render(<ConsolePanel />)
fireEvent.click(screen.getByRole('button', { name: /fullscreen/i }))
expect(container.querySelector('.bq-console--fullscreen')).toBeInTheDocument()
})
it('Save button opens the save dialog with current SQL', () => {
render(<ConsolePanel />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'SELECT 99' } })
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
const dialog = screen.getByRole('dialog', { name: /save query/i })
expect(dialog).toBeInTheDocument()
expect(dialog).toHaveTextContent('SELECT 99')
})
})[ ] Step 2: Run โ Expected: FAIL.
[ ] Step 3: Implement
ConsolePanel
apps/admin/src/admin/analytics/console/ConsolePanel.jsx:
import React, { useCallback, useRef, useState } from 'react'
import { format as formatSql } from 'sql-formatter'
import { auth } from '../../../firebase'
import ConsoleToolbar from './ConsoleToolbar'
import ConsoleRail from './ConsoleRail'
import ConsoleEditor from './ConsoleEditor'
import ConsoleMetaStrip from './ConsoleMetaStrip'
import ConsoleResults from './ConsoleResults'
import SaveQueryDialog from './SaveQueryDialog'
import { useConsolePersistence } from './useConsolePersistence'
import { useConsoleHistory } from './useConsoleHistory'
import { useConsoleState } from './useConsoleState'
export default function ConsolePanel() {
const adminId = auth.currentUser?.uid || null
const persistence = useConsolePersistence(adminId)
const history = useConsoleHistory(adminId)
const [isFullscreen, setIsFullscreen] = useState(false)
const [saveDialogOpen, setSaveDialogOpen] = useState(false)
const [savedCount, setSavedCount] = useState(0)
const editorRef = useRef(null)
const onRunSuccess = useCallback(({ sql, scanBytes, durationMs }) => {
history.record({ sql, scanBytes, durationMs })
}, [history])
const consoleState = useConsoleState({ onRunSuccess })
const handleSchemaInsert = useCallback((text) => {
editorRef.current?.insertAtCursor(text)
}, [])
const handleSavedLoad = useCallback((sql) => {
consoleState.setSql(sql)
persistence.setActiveTab('schema')
}, [consoleState, persistence])
const handleHistoryLoad = useCallback((sql) => {
consoleState.setSql(sql)
persistence.setActiveTab('schema')
}, [consoleState, persistence])
const handleFormat = useCallback(() => {
try {
const next = formatSql(consoleState.sql, { language: 'bigquery' })
consoleState.setSql(next)
} catch {
// formatter failed โ leave SQL alone
}
}, [consoleState])
return (
<div className={`bq-console${isFullscreen ? ' bq-console--fullscreen' : ''}`}>
<ConsoleToolbar
isRunning={consoleState.isRunning}
isFullscreen={isFullscreen}
onRun={consoleState.run}
onEstimate={consoleState.estimate}
onSave={() => setSaveDialogOpen(true)}
onFormat={handleFormat}
onClear={consoleState.clear}
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
/>
<div className="bq-console__body">
<ConsoleRail
activeTab={persistence.activeTab}
onTabChange={persistence.setActiveTab}
width={persistence.railWidth}
onWidthChange={persistence.setRailWidth}
collapsed={persistence.railCollapsed}
onToggleCollapsed={() => persistence.setRailCollapsed(!persistence.railCollapsed)}
history={history.entries}
onLoadHistory={handleHistoryLoad}
onClearHistory={history.clear}
savedCount={savedCount}
onSchemaInsert={handleSchemaInsert}
onSavedLoad={handleSavedLoad}
onSavedCountChange={setSavedCount}
/>
<div className="bq-console__work">
<ConsoleEditor
ref={editorRef}
sql={consoleState.sql}
onSqlChange={consoleState.setSql}
height={persistence.editorHeight}
onHeightChange={persistence.setEditorHeight}
/>
<ConsoleMetaStrip
lastEstimate={consoleState.lastEstimate}
lastJob={consoleState.lastJob}
error={consoleState.error}
/>
<ConsoleResults job={consoleState.lastJob} isRunning={consoleState.isRunning} />
</div>
</div>
<SaveQueryDialog
open={saveDialogOpen}
sql={consoleState.sql}
onClose={() => setSaveDialogOpen(false)}
onSaved={() => { /* SavedQueriesPanel subscription will refresh the list */ }}
/>
</div>
)
}The
onSavedCountChangeprop is plumbed end-to-end: ConsolePanel (setSavedCount) โ ConsoleRail (forwarded as a prop) โ SavedQueriesPanel (onCountChangefrom Task 5).
- [ ] Step 4: Wire
BigQueryWorkspace.jsxto use the new panel
In BigQueryWorkspace.jsx:
Replace
import ConsolePanel from './console/ConsolePanelLegacy'withimport ConsolePanel from './console/ConsolePanel'.[ ] Step 5: Delete the legacy panel
git rm apps/admin/src/admin/analytics/console/ConsolePanelLegacy.jsx- [ ] Step 6: Update existing
BigQueryWorkspace.test.jsx
Open apps/admin/src/admin/analytics/__tests__/BigQueryWorkspace.test.jsx and update any assertions that reference the old console layout (built-in reports section, old structure). For each failing assertion:
- If it asserted built-in-reports content existed: delete the assertion (built-in reports is removed from this page).
- If it asserted a saved-query card layout: re-target the assertion at the new rail's Saved tab (you may need to switch to the tab via
fireEvent.click(screen.getByRole('tab', { name: /saved/i }))). - If it asserted on the console layout structure (e.g., specific class names): update to the new layout.
Run iteratively:
npm run test --workspace=apps/admin -- src/admin/analytics/__tests__/BigQueryWorkspace.test.jsxUntil: PASS.
- [ ] Step 7: Run the full analytics test directory
npm run test --workspace=apps/admin -- src/admin/analyticsExpected: PASS.
- [ ] Step 8: Smoke test in browser
npm run dev --workspace=apps/adminVisit /admin/analytics/bigquery/console. Verify:
- Tabbed rail (Schema ยท Saved ยท History) renders.
- Switching tabs works.
- Typing SQL + clicking Run shows results.
- Format reformats SQL.
- Clear empties the editor.
- Fullscreen toggle hides admin chrome (if Task 17 is done; otherwise just adds the class).
- Reload: rail width and editor height persist; active tab persists.
- Built-in reports section is gone.
- [ ] Step 9: Commit
git add -A apps/admin/src/admin/analytics
git commit -m "feat(admin/bq): replace legacy console with tabbed-rail layout
Drops built-in reports section, introduces ConsolePanel orchestrator,
adds Schema/Saved/History tabbed rail, editor/results vertical split
with persistent sizes, and adds Format button via sql-formatter."Phase 4 โ Cross-cutting polish โ
Task 17: CSS-only fullscreen toggle (hide admin chrome) โ
The bq-console--fullscreen class needs to actually hide the admin sidebar and BigQueryTabs. This is a cross-cutting CSS change.
Files:
Create or modify:
apps/admin/src/admin/analytics/console/ConsolePanel.css(new โ or append to existing console-related CSS file)Modify:
apps/admin/src/admin/analytics/console/ConsolePanel.jsxโ import the CSS[ ] Step 1: Find the admin shell CSS / sidebar selector
grep -rln "admin-sidebar\|admin-shell\|admin-layout\|page-tabs" apps/admin/src/ | headPick the right selector. Likely .admin-shell__sidebar and .page-tabs (used by BigQueryTabs.jsx).
- [ ] Step 2: Write CSS
apps/admin/src/admin/analytics/console/ConsolePanel.css:
/* When ConsolePanel is fullscreen, escape the admin shell. */
.bq-console--fullscreen {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--surface, #0a0a0a);
}
/* Hide chrome above the console while we're fullscreen.
Targets are the admin shell sidebar and BigQueryTabs sub-nav. */
body:has(.bq-console--fullscreen) .admin-shell__sidebar,
body:has(.bq-console--fullscreen) .page-tabs,
body:has(.bq-console--fullscreen) .admin-shell__header {
display: none;
}(If :has is unsupported in your target browsers, fall back to applying a class to <body> from ConsolePanel's useEffect and styling against that class.)
- [ ] Step 3: Import the CSS
In ConsolePanel.jsx, add at the top:
import './ConsolePanel.css'- [ ] Step 4: Add Esc-to-exit handling
In ConsolePanel.jsx, add inside the component:
React.useEffect(() => {
if (!isFullscreen) return
function onKey(e) {
if (e.key === 'Escape') setIsFullscreen(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [isFullscreen])- [ ] Step 5: Smoke-test in browser
npm run dev --workspace=apps/adminClick the Fullscreen button โ admin chrome disappears, console fills the viewport. Press Esc โ chrome returns.
- [ ] Step 6: Commit
git add apps/admin/src/admin/analytics/console/ConsolePanel.css apps/admin/src/admin/analytics/console/ConsolePanel.jsx
git commit -m "feat(admin/bq): CSS-only fullscreen for query console (Esc to exit)"Task 18: Keyboard shortcuts โ
Files:
Modify:
apps/admin/src/admin/analytics/console/ConsolePanel.jsxTest: append to
ConsolePanel.test.jsx[ ] Step 1: Add tests
Add to apps/admin/src/admin/analytics/console/__tests__/ConsolePanel.test.jsx:
it('Cmd+Enter triggers Run', async () => {
runBqQueryRaw.mockResolvedValue({ rows: [], schema: [], jobMeta: { scanBytes: 0, durationMs: 0 } })
render(<ConsolePanel />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'SELECT 1' } })
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter', metaKey: true })
await waitFor(() => expect(runBqQueryRaw).toHaveBeenCalled())
})
it('Cmd+E triggers Estimate', async () => {
const { estimateBqQueryRaw } = await import('../../../../shared/lib/analyticsApi')
estimateBqQueryRaw.mockResolvedValue({ scanBytes: 0, costUsd: 0 })
render(<ConsolePanel />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'SELECT 1' } })
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'e', metaKey: true })
await waitFor(() => expect(estimateBqQueryRaw).toHaveBeenCalled())
})[ ] Step 2: Run โ Expected: FAIL.
[ ] Step 3: Implement keyboard handling
In ConsolePanel.jsx, add a useEffect that wires keys on the document:
React.useEffect(() => {
function onKey(e) {
const mod = e.metaKey || e.ctrlKey
if (!mod) return
if (e.key === 'Enter') { e.preventDefault(); consoleState.run() }
else if (e.key === 'e' || e.key === 'E') { e.preventDefault(); consoleState.estimate() }
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [consoleState])[ ] Step 4: Run โ Expected: PASS.
[ ] Step 5: Commit
git add apps/admin/src/admin/analytics/console/ConsolePanel.jsx apps/admin/src/admin/analytics/console/__tests__/ConsolePanel.test.jsx
git commit -m "feat(admin/bq): Cmd+Enter to Run, Cmd+E to Estimate"Task 19: Run the full admin validation โ
Files: none
- [ ] Step 1: Run admin tests
npm run test --workspace=apps/adminExpected: PASS.
- [ ] Step 2: Run admin lint
npm run lint --workspace=apps/adminFix any issues (likely unused imports left in BigQueryWorkspace.jsx).
- [ ] Step 3: Run validate (project-wide pre-commit gate)
npm run validate -- --workspace=apps/adminExpected: PASS.
- [ ] Step 4: Commit any cleanup
git add -A
git commit -m "chore(admin/bq): lint cleanup post-refactor"(Skip the commit if there were no changes.)
Task 20: Storybook story for ConsolePanel โ
Files:
Create:
apps/admin/src/admin/analytics/console/ConsolePanel.stories.jsx[ ] Step 1: Write the story file
apps/admin/src/admin/analytics/console/ConsolePanel.stories.jsx:
import React from 'react'
import ConsolePanel from './ConsolePanel'
export default {
title: 'Admin/Analytics/BigQuery/ConsolePanel',
component: ConsolePanel,
parameters: { layout: 'fullscreen' },
}
export const Default = () => <ConsolePanel />(Mocking the analytics API and Firebase auth at story level depends on existing Storybook decorator patterns; reuse what QueryResultTable.stories.jsx does. If Storybook does not boot cleanly, scope this story to the smaller ConsoleResults and ConsoleMetaStrip components instead.)
- [ ] Step 2: Verify Storybook builds
npm run storybook --workspace=apps/adminExpected: story appears under Admin/Analytics/BigQuery/ConsolePanel.
- [ ] Step 3: Commit
git add apps/admin/src/admin/analytics/console/ConsolePanel.stories.jsx
git commit -m "docs(admin/bq): storybook for ConsolePanel"Final verification โ
- [ ] Step 1: Confirm spec coverage
Re-read the spec and confirm each section is addressed:
Toolbar (Run/Estimate/Save/Format/Clear/limits/fullscreen) โ Task 10 + Task 16 wiring
Save form / dialog โ Task 15b
Tabbed Rail (Schema/Saved/History, resizable, collapsible) โ Task 15 + Task 14
Saved-tab count badge โ Task 5 (
onCountChange) + Task 16 (savedCountstate)Editor (resizable height, ref insert) โ Task 11
Cost & job meta strip โ Task 12
Results (filter, copy CSV, download, row-expand) โ Task 13
Persistence keys โ Task 7
History (
localStorage, capped, per admin) โ Task 8Keyboard shortcuts โ Task 18
Error handling (inline in meta strip) โ Task 12
File structure (
console/) โ Tasks 1โ6sql-formatterdependency โ Task 0Built-in reports removal โ Task 16 (delete legacy + update tests)
Fullscreen (CSS-only, Esc) โ Task 17
Tests for all new hooks/components โ each task
[ ] Step 2: Push branch / open PR
Coordinate with the user on branch / PR creation per the project's standing workflow.