diff --git a/CHANGELOG.md b/CHANGELOG.md index c4701670..80bd6c78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/samples/sorting-directory-first.ts b/samples/sorting-directory-first.ts new file mode 100644 index 00000000..cc9eaeb6 --- /dev/null +++ b/samples/sorting-directory-first.ts @@ -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 diff --git a/src/components/app/FilteredDocuments.spec.tsx b/src/components/app/FilteredDocuments.spec.tsx index 3327b7a6..d74784b9 100644 --- a/src/components/app/FilteredDocuments.spec.tsx +++ b/src/components/app/FilteredDocuments.spec.tsx @@ -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' @@ -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( - + ) @@ -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', ]) }) }) diff --git a/src/hooks/helpers.ts b/src/hooks/helpers.ts index 721b9149..81cea466 100644 --- a/src/hooks/helpers.ts +++ b/src/hooks/helpers.ts @@ -4,3 +4,34 @@ export function ensure(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 +} diff --git a/src/hooks/useFilteredDocuments.ts b/src/hooks/useFilteredDocuments.ts index 0b2b3387..9ee7cb68 100644 --- a/src/hooks/useFilteredDocuments.ts +++ b/src/hooks/useFilteredDocuments.ts @@ -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' @@ -52,7 +52,7 @@ export function useFilteredDocuments(): { const [results, setResults] = useState>() const setResultsSorting: Dispatch> = 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) }, [])