Skip to content

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.json

  • Modify: package-lock.json (top-level workspace lockfile)

  • [ ] Step 1: Install the package

bash
npm install --workspace=apps/admin sql-formatter

Expected: package.json gains "sql-formatter": "^15.x" under dependencies; lockfile updated.

  • [ ] Step 2: Verify import works

Run from repo root:

bash
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
bash
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.js

  • Modify: apps/admin/src/admin/analytics/BigQueryWorkspace.jsx

  • [ ] Step 1: Create the new utils file

Create apps/admin/src/admin/analytics/console/utils.js:

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
bash
sed -n '629,635p;662,672p' apps/admin/src/admin/analytics/BigQueryWorkspace.jsx

If any difference vs Step 1: update console/utils.js to match the original bodies exactly.

  • [ ] Step 3: Update BigQueryWorkspace.jsx to import from new location

In apps/admin/src/admin/analytics/BigQueryWorkspace.jsx:

  • Add at the top of the imports:

    js
    import { formatBytes, structuredError } from './console/utils'
  • Delete the inline function formatBytes and function structuredError definitions.

  • [ ] Step 4: Run the existing test suite

bash
npm run test --workspace=apps/admin -- src/admin/analytics

Expected: PASS โ€” same as before.

  • [ ] Step 5: Commit
bash
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.jsx

  • Modify: apps/admin/src/admin/analytics/BigQueryWorkspace.jsx

  • [ ] Step 1: Read the three component bodies

bash
sed -n '637,696p' apps/admin/src/admin/analytics/BigQueryWorkspace.jsx

Copy the bodies of ErrorCard (line 637), ResultMetaFooter (line 674), SectionHeader (line 686) verbatim.

  • [ ] Step 2: Create console/atoms.jsx with the three components
jsx
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.jsx

  • Add to imports:

    js
    import { ErrorCard, ResultMetaFooter, SectionHeader } from './console/atoms'
  • Delete the inline function ErrorCard, function ResultMetaFooter, function SectionHeader definitions.

  • [ ] Step 4: Run the tests

bash
npm run test --workspace=apps/admin -- src/admin/analytics

Expected: PASS.

  • [ ] Step 5: Commit
bash
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.jsx

  • Modify: apps/admin/src/admin/analytics/BigQueryWorkspace.jsx

  • [ ] Step 1: Read the existing component (lines 698โ€“940)

bash
sed -n '698,941p' apps/admin/src/admin/analytics/BigQueryWorkspace.jsx

Copy 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:

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 RawSqlEditor

Adjust imports to include exactly what the pasted body uses โ€” read the body first to determine which symbols are needed.

  • [ ] Step 3: Update BigQueryWorkspace.jsx

  • Add import:

    js
    import RawSqlEditor from './console/RawSqlEditor'
  • Delete the inline RawSqlEditor definition (entire block from const 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

bash
npm run test --workspace=apps/admin -- src/admin/analytics

Expected: PASS.

  • [ ] Step 5: Commit
bash
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.jsx

  • Modify: apps/admin/src/admin/analytics/BigQueryWorkspace.jsx

  • [ ] Step 1: Read the existing components

bash
sed -n '942,1136p' apps/admin/src/admin/analytics/BigQueryWorkspace.jsx

Copy 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
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.jsx

  • Add import:

    js
    import SchemaBrowser from './console/SchemaBrowser'
  • Delete the inline SchemaTableRow and SchemaBrowser definitions.

  • [ ] Step 4: Run tests

bash
npm run test --workspace=apps/admin -- src/admin/analytics

Expected: PASS.

  • [ ] Step 5: Commit
bash
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.jsx

  • Modify: apps/admin/src/admin/analytics/BigQueryWorkspace.jsx

  • [ ] Step 1: Read existing

bash
sed -n '1138,1268p' apps/admin/src/admin/analytics/BigQueryWorkspace.jsx

Copy formatRelativeTime (line 1138) and SavedQueriesPanel (line 1151) verbatim.

  • [ ] Step 2: Create the file
jsx
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.jsx

  • Add: import SavedQueriesPanel from './console/SavedQueriesPanel'

  • Delete inline formatRelativeTime and SavedQueriesPanel.

  • [ ] Step 4: Run tests

bash
npm run test --workspace=apps/admin -- src/admin/analytics

Expected: PASS.

  • [ ] Step 5: Commit
bash
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.jsx

  • Modify: apps/admin/src/admin/analytics/BigQueryWorkspace.jsx

  • [ ] Step 1: Read existing SavedQueryCard and ConsolePanel

bash
sed -n '594,627p;1270,1342p' apps/admin/src/admin/analytics/BigQueryWorkspace.jsx
  • [ ] Step 2: Create console/ConsolePanelLegacy.jsx
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.jsx

  • Replace any internal call to the deleted ConsolePanel with:

    js
    import ConsolePanel from './console/ConsolePanelLegacy'
  • Delete inline SavedQueryCard and ConsolePanel definitions.

  • Remove now-unused imports flagged by your editor / lint.

  • [ ] Step 4: Run tests

bash
npm run test --workspace=apps/admin -- src/admin/analytics

Expected: PASS.

  • [ ] Step 5: Verify in browser
bash
npm run dev --workspace=apps/admin

Visit /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
bash
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.js

  • Test: 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:

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
bash
npm run test --workspace=apps/admin -- console/__tests__/useConsolePersistence.test.js

Expected: FAIL โ€” module not found.

  • [ ] Step 3: Implement the hook

apps/admin/src/admin/analytics/console/useConsolePersistence.js:

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
bash
npm run test --workspace=apps/admin -- console/__tests__/useConsolePersistence.test.js

Expected: PASS (5 tests).

  • [ ] Step 5: Commit
bash
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.js

  • Test: 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:

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
bash
npm run test --workspace=apps/admin -- console/__tests__/useConsoleHistory.test.js

Expected: FAIL โ€” module not found.

  • [ ] Step 3: Implement

apps/admin/src/admin/analytics/console/useConsoleHistory.js:

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
bash
npm run test --workspace=apps/admin -- console/__tests__/useConsoleHistory.test.js

Expected: PASS (6 tests).

  • [ ] Step 5: Commit
bash
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.js

  • Test: 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:

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
bash
npm run test --workspace=apps/admin -- console/__tests__/useConsoleState.test.js

Expected: FAIL โ€” module not found.

  • [ ] Step 3: Implement

apps/admin/src/admin/analytics/console/useConsoleState.js:

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
bash
npm run test --workspace=apps/admin -- console/__tests__/useConsoleState.test.js

Expected: PASS (8 tests).

  • [ ] Step 5: Commit
bash
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.jsx

  • Test: 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:

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.
bash
npm run test --workspace=apps/admin -- console/__tests__/ConsoleToolbar.test.jsx
  • [ ] Step 3: Implement

apps/admin/src/admin/analytics/console/ConsoleToolbar.jsx:

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

bash
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 RawSqlEditor is a forwardRef that exposes insertAtCursor(text) and loadSql(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 via onChange and exposes insertAtCursor + setSql via ref.

  • Create: apps/admin/src/admin/analytics/console/ConsoleEditor.jsx

  • Test: apps/admin/src/admin/analytics/console/__tests__/ConsoleEditor.test.jsx

  • [ ] Step 1: Reduce RawSqlEditor responsibilities

In console/RawSqlEditor.jsx:

  1. Add props: value (controlled SQL string) and onChange(sql) (called on user input).
  2. 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.
  3. Keep: the <textarea> (or whatever editor primitive it uses), forwardRef exposing insertAtCursor(text) (which now needs to call props.onChange(newValue) after computing the new string).
  4. Remove imports that are no longer used (e.g. runBqQueryRaw, estimateBqQueryRaw, saveAdminQuery, QueryResultTable, ErrorCard, etc.).
  5. The default export remains a forwardRef that renders the textarea bound to value and calls onChange on 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:

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).
bash
npm run test --workspace=apps/admin -- console/__tests__/RawSqlEditor.test.jsx

If 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:

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:

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.
bash
npm run test --workspace=apps/admin -- console/__tests__/RawSqlEditor.test.jsx console/__tests__/ConsoleEditor.test.jsx
  • [ ] Step 8: Commit
bash
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.jsx

  • Test: 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:

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:

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

bash
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.jsx

  • Test: 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:

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:

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

bash
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.jsx

  • Test: 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:

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:

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

bash
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.jsx

  • Test: 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:

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:

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

bash
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.jsx

  • Test: 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:

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:

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

bash
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.jsx

  • Test: apps/admin/src/admin/analytics/console/__tests__/ConsolePanel.test.jsx

  • Modify: apps/admin/src/admin/analytics/BigQueryWorkspace.jsx โ€” change ConsolePanelLegacy import to ConsolePanel

  • Delete: apps/admin/src/admin/analytics/console/ConsolePanelLegacy.jsx

  • Modify: 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:

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:

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 onSavedCountChange prop is plumbed end-to-end: ConsolePanel (setSavedCount) โ†’ ConsoleRail (forwarded as a prop) โ†’ SavedQueriesPanel (onCountChange from Task 5).

  • [ ] Step 4: Wire BigQueryWorkspace.jsx to use the new panel

In BigQueryWorkspace.jsx:

  • Replace import ConsolePanel from './console/ConsolePanelLegacy' with import ConsolePanel from './console/ConsolePanel'.

  • [ ] Step 5: Delete the legacy panel

bash
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:

bash
npm run test --workspace=apps/admin -- src/admin/analytics/__tests__/BigQueryWorkspace.test.jsx

Until: PASS.

  • [ ] Step 7: Run the full analytics test directory
bash
npm run test --workspace=apps/admin -- src/admin/analytics

Expected: PASS.

  • [ ] Step 8: Smoke test in browser
bash
npm run dev --workspace=apps/admin

Visit /admin/analytics/bigquery/console. Verify:

  1. Tabbed rail (Schema ยท Saved ยท History) renders.
  2. Switching tabs works.
  3. Typing SQL + clicking Run shows results.
  4. Format reformats SQL.
  5. Clear empties the editor.
  6. Fullscreen toggle hides admin chrome (if Task 17 is done; otherwise just adds the class).
  7. Reload: rail width and editor height persist; active tab persists.
  8. Built-in reports section is gone.
  • [ ] Step 9: Commit
bash
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

bash
grep -rln "admin-sidebar\|admin-shell\|admin-layout\|page-tabs" apps/admin/src/ | head

Pick 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:

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:

js
import './ConsolePanel.css'
  • [ ] Step 4: Add Esc-to-exit handling

In ConsolePanel.jsx, add inside the component:

jsx
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
bash
npm run dev --workspace=apps/admin

Click the Fullscreen button โ€” admin chrome disappears, console fills the viewport. Press Esc โ€” chrome returns.

  • [ ] Step 6: Commit
bash
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.jsx

  • Test: append to ConsolePanel.test.jsx

  • [ ] Step 1: Add tests

Add to apps/admin/src/admin/analytics/console/__tests__/ConsolePanel.test.jsx:

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:

jsx
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

bash
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
bash
npm run test --workspace=apps/admin

Expected: PASS.

  • [ ] Step 2: Run admin lint
bash
npm run lint --workspace=apps/admin

Fix any issues (likely unused imports left in BigQueryWorkspace.jsx).

  • [ ] Step 3: Run validate (project-wide pre-commit gate)
bash
npm run validate -- --workspace=apps/admin

Expected: PASS.

  • [ ] Step 4: Commit any cleanup
bash
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:

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
bash
npm run storybook --workspace=apps/admin

Expected: story appears under Admin/Analytics/BigQuery/ConsolePanel.

  • [ ] Step 3: Commit
bash
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 (savedCount state)

  • 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 8

  • Keyboard shortcuts โ†’ Task 18

  • Error handling (inline in meta strip) โ†’ Task 12

  • File structure (console/) โ†’ Tasks 1โ€“6

  • sql-formatter dependency โ†’ Task 0

  • Built-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.

Built with VitePress