Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]
### Changed
- Add directory hierarchy awareness to document sorting ([#517](https://github.com/cucumber/react-components/pull/517)) by [@MuhammadTalha57](https://github.com/MuhammadTalha57)

## [24.3.0] - 2026-04-13
### Added
Expand Down
197 changes: 197 additions & 0 deletions samples/sorting-directory-first.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// Generated file. Do not edit.
import { type Envelope, SourceMediaType } from '@cucumber/messages'

type FeatureSpec = {
uri: string
featureName: string
ids: {
scenario: string
pickle: string
testCase: string
testCaseStarted: string
}
nanos: number
}

function createFeatureEnvelopes({ uri, featureName, ids, nanos }: FeatureSpec): Envelope[] {
return [
{
source: {
uri,
data: `Feature: ${featureName}\n\n Scenario: A\n`,
mediaType: SourceMediaType.TEXT_X_CUCUMBER_GHERKIN_PLAIN,
},
},
{
gherkinDocument: {
feature: {
tags: [],
location: { line: 1, column: 1 },
language: 'en',
keyword: 'Feature',
name: featureName,
description: '',
children: [
{
scenario: {
id: ids.scenario,
tags: [],
location: { line: 3, column: 3 },
keyword: 'Scenario',
name: 'A',
description: '',
steps: [],
examples: [],
},
},
],
},
comments: [],
uri,
},
},
{
pickle: {
id: ids.pickle,
uri,
location: { line: 3, column: 3 },
astNodeIds: [ids.scenario],
tags: [],
name: 'A',
language: 'en',
steps: [],
},
},
{
testCase: {
id: ids.testCase,
pickleId: ids.pickle,
testSteps: [],
testRunStartedId: '0',
},
},
{
testCaseStarted: {
id: ids.testCaseStarted,
testCaseId: ids.testCase,
timestamp: { seconds: 0, nanos },
attempt: 0,
},
},
{
testCaseFinished: {
testCaseStartedId: ids.testCaseStarted,
timestamp: { seconds: 0, nanos: nanos + 1000000 },
willBeRetried: false,
},
},
]
}

const features: FeatureSpec[] = [
{
uri: 'Features/C/f8.feature',
featureName: 'f8',
ids: {
scenario: '29',
pickle: '30',
testCase: '31',
testCaseStarted: '32',
},
nanos: 15000000,
},
{
uri: 'Features/C/f7.feature',
featureName: 'f7',
ids: {
scenario: '25',
pickle: '26',
testCase: '27',
testCaseStarted: '28',
},
nanos: 13000000,
},
{
uri: 'Features/C/D/f6.feature',
featureName: 'f6',
ids: {
scenario: '21',
pickle: '22',
testCase: '23',
testCaseStarted: '24',
},
nanos: 11000000,
},
{
uri: 'Features/C/D/f5.feature',
featureName: 'f5',
ids: {
scenario: '17',
pickle: '18',
testCase: '19',
testCaseStarted: '20',
},
nanos: 9000000,
},
{
uri: 'Features/A/f4.feature',
featureName: 'f4',
ids: {
scenario: '13',
pickle: '14',
testCase: '15',
testCaseStarted: '16',
},
nanos: 7000000,
},
{
uri: 'Features/A/f3.feature',
featureName: 'f3',
ids: {
scenario: '9',
pickle: '10',
testCase: '11',
testCaseStarted: '12',
},
nanos: 5000000,
},
{
uri: 'Features/A/B/f1.feature',
featureName: 'f1',
ids: {
scenario: '1',
pickle: '2',
testCase: '3',
testCaseStarted: '4',
},
nanos: 1000000,
},
{
uri: 'Features/A/B/f2.feature',
featureName: 'f2',
ids: {
scenario: '5',
pickle: '6',
testCase: '7',
testCaseStarted: '8',
},
nanos: 3000000,
},
]

export default [
{
testRunStarted: {
id: '0',
timestamp: { seconds: 0, nanos: 0 },
},
},
...features.flatMap((feature) => createFeatureEnvelopes(feature)),
{
testRunFinished: {
testRunStartedId: '0',
timestamp: { seconds: 0, nanos: 17000000 },
success: true,
},
},
] as ReadonlyArray<Envelope>
20 changes: 11 additions & 9 deletions src/components/app/FilteredDocuments.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import examplesTables from '../../../acceptance/examples-tables/examples-tables.
import hooksConditional from '../../../acceptance/hooks-conditional/hooks-conditional.js'
import retry from '../../../acceptance/retry/retry.js'
import rules from '../../../acceptance/rules/rules.js'
import randomOrderRun from '../../../samples/random-order-run.js'
import directoryFirstSort from '../../../samples/sorting-directory-first.js'
import targetedRun from '../../../samples/targeted-run.js'
import { EnvelopesProvider } from './EnvelopesProvider.js'
import { FilteredDocuments } from './FilteredDocuments.js'
Expand Down Expand Up @@ -43,9 +43,9 @@ describe('FilteredDocuments', () => {
})
})

it('displays features in alphabetical order by URI', async () => {
it('sorts nested feature paths before sibling files', async () => {
const { getAllByRole } = render(
<EnvelopesProvider envelopes={randomOrderRun}>
<EnvelopesProvider envelopes={directoryFirstSort}>
<FilteredDocuments />
</EnvelopesProvider>
)
Expand All @@ -54,13 +54,15 @@ describe('FilteredDocuments', () => {
const headings = getAllByRole('heading', { level: 3 })
const featureNames = headings.map((heading: HTMLElement) => heading.textContent)

// Verify the features are displayed in alphabetical order by URI
expect(featureNames).to.deep.equal([
'Features/a.feature',
'Features/B.feature',
'Features/c.feature',
'Features/d/e.feature',
'Features/f.feature',
'Features/A/B/f1.feature',
'Features/A/B/f2.feature',
'Features/A/f3.feature',
'Features/A/f4.feature',
'Features/C/D/f5.feature',
'Features/C/D/f6.feature',
'Features/C/f7.feature',
'Features/C/f8.feature',
])
})
})
Expand Down
31 changes: 31 additions & 0 deletions src/hooks/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,34 @@ export function ensure<T>(value: T | undefined, message: string): T {
}
return value
}

// Helper function for sorting directories
export function comparePaths(uriA: string, uriB: string): number {
// Assumes that last part of every uri is a file
const partsA = uriA.split('/')
const partsB = uriB.split('/')
const minLength = Math.min(partsA.length, partsB.length)

for (let i = 0; i < minLength; i++) {
const partA = partsA[i]
const partB = partsB[i]

if (partA !== partB) {
const isALast = i === partsA.length - 1
const isBLast = i === partsB.length - 1

if (isALast && !isBLast) {
return 1 // A is file and B is directory -> B comes first
}
if (!isALast && isBLast) {
return -1 // A is directory and B is file -> A comes first
}

// Both are files or both are directories -> Alphabetical sorting
return partA.localeCompare(partB)
}
}

// If one path is prefix of other then shorter path comes first
return partsA.length - partsB.length
}
4 changes: 2 additions & 2 deletions src/hooks/useFilteredDocuments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
pruneGherkinDocuments,
type SearchIndex,
} from '../search/index.js'
import { ensure } from './helpers.js'
import { comparePaths, ensure } from './helpers.js'
import { useQueries } from './useQueries.js'
import { useSearch } from './useSearch.js'

Expand Down Expand Up @@ -52,7 +52,7 @@ export function useFilteredDocuments(): {
const [results, setResults] = useState<ReadonlyArray<GherkinDocument>>()
const setResultsSorting: Dispatch<ReadonlyArray<GherkinDocument>> = useCallback((unsorted) => {
const sorted = [...unsorted]
sorted.sort((a, b) => (a.uri || '').localeCompare(b.uri || ''))
sorted.sort((a, b) => comparePaths(a.uri || '', b.uri || ''))
setResults(sorted)
}, [])

Expand Down