From 381ac0bc83e40549f765a835c9bcc2748287fd7c Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:45:01 +0200 Subject: [PATCH 1/3] refactor(app): decompose SpecTabs into feature folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split the 961-line SpecTabs.tsx monolith into src/sections/spec-detail/SpecTabs/ with index.tsx (tabs orchestration, tag-count state, tag chip rendering), CodeTab.tsx, SpecTab.tsx, ImplTab.tsx, QualityTab.tsx, and utils.tsx - utils.tsx keeps the module-level tag-count cache (via getter/setter to preserve its once-per-session lifetime), Md helpers, parseInlineCode, TabPanel, formatDate, and the tag→URL-param maps - QualityTab expansion state stays lifted in index.tsx (as before) and is passed down as expandedCategories/onToggleCategory props; copy state moved with the copy button into CodeTab - No behavior change: all trackEvent calls, aria attributes, and interactions preserved verbatim; existing SpecTabs.test.tsx moved into the folder unchanged - Add focused per-tab tests for the now prop-driven surfaces (QualityTab expansion contract, CodeTab copy/tracking, ImplTab date fallback, SpecTab inline-code rendering): 60→64 test files, 552→572 tests Part 9 of the frontend modernization roadmap. Co-Authored-By: Claude Fable 5 --- app/src/sections/spec-detail/SpecTabs.tsx | 961 ------------------ .../spec-detail/SpecTabs/CodeTab.test.tsx | 73 ++ .../sections/spec-detail/SpecTabs/CodeTab.tsx | 88 ++ .../spec-detail/SpecTabs/ImplTab.test.tsx | 60 ++ .../sections/spec-detail/SpecTabs/ImplTab.tsx | 140 +++ .../spec-detail/SpecTabs/QualityTab.test.tsx | 116 +++ .../spec-detail/SpecTabs/QualityTab.tsx | 229 +++++ .../spec-detail/SpecTabs/SpecTab.test.tsx | 49 + .../sections/spec-detail/SpecTabs/SpecTab.tsx | 90 ++ .../{ => SpecTabs}/SpecTabs.test.tsx | 0 .../sections/spec-detail/SpecTabs/index.tsx | 420 ++++++++ app/src/sections/spec-detail/SpecTabs/md.tsx | 66 ++ .../sections/spec-detail/SpecTabs/utils.tsx | 73 ++ 13 files changed, 1404 insertions(+), 961 deletions(-) delete mode 100644 app/src/sections/spec-detail/SpecTabs.tsx create mode 100644 app/src/sections/spec-detail/SpecTabs/CodeTab.test.tsx create mode 100644 app/src/sections/spec-detail/SpecTabs/CodeTab.tsx create mode 100644 app/src/sections/spec-detail/SpecTabs/ImplTab.test.tsx create mode 100644 app/src/sections/spec-detail/SpecTabs/ImplTab.tsx create mode 100644 app/src/sections/spec-detail/SpecTabs/QualityTab.test.tsx create mode 100644 app/src/sections/spec-detail/SpecTabs/QualityTab.tsx create mode 100644 app/src/sections/spec-detail/SpecTabs/SpecTab.test.tsx create mode 100644 app/src/sections/spec-detail/SpecTabs/SpecTab.tsx rename app/src/sections/spec-detail/{ => SpecTabs}/SpecTabs.test.tsx (100%) create mode 100644 app/src/sections/spec-detail/SpecTabs/index.tsx create mode 100644 app/src/sections/spec-detail/SpecTabs/md.tsx create mode 100644 app/src/sections/spec-detail/SpecTabs/utils.tsx diff --git a/app/src/sections/spec-detail/SpecTabs.tsx b/app/src/sections/spec-detail/SpecTabs.tsx deleted file mode 100644 index dbe6a47b4c..0000000000 --- a/app/src/sections/spec-detail/SpecTabs.tsx +++ /dev/null @@ -1,961 +0,0 @@ -import { lazy, Suspense, useCallback, useEffect, useState } from 'react'; - -import { useNavigate } from 'react-router-dom'; - -import CheckIcon from '@mui/icons-material/Check'; -import CodeIcon from '@mui/icons-material/Code'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import DescriptionIcon from '@mui/icons-material/Description'; -import ExpandLessIcon from '@mui/icons-material/ExpandLess'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import ImageIcon from '@mui/icons-material/Image'; -import StarIcon from '@mui/icons-material/Star'; -import Box from '@mui/material/Box'; -import Chip from '@mui/material/Chip'; -import Collapse from '@mui/material/Collapse'; -import IconButton from '@mui/material/IconButton'; -import Tab from '@mui/material/Tab'; -import Tabs from '@mui/material/Tabs'; -import Tooltip from '@mui/material/Tooltip'; -import Typography from '@mui/material/Typography'; - -import { paths } from 'src/routes/paths'; - -const CodeHighlighter = lazy(() => import('src/components/CodeHighlighter')); -import { apiGet, endpoints } from 'src/lib/api'; -import { colors, fontSize, semanticColors, typography } from 'src/theme'; - -// Cached global tag counts — loaded once, shared across all SpecTabs instances -let cachedTagCounts: Record> | null = null; - -// Map tag category names to URL parameter names -const SPEC_TAG_PARAM_MAP: Record = { - plot_type: 'plot', - data_type: 'data', - domain: 'dom', - features: 'feat', -}; - -const IMPL_TAG_PARAM_MAP: Record = { - dependencies: 'dep', - techniques: 'tech', - patterns: 'pat', - dataprep: 'prep', - styling: 'style', -}; - -interface SpecTabsProps { - // Code tab - code: string | null; - // Specification tab - specId: string; - title: string; - description: string; - applications?: string[]; - data?: string[]; - notes?: string[]; - tags?: Record; - created?: string; - updated?: string; - // Implementation tab - imageDescription?: string; - strengths?: string[]; - weaknesses?: string[]; - implTags?: Record; - // Quality tab - qualityScore: number | null; - criteriaChecklist?: Record; - // Implementation date - generatedAt?: string; - // Common - libraryId: string; - language?: string; - onTrackEvent?: (name: string, props?: Record) => void; - // Overview mode - only show Spec tab - overviewMode?: boolean; - // Tags to highlight (from similar specs hover) - highlightedTags?: string[]; -} - -interface TabPanelProps { - children?: React.ReactNode; - index: number; - value: number | null; -} - -function TabPanel({ children, value, index }: TabPanelProps) { - const isOpen = value === index; - return ( - - - {children} - - - ); -} - -// Clean heading without markdown syntax -function MdHeading({ level, children }: { level: 1 | 2; children: React.ReactNode }) { - return ( - - {children} - - ); -} - -// Parse text with backticks into React nodes with inline code styling -function parseInlineCode(text: string): React.ReactNode[] { - const parts = text.split(/(`[^`]+`)/g); - return parts.map((part, i) => { - if (part.startsWith('`') && part.endsWith('`')) { - return ( - - {part.slice(1, -1)} - - ); - } - return part; - }); -} - -// Clean bullet list item -function MdListItem({ children }: { children: string }) { - return ( - - {parseInlineCode(children)} - - ); -} - -export function SpecTabs({ - code, - specId, - title, - description, - applications, - data, - notes, - tags, - created, - updated, - imageDescription, - strengths, - weaknesses, - implTags, - qualityScore, - criteriaChecklist, - generatedAt, - libraryId, - language, - onTrackEvent, - overviewMode = false, - highlightedTags = [], -}: SpecTabsProps) { - const [copied, setCopied] = useState(false); - // In overview mode, start with Spec tab open; in detail mode, all collapsed - const [tabIndex, setTabIndex] = useState(null); - const [expandedCategories, setExpandedCategories] = useState>({}); - const [tagCounts, setTagCounts] = useState> | null>( - cachedTagCounts - ); - - // Fetch global tag counts once (module-level cache) - useEffect(() => { - if (cachedTagCounts) return; - const controller = new AbortController(); - // Non-ok responses previously resolved to null and were ignored; apiGet - // throws instead, so the empty catch keeps the same silent-skip behavior. - apiGet<{ globalCounts?: Record> }>( - endpoints.plotsFilter('limit=1'), - { signal: controller.signal } - ) - .then(data => { - if (data?.globalCounts) { - cachedTagCounts = data.globalCounts; - setTagCounts(data.globalCounts); - } - }) - .catch(() => {}); - return () => controller.abort(); - }, []); - - // Get count for a tag value (e.g., "scatter" in "plot" category → 421 implementations) - const getTagCount = useCallback( - (paramName: string | undefined, value: string): number | null => { - if (!tagCounts || !paramName) return null; - return tagCounts[paramName]?.[value] ?? null; - }, - [tagCounts] - ); - - const navigate = useNavigate(); - - // Handle tag click — in-app navigation (preserves AppDataContext, no full reload). - // The previous `window.location.href = …` forced /specs, /libraries, /stats - // to be re-fetched on every tag click on a SpecTabs page. - const handleTagClick = useCallback( - (paramName: string, value: string) => { - onTrackEvent?.('tag_click', { param: paramName, value, source: 'spec_detail' }); - navigate(paths.plotsFiltered(paramName, value)); - }, - [navigate, onTrackEvent] - ); - - const toggleCategory = (category: string) => { - setExpandedCategories(prev => ({ ...prev, [category]: !prev[category] })); - }; - - const handleCopy = useCallback(async () => { - if (!code) return; - try { - await navigator.clipboard.writeText(code); - setCopied(true); - onTrackEvent?.('copy_code', { - spec: specId, - library: libraryId, - method: 'tab', - page: 'spec_detail', - }); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Copy failed:', err); - } - }, [code, specId, libraryId, onTrackEvent]); - - const handleTabChange = (_: React.SyntheticEvent, newValue: number) => { - // In overview mode, only Spec tab exists at index 0 - const tabNames = overviewMode - ? ['specification'] - : ['code', 'specification', 'implementation', 'quality']; - - // Toggle: clicking same tab collapses it - if (tabIndex === newValue) { - onTrackEvent?.('tab_toggle', { - action: 'close', - tab: tabNames[tabIndex], - library: libraryId || undefined, - }); - setTabIndex(null); - } else { - setTabIndex(newValue); - onTrackEvent?.('tab_toggle', { - action: 'open', - tab: tabNames[newValue], - library: libraryId || undefined, - }); - } - }; - - // Lazy-loaded syntax highlighter - only loads when Code tab is opened - const highlightedCode = code ? ( - - {code} - - } - > - - - ) : null; - - // Format date - const formatDate = (dateStr?: string) => { - if (!dateStr) return null; - try { - return new Date(dateStr).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - }); - } catch { - return dateStr; - } - }; - - // In overview mode, use different tab indexing (only Spec tab at index 0) - const specTabIndex = overviewMode ? 0 : 1; - - return ( - - - - {!overviewMode && ( - tabIndex === 0 && handleTabChange(e, 0)} - icon={} - iconPosition="start" - label="Code" - /> - )} - tabIndex === specTabIndex && handleTabChange(e, specTabIndex)} - icon={} - iconPosition="start" - label={ - <> - - Specification - - - Spec - - - } - /> - {!overviewMode && ( - tabIndex === 2 && handleTabChange(e, 2)} - icon={} - iconPosition="start" - label={ - <> - - Implementation - - - Impl - - - } - /> - )} - {!overviewMode && ( - tabIndex === 3 && handleTabChange(e, 3)} - icon={ - - } - iconPosition="start" - label={qualityScore ? `${Math.round(qualityScore)}` : 'Quality'} - /> - )} - - - - {/* Code Tab - only in detail mode */} - {!overviewMode && ( - - - - - {copied ? : } - - - {highlightedCode} - - - )} - - {/* Specification Tab */} - - - {/* Title only - spec ID visible in breadcrumb */} - - {title} - - - {/* ## Description */} - Description - - {parseInlineCode(description)} - - - {/* Applications */} - {applications && applications.length > 0 && ( - <> - Applications - - {applications.map((app, i) => ( - {app} - ))} - - - )} - - {/* Data */} - {data && data.length > 0 && ( - <> - Data - - {data.map((d, i) => ( - {d} - ))} - - - )} - - {/* Notes */} - {notes && notes.length > 0 && ( - <> - Notes - - {notes.map((note, i) => ( - {note} - ))} - - - )} - - - - {/* Implementation Tab - only in detail mode */} - {!overviewMode && ( - - - {/* Image Description */} - {imageDescription && ( - <> - Description - - {imageDescription} - - - )} - - {/* Strengths */} - {strengths && strengths.length > 0 && ( - <> - Strengths - - {strengths.map((s, i) => ( - - {s} - - ))} - - - )} - - {/* Weaknesses */} - {weaknesses && weaknesses.length > 0 && ( - <> - Weaknesses - - {weaknesses.map((w, i) => ( - - {w} - - ))} - - - )} - - {/* No data message */} - {!imageDescription && - (!strengths || strengths.length === 0) && - (!weaknesses || weaknesses.length === 0) && ( - - No implementation review data available. - - )} - - {/* Metadata */} - - {specId} - {libraryId && ` · ${libraryId}`} - {(() => { - const date = generatedAt || updated || created; - return date ? ` · ${formatDate(date)}` : ''; - })()} - - - - )} - - {/* Quality Tab - only in detail mode */} - {!overviewMode && ( - - - {/* Score */} - Score - = 90 - ? colors.success - : qualityScore && qualityScore >= 70 - ? colors.warning - : colors.error, - }} - > - {qualityScore ? `${Math.round(qualityScore)}/100` : 'N/A'} - - - {/* Criteria Checklist */} - {criteriaChecklist && Object.keys(criteriaChecklist).length > 0 && ( - <> - Breakdown - - {Object.entries(criteriaChecklist).map(([category, data]) => { - const catData = data as { - score?: number; - max?: number; - items?: Array<{ - id: string; - name: string; - score: number; - max: number; - passed: boolean; - comment?: string; - }>; - }; - const score = catData.score ?? 0; - const max = catData.max ?? 0; - const pct = max > 0 ? (score / max) * 100 : 0; - const items = catData.items || []; - const isExpanded = expandedCategories[category] ?? false; - - return ( - - {/* Category header - clickable */} - items.length > 0 && toggleCategory(category)} - sx={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - mb: 0.5, - cursor: items.length > 0 ? 'pointer' : 'default', - '&:hover': items.length > 0 ? { opacity: 0.8 } : {}, - }} - > - - {items.length > 0 && - (isExpanded ? ( - - ) : ( - - ))} - - {category.replace(/_/g, ' ')} - - - - {score}/{max} - - - {/* Progress bar */} - - = 90 - ? colors.success - : pct >= 70 - ? colors.warning - : colors.error, - borderRadius: 2, - }} - /> - - {/* Expandable items */} - - - {items.map(item => ( - - - - - - {item.name} - - - - {item.score}/{item.max} - - - {item.comment && ( - - {item.comment} - - )} - - ))} - - - - ); - })} - - - )} - - {/* No data message */} - {!qualityScore && - (!criteriaChecklist || Object.keys(criteriaChecklist).length === 0) && ( - - No quality data available. - - )} - - - )} - - {/* Tags — always visible after tab content (spec tags + impl tags on detail page) */} - {((tags && Object.keys(tags).length > 0) || - (implTags && Object.values(implTags).some(v => v?.length > 0))) && ( - - {tags && - Object.entries(tags).map(([category, values]) => { - const paramName = SPEC_TAG_PARAM_MAP[category]; - return ( - - - {category.replace(/_/g, ' ')}: - - {values.map((value, i) => { - const isHighlighted = highlightedTags.includes(value); - const count = getTagCount(paramName, value); - const chip = ( - handleTagClick(paramName, value) : undefined} - sx={{ - fontFamily: typography.fontFamily, - fontSize: fontSize.xs, - height: 24, - bgcolor: isHighlighted ? colors.highlight.bg : 'var(--bg-surface)', - color: isHighlighted ? colors.highlight.text : semanticColors.labelText, - cursor: paramName ? 'pointer' : 'default', - transition: 'all 0.2s ease', - fontWeight: isHighlighted ? 600 : 400, - '&:hover': paramName ? { bgcolor: 'var(--bg-elevated)' } : {}, - }} - /> - ); - return count !== null ? ( - - {chip} - - ) : ( - chip - ); - })} - - ); - })} - {!overviewMode && - implTags && - Object.entries(implTags) - .filter(([, values]) => values && values.length > 0) - .map(([category, values]) => { - const paramName = IMPL_TAG_PARAM_MAP[category]; - return ( - - - {category}: - - {values.map((value, i) => { - const isHighlighted = highlightedTags.includes(value); - const count = getTagCount(paramName, value); - const chip = ( - handleTagClick(paramName, value) : undefined} - sx={{ - fontFamily: typography.fontFamily, - fontSize: fontSize.xs, - height: 24, - bgcolor: isHighlighted ? colors.highlight.bg : 'var(--bg-surface)', - color: isHighlighted ? colors.highlight.text : semanticColors.labelText, - cursor: paramName ? 'pointer' : 'default', - transition: 'all 0.2s ease', - fontWeight: isHighlighted ? 600 : 400, - '&:hover': paramName ? { bgcolor: 'var(--bg-elevated)' } : {}, - }} - /> - ); - return count !== null ? ( - - {chip} - - ) : ( - chip - ); - })} - - ); - })} - - )} - - ); -} diff --git a/app/src/sections/spec-detail/SpecTabs/CodeTab.test.tsx b/app/src/sections/spec-detail/SpecTabs/CodeTab.test.tsx new file mode 100644 index 0000000000..17c01c73db --- /dev/null +++ b/app/src/sections/spec-detail/SpecTabs/CodeTab.test.tsx @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CodeTab } from 'src/sections/spec-detail/SpecTabs/CodeTab'; +import { render, screen, userEvent, waitFor } from 'src/test-utils'; + +// Mock the lazy-loaded CodeHighlighter +vi.mock('src/components/CodeHighlighter', () => ({ + default: ({ code }: { code: string }) =>
{code}
, +})); + +const baseProps = { + code: 'print("hello")', + specId: 'scatter-basic', + libraryId: 'matplotlib', +}; + +describe('CodeTab', () => { + it('renders the code through CodeHighlighter', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('code-highlighter')).toBeInTheDocument(); + }); + expect(screen.getByTestId('code-highlighter')).toHaveTextContent('print("hello")'); + }); + + it('renders no highlighter when code is null', () => { + render(); + expect(screen.queryByTestId('code-highlighter')).not.toBeInTheDocument(); + }); + + it('copies code, fires the tracking event, and shows the copied state', async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + writable: true, + configurable: true, + }); + + const onTrackEvent = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: /copy code/i })); + + expect(writeText).toHaveBeenCalledWith('print("hello")'); + expect(onTrackEvent).toHaveBeenCalledWith('copy_code', { + spec: 'scatter-basic', + library: 'matplotlib', + method: 'tab', + page: 'spec_detail', + }); + expect(screen.getByTestId('CheckIcon')).toBeInTheDocument(); + }); + + it('does not copy or track when code is null', async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + writable: true, + configurable: true, + }); + + const onTrackEvent = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: /copy code/i })); + + expect(writeText).not.toHaveBeenCalled(); + expect(onTrackEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/sections/spec-detail/SpecTabs/CodeTab.tsx b/app/src/sections/spec-detail/SpecTabs/CodeTab.tsx new file mode 100644 index 0000000000..6235cf495a --- /dev/null +++ b/app/src/sections/spec-detail/SpecTabs/CodeTab.tsx @@ -0,0 +1,88 @@ +import { lazy, Suspense, useCallback, useState } from 'react'; + +import CheckIcon from '@mui/icons-material/Check'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; + +import type { TrackEventFn } from 'src/sections/spec-detail/SpecTabs/utils'; +import { semanticColors, typography } from 'src/theme'; + +const CodeHighlighter = lazy(() => import('src/components/CodeHighlighter')); + +interface CodeTabProps { + code: string | null; + specId: string; + libraryId: string; + language?: string; + onTrackEvent?: TrackEventFn; +} + +export function CodeTab({ code, specId, libraryId, language, onTrackEvent }: CodeTabProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + if (!code) return; + try { + await navigator.clipboard.writeText(code); + setCopied(true); + onTrackEvent?.('copy_code', { + spec: specId, + library: libraryId, + method: 'tab', + page: 'spec_detail', + }); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Copy failed:', err); + } + }, [code, specId, libraryId, onTrackEvent]); + + // Lazy-loaded syntax highlighter - only loads when Code tab is opened + const highlightedCode = code ? ( + + {code} + + } + > + + + ) : null; + + return ( + + + + {copied ? : } + + + {highlightedCode} + + ); +} diff --git a/app/src/sections/spec-detail/SpecTabs/ImplTab.test.tsx b/app/src/sections/spec-detail/SpecTabs/ImplTab.test.tsx new file mode 100644 index 0000000000..bd7a2df55a --- /dev/null +++ b/app/src/sections/spec-detail/SpecTabs/ImplTab.test.tsx @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { ImplTab } from 'src/sections/spec-detail/SpecTabs/ImplTab'; +import { render, screen } from 'src/test-utils'; + +const baseProps = { + specId: 'scatter-basic', + libraryId: 'matplotlib', +}; + +describe('ImplTab', () => { + it('renders description, strengths, and weaknesses', () => { + render( + + ); + + expect(screen.getByText('A colorful scatter plot')).toBeInTheDocument(); + expect(screen.getByText('Strengths')).toBeInTheDocument(); + expect(screen.getByText('Clear layout')).toBeInTheDocument(); + expect(screen.getByText('Weaknesses')).toBeInTheDocument(); + expect(screen.getByText('Missing legend')).toBeInTheDocument(); + }); + + it('shows the no-data message when no review data is present', () => { + render(); + expect(screen.getByText('No implementation review data available.')).toBeInTheDocument(); + }); + + it('prefers generatedAt over updated and created in the metadata line', () => { + render( + + ); + expect(screen.getByText(/scatter-basic · matplotlib · Jan 15, 2025/)).toBeInTheDocument(); + }); + + it('falls back to updated, then created, for the metadata date', () => { + const { rerender } = render( + + ); + expect(screen.getByText(/scatter-basic · matplotlib · Feb 20, 2025/)).toBeInTheDocument(); + + rerender(); + expect(screen.getByText(/scatter-basic · matplotlib · Mar 25, 2025/)).toBeInTheDocument(); + }); + + it('omits the date from the metadata line when no date is available', () => { + render(); + expect(screen.getByText('scatter-basic · matplotlib')).toBeInTheDocument(); + }); +}); diff --git a/app/src/sections/spec-detail/SpecTabs/ImplTab.tsx b/app/src/sections/spec-detail/SpecTabs/ImplTab.tsx new file mode 100644 index 0000000000..29cf224327 --- /dev/null +++ b/app/src/sections/spec-detail/SpecTabs/ImplTab.tsx @@ -0,0 +1,140 @@ +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +import { MdHeading } from 'src/sections/spec-detail/SpecTabs/md'; +import { formatDate } from 'src/sections/spec-detail/SpecTabs/utils'; +import { colors, fontSize, semanticColors, typography } from 'src/theme'; + +interface ImplTabProps { + imageDescription?: string; + strengths?: string[]; + weaknesses?: string[]; + specId: string; + libraryId: string; + generatedAt?: string; + updated?: string; + created?: string; +} + +export function ImplTab({ + imageDescription, + strengths, + weaknesses, + specId, + libraryId, + generatedAt, + updated, + created, +}: ImplTabProps) { + return ( + + {/* Image Description */} + {imageDescription && ( + <> + Description + + {imageDescription} + + + )} + + {/* Strengths */} + {strengths && strengths.length > 0 && ( + <> + Strengths + + {strengths.map((s, i) => ( + + {s} + + ))} + + + )} + + {/* Weaknesses */} + {weaknesses && weaknesses.length > 0 && ( + <> + Weaknesses + + {weaknesses.map((w, i) => ( + + {w} + + ))} + + + )} + + {/* No data message */} + {!imageDescription && + (!strengths || strengths.length === 0) && + (!weaknesses || weaknesses.length === 0) && ( + + No implementation review data available. + + )} + + {/* Metadata */} + + {specId} + {libraryId && ` · ${libraryId}`} + {(() => { + const date = generatedAt || updated || created; + return date ? ` · ${formatDate(date)}` : ''; + })()} + + + ); +} diff --git a/app/src/sections/spec-detail/SpecTabs/QualityTab.test.tsx b/app/src/sections/spec-detail/SpecTabs/QualityTab.test.tsx new file mode 100644 index 0000000000..82741fa0d2 --- /dev/null +++ b/app/src/sections/spec-detail/SpecTabs/QualityTab.test.tsx @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { QualityTab } from 'src/sections/spec-detail/SpecTabs/QualityTab'; +import { render, screen, userEvent } from 'src/test-utils'; + +const criteriaChecklist = { + visual_quality: { + score: 18, + max: 20, + items: [ + { + id: 'vq1', + name: 'Color harmony', + score: 9, + max: 10, + passed: true, + comment: 'Great palette', + }, + ], + }, + accuracy: { + score: 15, + max: 20, + items: [], + }, +}; + +describe('QualityTab', () => { + it('shows the rounded score over 100', () => { + render( + undefined} /> + ); + expect(screen.getByText('87/100')).toBeInTheDocument(); + }); + + it('shows N/A when the score is null', () => { + render( + undefined} /> + ); + expect(screen.getByText('N/A')).toBeInTheDocument(); + }); + + it('calls onToggleCategory when a category with items is clicked', async () => { + const user = userEvent.setup(); + const onToggleCategory = vi.fn(); + render( + + ); + + await user.click(screen.getByText('visual quality')); + expect(onToggleCategory).toHaveBeenCalledWith('visual_quality'); + }); + + it('does not call onToggleCategory for categories without items', async () => { + const user = userEvent.setup(); + const onToggleCategory = vi.fn(); + render( + + ); + + await user.click(screen.getByText('accuracy')); + expect(onToggleCategory).not.toHaveBeenCalled(); + }); + + it('renders item details when the category is expanded via props', () => { + render( + undefined} + /> + ); + + expect(screen.getByTestId('ExpandLessIcon')).toBeInTheDocument(); + expect(screen.getByText('Color harmony')).toBeInTheDocument(); + expect(screen.getByText('9/10')).toBeInTheDocument(); + expect(screen.getByText('Great palette')).toBeInTheDocument(); + }); + + it('shows the collapsed expand icon when the category is not expanded', () => { + render( + undefined} + /> + ); + + expect(screen.getByTestId('ExpandMoreIcon')).toBeInTheDocument(); + expect(screen.queryByTestId('ExpandLessIcon')).not.toBeInTheDocument(); + }); + + it('shows the no-data message only when score and checklist are missing', () => { + const { rerender } = render( + undefined} /> + ); + expect(screen.getByText('No quality data available.')).toBeInTheDocument(); + + rerender( + undefined} /> + ); + expect(screen.queryByText('No quality data available.')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/sections/spec-detail/SpecTabs/QualityTab.tsx b/app/src/sections/spec-detail/SpecTabs/QualityTab.tsx new file mode 100644 index 0000000000..6325dc3203 --- /dev/null +++ b/app/src/sections/spec-detail/SpecTabs/QualityTab.tsx @@ -0,0 +1,229 @@ +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import Box from '@mui/material/Box'; +import Collapse from '@mui/material/Collapse'; +import Typography from '@mui/material/Typography'; + +import { MdHeading } from 'src/sections/spec-detail/SpecTabs/md'; +import { colors, semanticColors, typography } from 'src/theme'; + +interface QualityTabProps { + qualityScore: number | null; + criteriaChecklist?: Record; + expandedCategories: Record; + onToggleCategory: (category: string) => void; +} + +export function QualityTab({ + qualityScore, + criteriaChecklist, + expandedCategories, + onToggleCategory, +}: QualityTabProps) { + return ( + + {/* Score */} + Score + = 90 + ? colors.success + : qualityScore && qualityScore >= 70 + ? colors.warning + : colors.error, + }} + > + {qualityScore ? `${Math.round(qualityScore)}/100` : 'N/A'} + + + {/* Criteria Checklist */} + {criteriaChecklist && Object.keys(criteriaChecklist).length > 0 && ( + <> + Breakdown + + {Object.entries(criteriaChecklist).map(([category, data]) => { + const catData = data as { + score?: number; + max?: number; + items?: Array<{ + id: string; + name: string; + score: number; + max: number; + passed: boolean; + comment?: string; + }>; + }; + const score = catData.score ?? 0; + const max = catData.max ?? 0; + const pct = max > 0 ? (score / max) * 100 : 0; + const items = catData.items || []; + const isExpanded = expandedCategories[category] ?? false; + + return ( + + {/* Category header - clickable */} + items.length > 0 && onToggleCategory(category)} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + mb: 0.5, + cursor: items.length > 0 ? 'pointer' : 'default', + '&:hover': items.length > 0 ? { opacity: 0.8 } : {}, + }} + > + + {items.length > 0 && + (isExpanded ? ( + + ) : ( + + ))} + + {category.replace(/_/g, ' ')} + + + + {score}/{max} + + + {/* Progress bar */} + + = 90 ? colors.success : pct >= 70 ? colors.warning : colors.error, + borderRadius: 2, + }} + /> + + {/* Expandable items */} + + + {items.map(item => ( + + + + + + {item.name} + + + + {item.score}/{item.max} + + + {item.comment && ( + + {item.comment} + + )} + + ))} + + + + ); + })} + + + )} + + {/* No data message */} + {!qualityScore && (!criteriaChecklist || Object.keys(criteriaChecklist).length === 0) && ( + + No quality data available. + + )} + + ); +} diff --git a/app/src/sections/spec-detail/SpecTabs/SpecTab.test.tsx b/app/src/sections/spec-detail/SpecTabs/SpecTab.test.tsx new file mode 100644 index 0000000000..e782254fec --- /dev/null +++ b/app/src/sections/spec-detail/SpecTabs/SpecTab.test.tsx @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { SpecTab } from 'src/sections/spec-detail/SpecTabs/SpecTab'; +import { render, screen } from 'src/test-utils'; + +const baseProps = { + title: 'Basic Scatter Plot', + description: 'A scatter plot showing data points', +}; + +describe('SpecTab', () => { + it('renders the title and description', () => { + render(); + expect(screen.getByText('Basic Scatter Plot')).toBeInTheDocument(); + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('A scatter plot showing data points')).toBeInTheDocument(); + }); + + it('renders backtick spans in the description as inline code', () => { + render(); + const code = screen.getByText('numpy'); + expect(code.tagName).toBe('CODE'); + }); + + it('renders applications, data, and notes lists when provided', () => { + render( + + ); + + expect(screen.getByText('Applications')).toBeInTheDocument(); + expect(screen.getByText('Data analysis')).toBeInTheDocument(); + expect(screen.getByText('Data')).toBeInTheDocument(); + expect(screen.getByText('Random numeric data')).toBeInTheDocument(); + expect(screen.getByText('Notes')).toBeInTheDocument(); + expect(screen.getByText('Use for small datasets')).toBeInTheDocument(); + }); + + it('hides the optional sections when their props are absent or empty', () => { + render(); + expect(screen.queryByText('Applications')).not.toBeInTheDocument(); + expect(screen.queryByText('Data')).not.toBeInTheDocument(); + expect(screen.queryByText('Notes')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/sections/spec-detail/SpecTabs/SpecTab.tsx b/app/src/sections/spec-detail/SpecTabs/SpecTab.tsx new file mode 100644 index 0000000000..c30feebb77 --- /dev/null +++ b/app/src/sections/spec-detail/SpecTabs/SpecTab.tsx @@ -0,0 +1,90 @@ +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +import { MdHeading, MdListItem } from 'src/sections/spec-detail/SpecTabs/md'; +import { parseInlineCode } from 'src/sections/spec-detail/SpecTabs/utils'; +import { semanticColors, typography } from 'src/theme'; + +interface SpecTabProps { + title: string; + description: string; + applications?: string[]; + data?: string[]; + notes?: string[]; +} + +export function SpecTab({ title, description, applications, data, notes }: SpecTabProps) { + return ( + + {/* Title only - spec ID visible in breadcrumb */} + + {title} + + + {/* ## Description */} + Description + + {parseInlineCode(description)} + + + {/* Applications */} + {applications && applications.length > 0 && ( + <> + Applications + + {applications.map((app, i) => ( + {app} + ))} + + + )} + + {/* Data */} + {data && data.length > 0 && ( + <> + Data + + {data.map((d, i) => ( + {d} + ))} + + + )} + + {/* Notes */} + {notes && notes.length > 0 && ( + <> + Notes + + {notes.map((note, i) => ( + {note} + ))} + + + )} + + ); +} diff --git a/app/src/sections/spec-detail/SpecTabs.test.tsx b/app/src/sections/spec-detail/SpecTabs/SpecTabs.test.tsx similarity index 100% rename from app/src/sections/spec-detail/SpecTabs.test.tsx rename to app/src/sections/spec-detail/SpecTabs/SpecTabs.test.tsx diff --git a/app/src/sections/spec-detail/SpecTabs/index.tsx b/app/src/sections/spec-detail/SpecTabs/index.tsx new file mode 100644 index 0000000000..ed8f5f93c5 --- /dev/null +++ b/app/src/sections/spec-detail/SpecTabs/index.tsx @@ -0,0 +1,420 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { useNavigate } from 'react-router-dom'; + +import CodeIcon from '@mui/icons-material/Code'; +import DescriptionIcon from '@mui/icons-material/Description'; +import ImageIcon from '@mui/icons-material/Image'; +import StarIcon from '@mui/icons-material/Star'; +import Box from '@mui/material/Box'; +import Chip from '@mui/material/Chip'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; + +import { apiGet, endpoints } from 'src/lib/api'; +import { paths } from 'src/routes/paths'; +import { CodeTab } from 'src/sections/spec-detail/SpecTabs/CodeTab'; +import { ImplTab } from 'src/sections/spec-detail/SpecTabs/ImplTab'; +import { TabPanel } from 'src/sections/spec-detail/SpecTabs/md'; +import { QualityTab } from 'src/sections/spec-detail/SpecTabs/QualityTab'; +import { SpecTab } from 'src/sections/spec-detail/SpecTabs/SpecTab'; +import { + getCachedTagCounts, + IMPL_TAG_PARAM_MAP, + setCachedTagCounts, + SPEC_TAG_PARAM_MAP, + type TrackEventFn, +} from 'src/sections/spec-detail/SpecTabs/utils'; +import { colors, fontSize, semanticColors, typography } from 'src/theme'; + +interface SpecTabsProps { + // Code tab + code: string | null; + // Specification tab + specId: string; + title: string; + description: string; + applications?: string[]; + data?: string[]; + notes?: string[]; + tags?: Record; + created?: string; + updated?: string; + // Implementation tab + imageDescription?: string; + strengths?: string[]; + weaknesses?: string[]; + implTags?: Record; + // Quality tab + qualityScore: number | null; + criteriaChecklist?: Record; + // Implementation date + generatedAt?: string; + // Common + libraryId: string; + language?: string; + onTrackEvent?: TrackEventFn; + // Overview mode - only show Spec tab + overviewMode?: boolean; + // Tags to highlight (from similar specs hover) + highlightedTags?: string[]; +} + +export function SpecTabs({ + code, + specId, + title, + description, + applications, + data, + notes, + tags, + created, + updated, + imageDescription, + strengths, + weaknesses, + implTags, + qualityScore, + criteriaChecklist, + generatedAt, + libraryId, + language, + onTrackEvent, + overviewMode = false, + highlightedTags = [], +}: SpecTabsProps) { + // In overview mode, start with Spec tab open; in detail mode, all collapsed + const [tabIndex, setTabIndex] = useState(null); + const [expandedCategories, setExpandedCategories] = useState>({}); + const [tagCounts, setTagCounts] = useState> | null>( + getCachedTagCounts() + ); + + // Fetch global tag counts once (module-level cache) + useEffect(() => { + if (getCachedTagCounts()) return; + const controller = new AbortController(); + // Non-ok responses previously resolved to null and were ignored; apiGet + // throws instead, so the empty catch keeps the same silent-skip behavior. + apiGet<{ globalCounts?: Record> }>( + endpoints.plotsFilter('limit=1'), + { signal: controller.signal } + ) + .then(data => { + if (data?.globalCounts) { + setCachedTagCounts(data.globalCounts); + setTagCounts(data.globalCounts); + } + }) + .catch(() => {}); + return () => controller.abort(); + }, []); + + // Get count for a tag value (e.g., "scatter" in "plot" category → 421 implementations) + const getTagCount = useCallback( + (paramName: string | undefined, value: string): number | null => { + if (!tagCounts || !paramName) return null; + return tagCounts[paramName]?.[value] ?? null; + }, + [tagCounts] + ); + + const navigate = useNavigate(); + + // Handle tag click — in-app navigation (preserves AppDataContext, no full reload). + // The previous `window.location.href = …` forced /specs, /libraries, /stats + // to be re-fetched on every tag click on a SpecTabs page. + const handleTagClick = useCallback( + (paramName: string, value: string) => { + onTrackEvent?.('tag_click', { param: paramName, value, source: 'spec_detail' }); + navigate(paths.plotsFiltered(paramName, value)); + }, + [navigate, onTrackEvent] + ); + + const toggleCategory = useCallback((category: string) => { + setExpandedCategories(prev => ({ ...prev, [category]: !prev[category] })); + }, []); + + const handleTabChange = (_: React.SyntheticEvent, newValue: number) => { + // In overview mode, only Spec tab exists at index 0 + const tabNames = overviewMode + ? ['specification'] + : ['code', 'specification', 'implementation', 'quality']; + + // Toggle: clicking same tab collapses it + if (tabIndex === newValue) { + onTrackEvent?.('tab_toggle', { + action: 'close', + tab: tabNames[tabIndex], + library: libraryId || undefined, + }); + setTabIndex(null); + } else { + setTabIndex(newValue); + onTrackEvent?.('tab_toggle', { + action: 'open', + tab: tabNames[newValue], + library: libraryId || undefined, + }); + } + }; + + // In overview mode, use different tab indexing (only Spec tab at index 0) + const specTabIndex = overviewMode ? 0 : 1; + + return ( + + + + {!overviewMode && ( + tabIndex === 0 && handleTabChange(e, 0)} + icon={} + iconPosition="start" + label="Code" + /> + )} + tabIndex === specTabIndex && handleTabChange(e, specTabIndex)} + icon={} + iconPosition="start" + label={ + <> + + Specification + + + Spec + + + } + /> + {!overviewMode && ( + tabIndex === 2 && handleTabChange(e, 2)} + icon={} + iconPosition="start" + label={ + <> + + Implementation + + + Impl + + + } + /> + )} + {!overviewMode && ( + tabIndex === 3 && handleTabChange(e, 3)} + icon={ + + } + iconPosition="start" + label={qualityScore ? `${Math.round(qualityScore)}` : 'Quality'} + /> + )} + + + + {/* Code Tab - only in detail mode */} + {!overviewMode && ( + + + + )} + + {/* Specification Tab */} + + + + + {/* Implementation Tab - only in detail mode */} + {!overviewMode && ( + + + + )} + + {/* Quality Tab - only in detail mode */} + {!overviewMode && ( + + + + )} + + {/* Tags — always visible after tab content (spec tags + impl tags on detail page) */} + {((tags && Object.keys(tags).length > 0) || + (implTags && Object.values(implTags).some(v => v?.length > 0))) && ( + + {tags && + Object.entries(tags).map(([category, values]) => { + const paramName = SPEC_TAG_PARAM_MAP[category]; + return ( + + + {category.replace(/_/g, ' ')}: + + {values.map((value, i) => { + const isHighlighted = highlightedTags.includes(value); + const count = getTagCount(paramName, value); + const chip = ( + handleTagClick(paramName, value) : undefined} + sx={{ + fontFamily: typography.fontFamily, + fontSize: fontSize.xs, + height: 24, + bgcolor: isHighlighted ? colors.highlight.bg : 'var(--bg-surface)', + color: isHighlighted ? colors.highlight.text : semanticColors.labelText, + cursor: paramName ? 'pointer' : 'default', + transition: 'all 0.2s ease', + fontWeight: isHighlighted ? 600 : 400, + '&:hover': paramName ? { bgcolor: 'var(--bg-elevated)' } : {}, + }} + /> + ); + return count !== null ? ( + + {chip} + + ) : ( + chip + ); + })} + + ); + })} + {!overviewMode && + implTags && + Object.entries(implTags) + .filter(([, values]) => values && values.length > 0) + .map(([category, values]) => { + const paramName = IMPL_TAG_PARAM_MAP[category]; + return ( + + + {category}: + + {values.map((value, i) => { + const isHighlighted = highlightedTags.includes(value); + const count = getTagCount(paramName, value); + const chip = ( + handleTagClick(paramName, value) : undefined} + sx={{ + fontFamily: typography.fontFamily, + fontSize: fontSize.xs, + height: 24, + bgcolor: isHighlighted ? colors.highlight.bg : 'var(--bg-surface)', + color: isHighlighted ? colors.highlight.text : semanticColors.labelText, + cursor: paramName ? 'pointer' : 'default', + transition: 'all 0.2s ease', + fontWeight: isHighlighted ? 600 : 400, + '&:hover': paramName ? { bgcolor: 'var(--bg-elevated)' } : {}, + }} + /> + ); + return count !== null ? ( + + {chip} + + ) : ( + chip + ); + })} + + ); + })} + + )} + + ); +} diff --git a/app/src/sections/spec-detail/SpecTabs/md.tsx b/app/src/sections/spec-detail/SpecTabs/md.tsx new file mode 100644 index 0000000000..ace749f895 --- /dev/null +++ b/app/src/sections/spec-detail/SpecTabs/md.tsx @@ -0,0 +1,66 @@ +import Box from '@mui/material/Box'; +import Collapse from '@mui/material/Collapse'; +import Typography from '@mui/material/Typography'; + +import { parseInlineCode } from 'src/sections/spec-detail/SpecTabs/utils'; +import { semanticColors, typography } from 'src/theme'; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number | null; +} + +export function TabPanel({ children, value, index }: TabPanelProps) { + const isOpen = value === index; + return ( + + + {children} + + + ); +} + +// Clean heading without markdown syntax +export function MdHeading({ level, children }: { level: 1 | 2; children: React.ReactNode }) { + return ( + + {children} + + ); +} + +// Clean bullet list item +export function MdListItem({ children }: { children: string }) { + return ( + + {parseInlineCode(children)} + + ); +} diff --git a/app/src/sections/spec-detail/SpecTabs/utils.tsx b/app/src/sections/spec-detail/SpecTabs/utils.tsx new file mode 100644 index 0000000000..39e8e79fbd --- /dev/null +++ b/app/src/sections/spec-detail/SpecTabs/utils.tsx @@ -0,0 +1,73 @@ +import Box from '@mui/material/Box'; + +import { colors, typography } from 'src/theme'; + +// Cached global tag counts — loaded once, shared across all SpecTabs instances +let cachedTagCounts: Record> | null = null; + +export function getCachedTagCounts(): Record> | null { + return cachedTagCounts; +} + +export function setCachedTagCounts(counts: Record>) { + cachedTagCounts = counts; +} + +// Map tag category names to URL parameter names +export const SPEC_TAG_PARAM_MAP: Record = { + plot_type: 'plot', + data_type: 'data', + domain: 'dom', + features: 'feat', +}; + +export const IMPL_TAG_PARAM_MAP: Record = { + dependencies: 'dep', + techniques: 'tech', + patterns: 'pat', + dataprep: 'prep', + styling: 'style', +}; + +export type TrackEventFn = (name: string, props?: Record) => void; + +// Parse text with backticks into React nodes with inline code styling +export function parseInlineCode(text: string): React.ReactNode[] { + const parts = text.split(/(`[^`]+`)/g); + return parts.map((part, i) => { + if (part.startsWith('`') && part.endsWith('`')) { + return ( + + {part.slice(1, -1)} + + ); + } + return part; + }); +} + +// Format date +export function formatDate(dateStr?: string) { + if (!dateStr) return null; + try { + return new Date(dateStr).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } catch { + return dateStr; + } +} From e154ac65f598066e487071c7f4b47c0bd8898f7f Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 10 Jun 2026 03:52:05 +0200 Subject: [PATCH 2/3] fix(app): treat quality_score 0 as a real score + keyboard-accessible criteria headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review findings on the SpecTabs decomposition (pre-existing behavior moved verbatim, fixed in a separate commit to keep the decomposition pure): - qualityScore truthiness misclassified a legitimate score of 0 as missing (N/A display, no-data guard, Quality tab label) — explicit null checks now - QualityTab category headers were clickable plain divs; now MUI ButtonBase with aria-expanded and disabled state for item-less categories Co-Authored-By: Claude Fable 5 --- .../spec-detail/SpecTabs/QualityTab.test.tsx | 9 +++-- .../spec-detail/SpecTabs/QualityTab.tsx | 39 +++++++++++-------- .../sections/spec-detail/SpecTabs/index.tsx | 2 +- app/src/test-utils.tsx | 2 +- 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/app/src/sections/spec-detail/SpecTabs/QualityTab.test.tsx b/app/src/sections/spec-detail/SpecTabs/QualityTab.test.tsx index 82741fa0d2..58bd5ca95f 100644 --- a/app/src/sections/spec-detail/SpecTabs/QualityTab.test.tsx +++ b/app/src/sections/spec-detail/SpecTabs/QualityTab.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { QualityTab } from 'src/sections/spec-detail/SpecTabs/QualityTab'; -import { render, screen, userEvent } from 'src/test-utils'; +import { fireEvent, render, screen, userEvent } from 'src/test-utils'; const criteriaChecklist = { visual_quality: { @@ -56,8 +56,7 @@ describe('QualityTab', () => { expect(onToggleCategory).toHaveBeenCalledWith('visual_quality'); }); - it('does not call onToggleCategory for categories without items', async () => { - const user = userEvent.setup(); + it('disables the header for categories without items', () => { const onToggleCategory = vi.fn(); render( { /> ); - await user.click(screen.getByText('accuracy')); + const header = screen.getByText('accuracy').closest('button'); + expect(header).toBeDisabled(); + fireEvent.click(screen.getByText('accuracy')); expect(onToggleCategory).not.toHaveBeenCalled(); }); diff --git a/app/src/sections/spec-detail/SpecTabs/QualityTab.tsx b/app/src/sections/spec-detail/SpecTabs/QualityTab.tsx index 6325dc3203..ad1a906e8d 100644 --- a/app/src/sections/spec-detail/SpecTabs/QualityTab.tsx +++ b/app/src/sections/spec-detail/SpecTabs/QualityTab.tsx @@ -1,6 +1,7 @@ import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import Box from '@mui/material/Box'; +import ButtonBase from '@mui/material/ButtonBase'; import Collapse from '@mui/material/Collapse'; import Typography from '@mui/material/Typography'; @@ -37,14 +38,14 @@ export function QualityTab({ fontSize: '2rem', fontWeight: 700, color: - qualityScore && qualityScore >= 90 + qualityScore !== null && qualityScore >= 90 ? colors.success - : qualityScore && qualityScore >= 70 + : qualityScore !== null && qualityScore >= 70 ? colors.warning : colors.error, }} > - {qualityScore ? `${Math.round(qualityScore)}/100` : 'N/A'} + {qualityScore !== null ? `${Math.round(qualityScore)}/100` : 'N/A'} {/* Criteria Checklist */} @@ -74,13 +75,18 @@ export function QualityTab({ return ( {/* Category header - clickable */} - items.length > 0 && onToggleCategory(category)} + disabled={items.length === 0} + disableRipple + aria-expanded={items.length > 0 ? isExpanded : undefined} sx={{ + width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5, + textAlign: 'left', cursor: items.length > 0 ? 'pointer' : 'default', '&:hover': items.length > 0 ? { opacity: 0.8 } : {}, }} @@ -111,7 +117,7 @@ export function QualityTab({ > {score}/{max} - + {/* Progress bar */} - No quality data available. - - )} + {qualityScore === null && + (!criteriaChecklist || Object.keys(criteriaChecklist).length === 0) && ( + + No quality data available. + + )} ); } diff --git a/app/src/sections/spec-detail/SpecTabs/index.tsx b/app/src/sections/spec-detail/SpecTabs/index.tsx index ed8f5f93c5..68d6adb64a 100644 --- a/app/src/sections/spec-detail/SpecTabs/index.tsx +++ b/app/src/sections/spec-detail/SpecTabs/index.tsx @@ -246,7 +246,7 @@ export function SpecTabs({ /> } iconPosition="start" - label={qualityScore ? `${Math.round(qualityScore)}` : 'Quality'} + label={qualityScore !== null ? `${Math.round(qualityScore)}` : 'Quality'} /> )} diff --git a/app/src/test-utils.tsx b/app/src/test-utils.tsx index 4e11fb0770..e2a824815f 100644 --- a/app/src/test-utils.tsx +++ b/app/src/test-utils.tsx @@ -20,5 +20,5 @@ function customRender(ui: ReactElement, options?: Omit } export { customRender as render }; -export { screen, within, waitFor, act } from '@testing-library/react'; +export { screen, within, waitFor, act, fireEvent } from '@testing-library/react'; export { default as userEvent } from '@testing-library/user-event'; From 4323bf97476a89069ddeabbbdd142f7be51adf8d Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 10 Jun 2026 04:38:49 +0200 Subject: [PATCH 3/3] ci: retrigger required checks (no changes)