[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(ui): hide awkward empty state for weekly downloads for new packages (#1054)

authored by

Wojciech Maj and committed by
GitHub
188e8876 145cc8db

+122 -40
+57 -40
app/components/Package/WeeklyDownloadStats.vue
··· 12 12 const hasChartModalTransitioned = shallowRef(false) 13 13 const isChartModalOpen = shallowRef(false) 14 14 15 - async function openChartModal() { 16 - isChartModalOpen.value = true 17 - hasChartModalTransitioned.value = false 18 - // ensure the component renders before opening the dialog 19 - await nextTick() 20 - await nextTick() 21 - chartModal.open() 22 - } 23 - 24 15 function handleModalClose() { 25 16 isChartModalOpen.value = false 26 17 hasChartModalTransitioned.value = false ··· 96 87 }) 97 88 98 89 const weeklyDownloads = shallowRef<WeeklyDownloadPoint[]>([]) 90 + const isLoadingWeeklyDownloads = shallowRef(true) 91 + const hasWeeklyDownloads = computed(() => weeklyDownloads.value.length > 0) 92 + 93 + async function openChartModal() { 94 + if (!hasWeeklyDownloads.value) return 95 + 96 + isChartModalOpen.value = true 97 + hasChartModalTransitioned.value = false 98 + // ensure the component renders before opening the dialog 99 + await nextTick() 100 + await nextTick() 101 + chartModal.open() 102 + } 99 103 100 104 async function loadWeeklyDownloads() { 101 105 if (!import.meta.client) return 102 106 107 + isLoadingWeeklyDownloads.value = true 103 108 try { 104 109 const result = await fetchPackageDownloadEvolution( 105 110 () => props.packageName, ··· 109 114 weeklyDownloads.value = (result as WeeklyDownloadPoint[]) ?? [] 110 115 } catch { 111 116 weeklyDownloads.value = [] 117 + } finally { 118 + isLoadingWeeklyDownloads.value = false 112 119 } 113 120 } 114 121 ··· 212 219 <CollapsibleSection id="downloads" :title="$t('package.downloads.title')"> 213 220 <template #actions> 214 221 <ButtonBase 222 + v-if="hasWeeklyDownloads" 215 223 type="button" 216 224 @click="openChartModal" 217 225 class="text-fg-subtle hover:text-fg transition-colors duration-200 inline-flex items-center justify-center min-w-6 min-h-6 -m-1 p-1 focus-visible:outline-accent/70 rounded" ··· 223 231 </template> 224 232 225 233 <div class="w-full overflow-hidden"> 226 - <ClientOnly> 227 - <VueUiSparkline class="w-full max-w-xs" :dataset :config> 228 - <template #skeleton> 229 - <!-- This empty div overrides the default built-in scanning animation on load --> 230 - <div /> 231 - </template> 232 - </VueUiSparkline> 233 - <template #fallback> 234 - <!-- Skeleton matching sparkline layout: title row + chart with data label --> 235 - <div class="min-h-[75.195px]"> 236 - <!-- Title row: date range (24px height) --> 237 - <div class="h-6 flex items-center ps-3"> 238 - <SkeletonInline class="h-3 w-36" /> 239 - </div> 240 - <!-- Chart area: data label left, sparkline right --> 241 - <div class="aspect-[500/80] flex items-center"> 242 - <!-- Data label (covers ~42% width) --> 243 - <div class="w-[42%] flex items-center ps-0.5"> 244 - <SkeletonInline class="h-7 w-24" /> 234 + <template v-if="isLoadingWeeklyDownloads || hasWeeklyDownloads"> 235 + <ClientOnly> 236 + <VueUiSparkline class="w-full max-w-xs" :dataset :config> 237 + <template #skeleton> 238 + <!-- This empty div overrides the default built-in scanning animation on load --> 239 + <div /> 240 + </template> 241 + </VueUiSparkline> 242 + <template #fallback> 243 + <!-- Skeleton matching sparkline layout: title row + chart with data label --> 244 + <div class="min-h-[75.195px]"> 245 + <!-- Title row: date range (24px height) --> 246 + <div class="h-6 flex items-center ps-3"> 247 + <SkeletonInline class="h-3 w-36" /> 245 248 </div> 246 - <!-- Sparkline area (~58% width) --> 247 - <div class="flex-1 flex items-end gap-0.5 h-4/5 pe-3"> 248 - <SkeletonInline 249 - v-for="i in 16" 250 - :key="i" 251 - class="flex-1 rounded-sm" 252 - :style="{ height: `${25 + ((i * 7) % 50)}%` }" 253 - /> 249 + <!-- Chart area: data label left, sparkline right --> 250 + <div class="aspect-[500/80] flex items-center"> 251 + <!-- Data label (covers ~42% width) --> 252 + <div class="w-[42%] flex items-center ps-0.5"> 253 + <SkeletonInline class="h-7 w-24" /> 254 + </div> 255 + <!-- Sparkline area (~58% width) --> 256 + <div class="flex-1 flex items-end gap-0.5 h-4/5 pe-3"> 257 + <SkeletonInline 258 + v-for="i in 16" 259 + :key="i" 260 + class="flex-1 rounded-sm" 261 + :style="{ height: `${25 + ((i * 7) % 50)}%` }" 262 + /> 263 + </div> 254 264 </div> 255 265 </div> 256 - </div> 257 - </template> 258 - </ClientOnly> 266 + </template> 267 + </ClientOnly> 268 + </template> 269 + <p v-else class="py-2 text-sm font-mono text-fg-subtle"> 270 + {{ $t('package.downloads.no_data') }} 271 + </p> 259 272 </div> 260 273 </CollapsibleSection> 261 274 </div> 262 275 263 - <PackageChartModal @close="handleModalClose" @transitioned="handleModalTransitioned"> 276 + <PackageChartModal 277 + v-if="isChartModalOpen && hasWeeklyDownloads" 278 + @close="handleModalClose" 279 + @transitioned="handleModalTransitioned" 280 + > 264 281 <!-- The Chart is mounted after the dialog has transitioned --> 265 282 <!-- This avoids flaky behavior that hides the chart's minimap half of the time --> 266 283 <Transition name="opacity" mode="out-in">
+65
test/nuxt/components/PackageWeeklyDownloadStats.spec.ts
··· 1 + import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime' 2 + import { defineComponent, h } from 'vue' 3 + import { describe, expect, it, vi } from 'vitest' 4 + 5 + const { mockFetchPackageDownloadEvolution } = vi.hoisted(() => ({ 6 + mockFetchPackageDownloadEvolution: vi.fn(), 7 + })) 8 + 9 + mockNuxtImport('useCharts', () => { 10 + return () => ({ 11 + fetchPackageDownloadEvolution: (...args: unknown[]) => 12 + mockFetchPackageDownloadEvolution(...args), 13 + }) 14 + }) 15 + 16 + vi.mock('vue-data-ui/vue-ui-sparkline', () => ({ 17 + VueUiSparkline: defineComponent({ 18 + name: 'VueUiSparkline', 19 + inheritAttrs: false, 20 + setup(_, { attrs, slots }) { 21 + return () => h('div', { class: attrs.class }, slots.default?.() ?? []) 22 + }, 23 + }), 24 + })) 25 + 26 + import PackageWeeklyDownloadStats from '~/components/Package/WeeklyDownloadStats.vue' 27 + 28 + describe('PackageWeeklyDownloadStats', () => { 29 + const baseProps = { 30 + packageName: 'test-package', 31 + createdIso: '2026-02-05T00:00:00.000Z', 32 + } 33 + 34 + it('hides the section when weekly downloads are empty', async () => { 35 + mockFetchPackageDownloadEvolution.mockReset() 36 + mockFetchPackageDownloadEvolution.mockResolvedValue([]) 37 + 38 + const component = await mountSuspended(PackageWeeklyDownloadStats, { 39 + props: baseProps, 40 + }) 41 + 42 + expect(component.text()).toContain('Weekly Downloads') 43 + expect(component.text()).toContain('No download data available') 44 + }) 45 + 46 + it('shows the section when weekly downloads exist', async () => { 47 + mockFetchPackageDownloadEvolution.mockReset() 48 + mockFetchPackageDownloadEvolution.mockResolvedValue([ 49 + { 50 + weekStart: '2026-01-01', 51 + weekEnd: '2026-01-07', 52 + timestampStart: 1767225600000, 53 + timestampEnd: 1767744000000, 54 + downloads: 42, 55 + }, 56 + ]) 57 + 58 + const component = await mountSuspended(PackageWeeklyDownloadStats, { 59 + props: baseProps, 60 + }) 61 + 62 + expect(component.text()).toContain('Weekly Downloads') 63 + expect(component.text()).not.toContain('No download data available') 64 + }) 65 + })