22
33import getScoreTextColor from "@/utils/get-score-test-color" ;
44import {
5+ CaretDown ,
6+ CaretRight ,
57 FileMagnifyingGlass ,
68 Info ,
79 Lightning ,
@@ -10,7 +12,7 @@ import {
1012 type Icon ,
1113} from "@phosphor-icons/react" ;
1214import classNames from "classnames" ;
13- import { useState } from "react" ;
15+ import { useMemo , useState } from "react" ;
1416import { useModal } from "react-modal-hook" ;
1517import IssueModal from "../IssueModal" ;
1618
@@ -25,8 +27,16 @@ export type Issue = {
2527 } ;
2628} ;
2729
28- const PAGE_LENGTH = 5 ;
29- const INITIAL_LENGTH = 5 ;
30+ type GroupedIssue = {
31+ code : string ;
32+ severity : number ;
33+ message : string ;
34+ occurrences : Issue [ ] ;
35+ } ;
36+
37+ const INITIAL_GROUPS = 5 ;
38+ const PAGE_GROUPS = 5 ;
39+ const AUTO_EXPAND_THRESHOLD = 3 ;
3040
3141type SeverityKey = "error" | "warning" | "info" | "hint" ;
3242
@@ -69,6 +79,63 @@ const SeverityTag = ({ severity }: { severity: number }) => {
6979 ) ;
7080} ;
7181
82+ // Build a friendly operation label from a Spectral issue path.
83+ // e.g. ["paths", "/users/{id}", "get", "responses", "401"] -> "GET /users/{id}"
84+ // Falls back to the joined path when no HTTP verb is found.
85+ const HTTP_METHODS = new Set ( [
86+ "get" ,
87+ "post" ,
88+ "put" ,
89+ "patch" ,
90+ "delete" ,
91+ "options" ,
92+ "head" ,
93+ "trace" ,
94+ ] ) ;
95+
96+ function describeOccurrence ( issue : Issue ) : string {
97+ const parts = issue . path . map ( String ) ;
98+ const pathsIdx = parts . indexOf ( "paths" ) ;
99+ if ( pathsIdx >= 0 && pathsIdx + 2 < parts . length ) {
100+ const route = parts [ pathsIdx + 1 ] ;
101+ const method = parts [ pathsIdx + 2 ] ;
102+ if ( HTTP_METHODS . has ( method . toLowerCase ( ) ) ) {
103+ return `${ method . toUpperCase ( ) } ${ route } ` ;
104+ }
105+ }
106+ return parts . join ( "." ) || "(root)" ;
107+ }
108+
109+ function groupIssues ( issues : Issue [ ] ) : GroupedIssue [ ] {
110+ const map = new Map < string , GroupedIssue > ( ) ;
111+ for ( const issue of issues ) {
112+ const code = String ( issue . code ) ;
113+ const existing = map . get ( code ) ;
114+ if ( existing ) {
115+ existing . occurrences . push ( issue ) ;
116+ // Keep the most-severe representative for the group header (lower = worse).
117+ if ( issue . severity < existing . severity ) {
118+ existing . severity = issue . severity ;
119+ existing . message = issue . message ;
120+ }
121+ } else {
122+ map . set ( code , {
123+ code,
124+ severity : issue . severity ,
125+ message : issue . message ,
126+ occurrences : [ issue ] ,
127+ } ) ;
128+ }
129+ }
130+ return Array . from ( map . values ( ) ) . sort ( ( a , b ) => {
131+ if ( a . severity !== b . severity ) return a . severity - b . severity ;
132+ if ( a . occurrences . length !== b . occurrences . length ) {
133+ return b . occurrences . length - a . occurrences . length ;
134+ }
135+ return a . message . localeCompare ( b . message ) ;
136+ } ) ;
137+ }
138+
72139const DetailedScoreSection = ( {
73140 title,
74141 score,
@@ -84,16 +151,36 @@ const DetailedScoreSection = ({
84151 openapi : string ;
85152 fileExtension : "json" | "yaml" ;
86153} ) => {
87- const [ page , setPage ] = useState ( 0 ) ;
88154 const scoreTextColor = getScoreTextColor ( score ) ;
89155 const titleSlug = title . toLowerCase ( ) . replace ( " " , "-" ) ;
156+
157+ const groups = useMemo ( ( ) => groupIssues ( issues ) , [ issues ] ) ;
158+ const groupCount = groups . length ;
90159 const issueCount = issues . length ;
160+
161+ const [ page , setPage ] = useState ( 0 ) ;
91162 const totalPages = Math . max (
92163 0 ,
93- Math . ceil ( ( issueCount - INITIAL_LENGTH ) / PAGE_LENGTH ) ,
164+ Math . ceil ( ( groupCount - INITIAL_GROUPS ) / PAGE_GROUPS ) ,
94165 ) ;
95- const [ issueToView , setIssueToView ] = useState < Issue | undefined > ( ) ;
166+ const visibleCount = page
167+ ? PAGE_GROUPS * page + INITIAL_GROUPS
168+ : INITIAL_GROUPS ;
169+ const visibleGroups = groups . slice ( 0 , visibleCount ) ;
96170
171+ const [ expanded , setExpanded ] = useState < Record < string , boolean > > ( { } ) ;
172+ const isExpanded = ( code : string , occurrenceCount : number ) => {
173+ if ( code in expanded ) return expanded [ code ] ;
174+ return occurrenceCount <= AUTO_EXPAND_THRESHOLD ;
175+ } ;
176+ const toggle = ( code : string , occurrenceCount : number ) => {
177+ setExpanded ( ( prev ) => ( {
178+ ...prev ,
179+ [ code ] : ! isExpanded ( code , occurrenceCount ) ,
180+ } ) ) ;
181+ } ;
182+
183+ const [ issueToView , setIssueToView ] = useState < Issue | undefined > ( ) ;
97184 const handleViewClick = ( issue : Issue ) => {
98185 setIssueToView ( issue ) ;
99186 showModal ( ) ;
@@ -111,11 +198,6 @@ const DetailedScoreSection = ({
111198 ) ;
112199 } , [ issueToView ] ) ;
113200
114- const visibleIssues = issues . slice (
115- 0 ,
116- page ? PAGE_LENGTH * page + INITIAL_LENGTH : INITIAL_LENGTH ,
117- ) ;
118-
119201 return (
120202 < section className = "card mb-6 overflow-hidden p-0" >
121203 < header className = "border-border flex flex-col items-start justify-between gap-3 border-b p-5 md:flex-row md:items-center md:p-6" >
@@ -164,37 +246,66 @@ const DetailedScoreSection = ({
164246 < th className = "text-fg-faint px-5 py-2.5 text-[11px] font-semibold tracking-[0.05em] uppercase" >
165247 Issue
166248 </ th >
249+ < th className = "text-fg-faint w-[100px] px-5 py-2.5 text-right text-[11px] font-semibold tracking-[0.05em] uppercase" >
250+ Count
251+ </ th >
167252 < th className = "text-fg-faint w-[60px] px-5 py-2.5 text-[11px] font-semibold tracking-[0.05em] uppercase" />
168253 </ tr >
169254 </ thead >
170255 < tbody >
171- { visibleIssues . map ( ( issue , index : number ) => {
172- const onActivate = ( ) => handleViewClick ( issue ) ;
173- return (
256+ { visibleGroups . map ( ( group , groupIndex ) => {
257+ const expandedNow = isExpanded (
258+ group . code ,
259+ group . occurrences . length ,
260+ ) ;
261+ const single = group . occurrences . length === 1 ;
262+ const Caret = expandedNow ? CaretDown : CaretRight ;
263+ const handleHeaderActivate = ( ) => {
264+ if ( single ) {
265+ handleViewClick ( group . occurrences [ 0 ] ) ;
266+ } else {
267+ toggle ( group . code , group . occurrences . length ) ;
268+ }
269+ } ;
270+ return [
174271 < tr
175- key = { `${ titleSlug } -table-row- ${ index } ` }
272+ key = { `${ titleSlug } -group- ${ groupIndex } ` }
176273 role = "button"
177274 tabIndex = { 0 }
178- aria-label = { `View issue: ${ issue . message } ` }
179- onClick = { onActivate }
275+ aria-expanded = { single ? undefined : expandedNow }
276+ aria-label = {
277+ single
278+ ? `View issue: ${ group . message } `
279+ : `${ expandedNow ? "Collapse" : "Expand" } ${
280+ group . occurrences . length
281+ } occurrences of ${ group . message } `
282+ }
283+ onClick = { handleHeaderActivate }
180284 onKeyDown = { ( event ) => {
181285 if ( event . key === "Enter" || event . key === " " ) {
182286 event . preventDefault ( ) ;
183- onActivate ( ) ;
287+ handleHeaderActivate ( ) ;
184288 }
185289 } }
186- className = "border-bg-muted text-fg-secondary hover:bg-bg-subtle focus-visible:bg-bg-subtle focus-visible:outline-accent cursor-pointer border-b transition-colors last:border-b-0 focus-visible:outline-2 focus-visible:-outline-offset-2"
290+ className = "border-bg-muted text-fg-secondary hover:bg-bg-subtle focus-visible:bg-bg-subtle focus-visible:outline-accent cursor-pointer border-b transition-colors focus-visible:outline-2 focus-visible:-outline-offset-2"
187291 >
188292 < td className = "px-5 py-3 align-top" >
189- < SeverityTag severity = { issue . severity } />
293+ < SeverityTag severity = { group . severity } />
190294 </ td >
191295 < td className = "px-5 py-3 align-top" >
192296 < span className = "text-fg block break-words" >
193- { issue . message }
297+ { group . message }
194298 </ span >
195- { typeof issue . code !== "undefined" && (
196- < span className = "text-fg-muted mt-1 block font-mono text-xs" >
197- { issue . code }
299+ < span className = "text-fg-muted mt-1 block font-mono text-xs" >
300+ { group . code }
301+ </ span >
302+ </ td >
303+ < td className = "px-5 py-3 text-right align-top" >
304+ { single ? (
305+ < span className = "text-fg-faint text-xs" > —</ span >
306+ ) : (
307+ < span className = "badge-numeric badge-neutral" >
308+ × { group . occurrences . length }
198309 </ span >
199310 ) }
200311 </ td >
@@ -203,35 +314,84 @@ const DetailedScoreSection = ({
203314 aria-hidden = "true"
204315 className = "btn btn-ghost btn-icon"
205316 >
206- < FileMagnifyingGlass size = { 16 } weight = "regular" />
317+ { single ? (
318+ < FileMagnifyingGlass size = { 16 } weight = "regular" />
319+ ) : (
320+ < Caret size = { 16 } weight = "regular" />
321+ ) }
207322 </ span >
208323 </ td >
209- </ tr >
210- ) ;
324+ </ tr > ,
325+ ! single &&
326+ expandedNow &&
327+ group . occurrences . map ( ( occ , occIndex ) => {
328+ const line = occ . range . start . line + 1 ;
329+ const where = describeOccurrence ( occ ) ;
330+ const onActivate = ( ) => handleViewClick ( occ ) ;
331+ return (
332+ < tr
333+ key = { `${ titleSlug } -group-${ groupIndex } -occ-${ occIndex } ` }
334+ role = "button"
335+ tabIndex = { 0 }
336+ aria-label = { `View occurrence at ${ where } , line ${ line } ` }
337+ onClick = { onActivate }
338+ onKeyDown = { ( event ) => {
339+ if ( event . key === "Enter" || event . key === " " ) {
340+ event . preventDefault ( ) ;
341+ onActivate ( ) ;
342+ }
343+ } }
344+ className = "border-bg-muted text-fg-muted hover:bg-bg-subtle focus-visible:bg-bg-subtle focus-visible:outline-accent bg-bg-subtle/40 cursor-pointer border-b transition-colors focus-visible:outline-2 focus-visible:-outline-offset-2"
345+ >
346+ < td className = "px-5 py-2 align-top" />
347+ < td className = "px-5 py-2 align-top" >
348+ < div className = "flex flex-col gap-1 pl-4" >
349+ < span className = "text-fg-secondary font-mono text-xs" >
350+ { where }
351+ </ span >
352+ </ div >
353+ </ td >
354+ < td className = "px-5 py-2 text-right align-top" >
355+ < span className = "badge-numeric badge-neutral" >
356+ L{ line }
357+ </ span >
358+ </ td >
359+ < td className = "px-3 py-2 text-right align-top" >
360+ < span
361+ aria-hidden = "true"
362+ className = "btn btn-ghost btn-icon"
363+ >
364+ < FileMagnifyingGlass size = { 14 } weight = "regular" />
365+ </ span >
366+ </ td >
367+ </ tr >
368+ ) ;
369+ } ) ,
370+ ] ;
211371 } ) }
212372 </ tbody >
213373 </ table >
214374 </ div >
215375 ) }
216376
217- { issueCount > INITIAL_LENGTH && (
377+ { groupCount > INITIAL_GROUPS && (
218378 < div className = "border-border bg-bg-subtle flex flex-col items-start gap-2 border-t px-5 py-4 md:flex-row md:items-center md:justify-between" >
219379 < span className = "text-fg-muted text-xs" >
220380 Showing{ " " }
221381 < span className = "text-fg font-semibold" >
222- { visibleIssues . length }
382+ { visibleGroups . length }
223383 </ span > { " " }
224- of < span className = "text-fg font-semibold" > { issueCount } </ span > { " " }
225- issues
384+ of < span className = "text-fg font-semibold" > { groupCount } </ span > { " " }
385+ unique issues ( { issueCount } total occurrences)
226386 </ span >
227387 < div className = "flex flex-wrap gap-2" >
228- { page < totalPages && issueCount > PAGE_LENGTH + INITIAL_LENGTH && (
388+ { page < totalPages && groupCount > PAGE_GROUPS + INITIAL_GROUPS && (
229389 < button
230390 type = "button"
231391 onClick = { ( ) => setPage ( page + 1 ) }
232392 className = "btn btn-ghost btn-sm"
233393 >
234- Show { PAGE_LENGTH } more
394+ Show { PAGE_GROUPS } more
235395 </ button >
236396 ) }
237397 { page < totalPages && (
@@ -240,7 +400,7 @@ const DetailedScoreSection = ({
240400 onClick = { ( ) => setPage ( totalPages ) }
241401 className = "btn btn-outlined btn-sm"
242402 >
243- Show all { issueCount }
403+ Show all { groupCount }
244404 </ button >
245405 ) }
246406 { page >= totalPages && (
0 commit comments