Skip to content
Open
7 changes: 1 addition & 6 deletions app/components/Package/Versions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
107 changes: 80 additions & 27 deletions app/components/VersionSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ const isLoadingAll = shallowRef(false)
/** Cached full version list */
const allVersionsCache = shallowRef<PackageVersionInfo[] | null>(null)

/** Whether non-tagged version groups are visible */
const showAllGroups = shallowRef(false)

// ============================================================================
// Computed
// ============================================================================
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -518,7 +571,7 @@ watch(
@keydown="handleListboxKeydown"
>
<!-- Version groups -->
<div v-for="group in versionGroups" :key="group.id">
<div v-for="group in visibleVersionGroups" :key="group.id">
<!-- Group header (primary version) -->
<div
:id="`version-${group.primaryVersion.version}`"
Expand All @@ -539,11 +592,11 @@ watch(
>
<!-- Expand button -->
<button
v-if="group.versions.length > 1 || !hasLoadedAll"
v-if="canToggleGroup(group)"
type="button"
class="w-4 h-4 flex items-center justify-center text-fg-subtle hover:text-fg transition-colors shrink-0"
:aria-expanded="group.isExpanded"
:aria-label="group.isExpanded ? $t('common.collapse') : $t('common.expand')"
:aria-expanded="isGroupOpen(group)"
:aria-label="isGroupOpen(group) ? $t('common.collapse') : $t('common.expand')"
@click.stop="toggleGroup(group.id)"
>
<span
Expand All @@ -554,11 +607,11 @@ watch(
<span
v-else
class="w-3 h-3 transition-transform duration-200 rtl-flip"
:class="group.isExpanded ? 'i-lucide:chevron-down' : 'i-lucide:chevron-right'"
:class="isGroupOpen(group) ? 'i-lucide:chevron-down' : 'i-lucide:chevron-right'"
aria-hidden="true"
/>
</button>
<span v-else class="w-4" />
<span v-else class="w-4 h-4 shrink-0" />

<!-- Version link -->
<NuxtLink
Expand Down Expand Up @@ -626,7 +679,7 @@ watch(
<!-- Link to package page for full version list -->
<div class="border-t border-border mt-1 pt-1 px-3 py-2">
<NuxtLink
:to="packageRoute(packageName)"
:to="packageVersionsRoute(packageName)"
class="text-xs text-fg-subtle hover:text-fg transition-[color] focus-visible:outline-none focus-visible:text-fg"
@click="isOpen = false"
>
Expand Down
6 changes: 6 additions & 0 deletions app/utils/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions test/nuxt/components/Package/Versions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -39,6 +40,12 @@ function isVersionLink(a: DOMWrapper<Element>): boolean {
)
}

function getRouter(
component: Awaited<ReturnType<typeof mountSuspended>>,
): Pick<typeof component.vm.$router, 'resolve'> {
return component.vm.$router
}

describe('PackageVersions', () => {
beforeEach(() => {
mockFetchAllPackageVersions.mockReset()
Expand Down Expand Up @@ -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: {
Expand Down
Loading
Loading