diff --git a/app/components/Package/Versions.vue b/app/components/Package/Versions.vue index fd5544db22..f915eb7594 100644 --- a/app/components/Package/Versions.vue +++ b/app/components/Package/Versions.vue @@ -96,12 +96,7 @@ function versionRoute(version: string): RouteLocationRaw { } // Route to the full versions history page -const versionsPageRoute = computed((): RouteLocationRaw => { - const [org, name = ''] = props.packageName.startsWith('@') - ? props.packageName.split('/') - : ['', props.packageName] - return { name: 'package-versions', params: { org, name } } -}) +const versionsPageRoute = computed(() => packageVersionsRoute(props.packageName)) // Version to tags lookup (supports multiple tags per version) const versionToTags = computed(() => buildVersionToTagsMap(props.distTags)) diff --git a/app/components/VersionSelector.vue b/app/components/VersionSelector.vue index eaac5f4fce..6cf5c9c071 100644 --- a/app/components/VersionSelector.vue +++ b/app/components/VersionSelector.vue @@ -70,6 +70,9 @@ const isLoadingAll = shallowRef(false) /** Cached full version list */ const allVersionsCache = shallowRef(null) +/** Whether non-tagged version groups are visible */ +const showAllGroups = shallowRef(false) + // ============================================================================ // Computed // ============================================================================ @@ -78,6 +81,18 @@ const latestVersion = computed(() => props.distTags.latest) const versionToTags = computed(() => buildVersionToTagsMap(props.distTags)) +const visibleVersionGroups = computed(() => { + if (!hasLoadedAll.value || showAllGroups.value) { + return versionGroups.value + } + + return versionGroups.value.filter(group => group.primaryVersion.tags?.length) +}) + +const hasAdditionalGroups = computed(() => + versionGroups.value.some(group => !group.primaryVersion.tags?.length), +) + /** Get URL for a specific version */ function getVersionUrl(version: string): string { return props.urlPattern.replace('{version}', version) @@ -310,30 +325,40 @@ async function toggleGroup(groupId: string) { const group = versionGroups.value.find(g => g.id === groupId) if (!group) return - if (group.isExpanded) { - group.isExpanded = false + if (group.isLoading) return + + if (hasLoadedAll.value) { + if (hasNestedVersions(group)) { + group.isExpanded = !group.isExpanded + return + } + + if (controlsAdditionalGroups(group)) { + showAllGroups.value = !showAllGroups.value + } + return } - // Load all versions if not yet loaded - if (!hasLoadedAll.value) { - group.isLoading = true - try { - const allVersions = await loadAllVersions() - processLoadedVersions(allVersions) - // Find the group again after processing (it may have moved) - const updatedGroup = versionGroups.value.find(g => g.id === groupId) - if (updatedGroup) { + group.isLoading = true + try { + const allVersions = await loadAllVersions() + processLoadedVersions(allVersions) + + // Find the group again after processing (it may have moved) + const updatedGroup = versionGroups.value.find(g => g.id === groupId) + if (updatedGroup) { + if (hasNestedVersions(updatedGroup)) { updatedGroup.isExpanded = true + } else if (controlsAdditionalGroups(updatedGroup)) { + showAllGroups.value = true } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to load versions:', error) - } finally { - group.isLoading = false } - } else { - group.isExpanded = true + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to load versions:', error) + } finally { + group.isLoading = false } } @@ -345,7 +370,7 @@ async function toggleGroup(groupId: string) { const flatItems = computed(() => { const items: Array<{ type: 'group' | 'version'; groupId: string; version?: VersionDisplay }> = [] - for (const group of versionGroups.value) { + for (const group of visibleVersionGroups.value) { items.push({ type: 'group', groupId: group.id, version: group.primaryVersion }) if (group.isExpanded && group.versions.length > 1) { @@ -401,7 +426,7 @@ function handleListboxKeydown(event: KeyboardEvent) { const item = items[focusedIndex.value] if (item?.type === 'group') { const group = versionGroups.value.find(g => g.id === item.groupId) - if (group && !group.isExpanded && group.versions.length > 1) { + if (group && !isGroupOpen(group) && canToggleGroup(group)) { toggleGroup(item.groupId) } } @@ -414,6 +439,8 @@ function handleListboxKeydown(event: KeyboardEvent) { const group = versionGroups.value.find(g => g.id === item.groupId) if (group?.isExpanded) { group.isExpanded = false + } else if (group && controlsAdditionalGroups(group) && showAllGroups.value) { + showAllGroups.value = false } } else if (item?.type === 'version') { // Jump to parent group @@ -450,6 +477,31 @@ function navigateToVersion(version: string) { navigateTo(getVersionUrl(version)) } +function hasNestedVersions(group: VersionGroup): boolean { + return group.versions.length > 1 +} + +function controlsAdditionalGroups(group: VersionGroup): boolean { + return ( + Boolean(group.primaryVersion.tags?.length) && + !hasNestedVersions(group) && + hasAdditionalGroups.value + ) +} + +function isGroupOpen(group: VersionGroup): boolean { + return group.isExpanded || (controlsAdditionalGroups(group) && showAllGroups.value) +} + +function canToggleGroup(group: VersionGroup): boolean { + return ( + group.isLoading || + hasNestedVersions(group) || + !hasLoadedAll.value || + controlsAdditionalGroups(group) + ) +} + // Reset focused index when dropdown opens watch(isOpen, open => { if (open) { @@ -463,6 +515,7 @@ watch(isOpen, open => { watch( () => [props.distTags, props.versions, props.currentVersion], () => { + showAllGroups.value = false if (hasLoadedAll.value && allVersionsCache.value) { processLoadedVersions(allVersionsCache.value) } else { @@ -518,7 +571,7 @@ watch( @keydown="handleListboxKeydown" > -
+
- +
diff --git a/app/utils/router.ts b/app/utils/router.ts index 0c22d92860..0c9bed9884 100644 --- a/app/utils/router.ts +++ b/app/utils/router.ts @@ -29,6 +29,12 @@ export function packageRoute( } } +/** Full version history page (`/package/.../versions`) */ +export function packageVersionsRoute(packageName: string): RouteLocationRaw { + const [org, name = ''] = packageName.startsWith('@') ? packageName.split('/') : ['', packageName] + return { name: 'package-versions', params: { org, name } } +} + export function diffRoute( packageName: string, fromVersion: string, diff --git a/test/nuxt/components/Package/Versions.spec.ts b/test/nuxt/components/Package/Versions.spec.ts index 93db7103ab..bb5963bbf4 100644 --- a/test/nuxt/components/Package/Versions.spec.ts +++ b/test/nuxt/components/Package/Versions.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { mountSuspended } from '@nuxt/test-utils/runtime' import type { DOMWrapper } from '@vue/test-utils' import PackageVersions from '~/components/Package/Versions.vue' +import { packageVersionsRoute } from '~/utils/router' // Mock the fetchAllPackageVersions function const mockFetchAllPackageVersions = vi.fn() @@ -39,6 +40,12 @@ function isVersionLink(a: DOMWrapper): boolean { ) } +function getRouter( + component: Awaited>, +): Pick { + return component.vm.$router +} + describe('PackageVersions', () => { beforeEach(() => { mockFetchAllPackageVersions.mockReset() @@ -109,6 +116,42 @@ describe('PackageVersions', () => { expect(versionLinks[0]?.text()).toBe('1.0.0') }) + it('view-all-versions link uses packageVersionsRoute for unscoped packages', async () => { + const component = await mountSuspended(PackageVersions, { + props: { + packageName: 'test-package', + versions: { + '1.0.0': createVersion('1.0.0'), + }, + distTags: { latest: '1.0.0' }, + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, + }, + }) + + const router = getRouter(component) + const expectedHref = router.resolve(packageVersionsRoute('test-package')).href + const viewAll = component.find('[data-testid="view-all-versions-link"]') + expect(viewAll.attributes('href')).toBe(expectedHref) + }) + + it('view-all-versions link uses packageVersionsRoute for scoped packages', async () => { + const component = await mountSuspended(PackageVersions, { + props: { + packageName: '@scope/test-package', + versions: { + '1.0.0': createVersion('1.0.0'), + }, + distTags: { latest: '1.0.0' }, + time: { '1.0.0': '2024-01-15T12:00:00.000Z' }, + }, + }) + + const router = getRouter(component) + const expectedHref = router.resolve(packageVersionsRoute('@scope/test-package')).href + const viewAll = component.find('[data-testid="view-all-versions-link"]') + expect(viewAll.attributes('href')).toBe(expectedHref) + }) + it('highlights the current version row when selectedVersion prop matches', async () => { const component = await mountSuspended(PackageVersions, { props: { diff --git a/test/nuxt/components/VersionSelector.spec.ts b/test/nuxt/components/VersionSelector.spec.ts index df6d3a2a1d..cb1afeb5f8 100644 --- a/test/nuxt/components/VersionSelector.spec.ts +++ b/test/nuxt/components/VersionSelector.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { mountSuspended } from '@nuxt/test-utils/runtime' +import type { PackageVersionInfo } from '#shared/types/npm-registry' import VersionSelector from '~/components/VersionSelector.vue' // Mock the fetchAllPackageVersions function @@ -424,6 +425,187 @@ describe('VersionSelector', () => { { timeout: 2000 }, ) }) + + it('toggles older version groups for a single-version tagged release', async () => { + mockFetchAllPackageVersions.mockResolvedValue([ + { version: '1.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false }, + { version: '0.9.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false }, + ]) + + const component = await mountSuspended(VersionSelector, { + props: { + packageName: 'test-package', + currentVersion: '1.0.0', + versions: { '1.0.0': {}, '0.9.0': {} }, + distTags: { latest: '1.0.0' }, + urlPattern: '/package-docs/test-package/v/{version}', + }, + }) + + const button = component.find('button[aria-haspopup="listbox"]') + await button.trigger('click') + + const expandButton = component.find('[role="listbox"] button[aria-expanded="false"]') + await expandButton.trigger('click') + + await vi.waitFor(() => { + expect(mockFetchAllPackageVersions).toHaveBeenCalledWith('test-package') + }) + + await vi.waitFor(() => { + expect(component.find('[role="listbox"]').text()).toContain('0.9') + const expandedButton = component.find('[role="listbox"] button[aria-expanded="true"]') + expect(expandedButton.exists()).toBe(true) + }) + + const expandedButton = component.find('[role="listbox"] button[aria-expanded="true"]') + await expandedButton.trigger('click') + + await vi.waitFor(() => { + expect(component.find('[role="listbox"]').text()).not.toContain('0.9') + const collapsedButton = component.find('[role="listbox"] button[aria-expanded="false"]') + expect(collapsedButton.exists()).toBe(true) + }) + }) + + it('does not reveal unrelated older groups when expanding a tagged row with nested versions', async () => { + mockFetchAllPackageVersions.mockResolvedValue([ + { version: '1.2.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false }, + { version: '1.1.0', time: '2024-01-12T12:00:00.000Z', hasProvenance: false }, + { version: '1.0.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false }, + { version: '0.9.0', time: '2024-01-08T12:00:00.000Z', hasProvenance: false }, + ]) + + const component = await mountSuspended(VersionSelector, { + props: { + packageName: 'test-package', + currentVersion: '1.2.0', + versions: { '1.2.0': {}, '1.1.0': {}, '1.0.0': {}, '0.9.0': {} }, + distTags: { latest: '1.2.0' }, + urlPattern: '/package-docs/test-package/v/{version}', + }, + }) + + const trigger = component.find('button[aria-haspopup="listbox"]') + await trigger.trigger('click') + + const expandButton = component.find('[role="listbox"] button[aria-expanded="false"]') + await expandButton.trigger('click') + + await vi.waitFor(() => { + expect(mockFetchAllPackageVersions).toHaveBeenCalledWith('test-package') + }) + + await vi.waitFor(() => { + const listboxText = component.find('[role="listbox"]').text() + expect(listboxText).toContain('1.1.0') + expect(listboxText).toContain('1.0.0') + expect(listboxText).not.toContain('0.9') + }) + + const expandedButton = component.find('[role="listbox"] button[aria-expanded="true"]') + await expandedButton.trigger('click') + + await vi.waitFor(() => { + const listboxText = component.find('[role="listbox"]').text() + expect(listboxText).not.toContain('1.1.0') + expect(listboxText).not.toContain('1.0.0') + expect(listboxText).not.toContain('0.9') + }) + }) + + it('collapses additional version groups with ArrowLeft when showAllGroups is open', async () => { + mockFetchAllPackageVersions.mockResolvedValue([ + { version: '1.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false }, + { version: '0.9.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false }, + ]) + + const component = await mountSuspended(VersionSelector, { + props: { + packageName: 'test-package', + currentVersion: '1.0.0', + versions: { '1.0.0': {}, '0.9.0': {} }, + distTags: { latest: '1.0.0' }, + urlPattern: '/package-docs/test-package/v/{version}', + }, + }) + + const trigger = component.find('button[aria-haspopup="listbox"]') + await trigger.trigger('click') + + await component.find('[role="listbox"] button[aria-expanded="false"]').trigger('click') + + await vi.waitFor(() => { + expect(component.find('[role="listbox"]').text()).toContain('0.9') + }) + + const listbox = component.find('[role="listbox"]') + await listbox.trigger('keydown', { key: 'ArrowLeft' }) + + await vi.waitFor(() => { + expect(listbox.text()).not.toContain('0.9') + }) + }) + + it('resets showAllGroups when dist-tags props change after loading', async () => { + mockFetchAllPackageVersions.mockResolvedValue([ + { version: '1.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false }, + { version: '0.9.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false }, + ]) + + const component = await mountSuspended(VersionSelector, { + props: { + packageName: 'test-package', + currentVersion: '1.0.0', + versions: { '1.0.0': {}, '0.9.0': {} }, + distTags: { latest: '1.0.0' }, + urlPattern: '/package-docs/test-package/v/{version}', + }, + }) + + const trigger = component.find('button[aria-haspopup="listbox"]') + await trigger.trigger('click') + await component.find('[role="listbox"] button[aria-expanded="false"]').trigger('click') + + await vi.waitFor(() => { + expect(component.find('[role="listbox"]').text()).toContain('0.9') + }) + + await component.setProps({ distTags: { latest: '1.0.0' } }) + + await vi.waitFor(() => { + expect(component.find('[role="listbox"]').text()).not.toContain('0.9') + }) + }) + + it('ignores expand clicks while a group is already loading', async () => { + let finishLoad: (value: PackageVersionInfo[]) => void + const loadPromise = new Promise(resolve => { + finishLoad = resolve + }) + mockFetchAllPackageVersions.mockReturnValue(loadPromise) + + const component = await mountSuspended(VersionSelector, { + props: { + packageName: 'test-package', + currentVersion: '1.0.0', + versions: { '1.0.0': {} }, + distTags: { latest: '1.0.0' }, + urlPattern: '/package-docs/test-package/v/{version}', + }, + }) + + const trigger = component.find('button[aria-haspopup="listbox"]') + await trigger.trigger('click') + + const expandButton = component.find('[role="listbox"] button[aria-expanded]') + await expandButton.trigger('click') + await expandButton.trigger('click') + + expect(mockFetchAllPackageVersions).toHaveBeenCalledTimes(1) + + finishLoad!([{ version: '1.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false }]) + }) }) describe('0.x version grouping', () => { @@ -459,12 +641,10 @@ describe('VersionSelector', () => { // Wait for versions to load await vi.waitFor( () => { - // 0.9.x versions should NOT be under the 0.10.x group - // They should be in a separate group const text = component.text() - // The component should have separate groups for 0.10 and 0.9 expect(text).toContain('0.10') - expect(text).toContain('0.9') + expect(text).toContain('0.10.0') + expect(text).not.toContain('0.9') }, { timeout: 2000 }, )