feat(02-02): tracer tab refreshes silently and every side effect is placed
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m32s
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m32s
Puts Marketplace.vue's data on the cache and adds the subtle background-
refresh indicator D-05 calls for:
- RefreshIndicator.vue: presentational, state-driven (ResourceLoadState).
Renders nothing for ready/idle/loading — a first load is the view's own
skeleton's job, not this component's — and a role="status"
aria-live="polite" spinner only while refreshing. A fixed-size outer slot
keeps its appearance/disappearance from ever shifting layout.
- Marketplace.vue: loadCommunityMarketplace()/loadBitcoinPruneStatus() move
onto useCachedResource behind shared keys app-catalog (300s TTL, persist —
near-static catalog, D-06 discretion) and bitcoin.prune-status (default
30s TTL, persist — both non-sensitive/small per T-02-01). app-catalog is a
shared key so Discover.vue's identical loader picks up the same entry
without its own conversion in 02-04. Error handling is keep-last-value
(D-07): a rejected refresh sets the existing communityError banner ref,
never a toast, and prior app cards stay on screen.
- Side-effect audit (the precedent 02-04 repeats across remaining tabs):
marketplaceAnimationDone is genuinely once-per-session intro state, stays
in onMounted; the two data loads needed no onMounted/onActivated hook of
their own at all — useCachedResource's internal onActivated (wired in
Task 1) already revalidates them on every kept-alive tab re-entry,
staleness-gated so a quick revisit issues no fetch. No interval,
subscription or window listener exists in this view, so no onDeactivated
teardown was needed either.
- Tests: RefreshIndicator's full render-nothing/render-something matrix
added to keepAliveTabs.test.ts (no router dependency, safe to colocate).
The D-07 rejected-refresh-keeps-content-and-no-toast test mounts
Marketplace.vue itself but lives in a new file,
views/__tests__/MarketplaceRefresh.test.ts, following the in-repo
CloudPeersRefresh.test.ts mount-the-view pattern — vi.mock('vue-router')
is hoisted file-wide, so colocating it in keepAliveTabs.test.ts would
clobber that file's real createRouter/createMemoryHistory used by the
DashboardRouterView tests (Rule 3 auto-fix; deviation from the plan's
literal single-test-file file list, documented here for the SUMMARY).
Marketplace.vue gains a defineExpose({ loadCommunityMarketplace,
loadBitcoinPruneStatus }) for tests, mirroring Cloud.vue's existing
defineExpose({ loadPeers }).
Full suite (87 files / 709 tests), type-check and build all green; the
built Marketplace chunk carries refresh-indicator.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
385c9d866e
commit
a9a20039eb
60
neode-ui/src/components/RefreshIndicator.vue
Normal file
60
neode-ui/src/components/RefreshIndicator.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="refresh-indicator-slot" aria-hidden="false">
|
||||
<div
|
||||
v-if="state === 'refreshing'"
|
||||
class="refresh-indicator"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="refresh-indicator-spinner" aria-hidden="true" />
|
||||
<span class="sr-only">{{ label ?? 'Refreshing…' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ResourceLoadState } from '@/stores/resources'
|
||||
|
||||
// Subtle background-refresh indicator (D-05): visible only while a cached
|
||||
// resource is revalidating behind already-rendered content. A first load
|
||||
// is the view's own skeleton's job, not this component's — 'loading'
|
||||
// intentionally renders nothing here, same as 'ready'/'idle'. No stale-age
|
||||
// text or "last updated" badge (D-05 rules those out).
|
||||
defineProps<{
|
||||
state: ResourceLoadState
|
||||
label?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Fixed-size outer slot so the indicator appearing/disappearing never
|
||||
shifts surrounding layout — callers can drop this inline without also
|
||||
reserving space themselves. */
|
||||
.refresh-indicator-slot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.refresh-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.refresh-indicator-spinner {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
border-top-color: rgba(255, 255, 255, 0.55);
|
||||
border-radius: 50%;
|
||||
animation: refresh-indicator-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes refresh-indicator-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@ -57,6 +57,7 @@
|
||||
:aria-label="t('marketplace.searchApps')"
|
||||
class="app-header-search px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile: categories + search (tabs handled by Dashboard.vue header) -->
|
||||
@ -69,6 +70,7 @@
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="discover-terminal-tag">discover</span>
|
||||
<h1 class="text-lg font-bold text-white">App Store</h1>
|
||||
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
|
||||
</div>
|
||||
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
|
||||
<button
|
||||
@ -171,6 +173,8 @@ import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
||||
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import RefreshIndicator from '@/components/RefreshIndicator.vue'
|
||||
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
|
||||
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
|
||||
import {
|
||||
@ -231,12 +235,40 @@ watch(() => route.query.category, (category) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Community marketplace state
|
||||
const loadingCommunity = ref(false)
|
||||
const communityError = ref('')
|
||||
const communityApps = ref<MarketplaceApp[]>([])
|
||||
// Community marketplace state — cached (D-09/D-06: near-static catalog, long
|
||||
// TTL) behind a shared key so Discover.vue's identical loader picks up the
|
||||
// same cache entry without its own conversion (plan 02-04). Non-sensitive
|
||||
// and small, so it persists across reloads.
|
||||
const catalogResource = useCachedResource<MarketplaceApp[]>({
|
||||
key: 'app-catalog',
|
||||
fetcher: async () => getCuratedAppList(),
|
||||
ttlMs: 300_000,
|
||||
persist: true,
|
||||
})
|
||||
const communityApps = computed(() => catalogResource.data.value ?? [])
|
||||
const loadingCommunity = computed(() => catalogResource.entry.loadState === 'loading')
|
||||
// Keep-last-value error banner (D-07): a failed background refresh never
|
||||
// raises a toast, it only surfaces here — content stays on screen either way.
|
||||
const communityError = computed(() => catalogResource.error.value ?? '')
|
||||
|
||||
interface BitcoinStatusResponse {
|
||||
blockchain_info?: { pruned?: boolean }
|
||||
}
|
||||
|
||||
// Prune status — the default TTL (30s) matches the rest of the app; also
|
||||
// non-sensitive and small, so it persists too.
|
||||
const pruneStatusResource = useCachedResource<BitcoinStatusResponse | null>({
|
||||
key: 'bitcoin.prune-status',
|
||||
fetcher: async (signal) => {
|
||||
const res = await fetch('/bitcoin-status', { credentials: 'include', signal })
|
||||
if (!res.ok) throw new Error(`bitcoin-status responded ${res.status}`)
|
||||
return res.json()
|
||||
},
|
||||
persist: true,
|
||||
})
|
||||
|
||||
const searchQuery = ref('')
|
||||
const bitcoinPruned = ref(false)
|
||||
const bitcoinPruned = computed(() => pruneStatusResource.data.value?.blockchain_info?.pruned === true)
|
||||
const marketplaceHeaderRef = ref<HTMLElement | null>(null)
|
||||
const marketplacePrimaryRef = ref<HTMLElement | null>(null)
|
||||
const marketplaceCategoryProbeRef = ref<HTMLElement | null>(null)
|
||||
@ -374,23 +406,21 @@ function launchInstalledApp(app: MarketplaceApp) {
|
||||
appLauncher.openSession(app.id)
|
||||
}
|
||||
|
||||
// onMounted fires exactly once for the lifetime of this kept-alive instance
|
||||
// (D-01/plan 02-01). marketplaceAnimationDone is genuinely once-per-session
|
||||
// intro state, so it stays here. The catalog and prune-status loads used to
|
||||
// live here too, fetching fresh on every remount; they're now cache-gated
|
||||
// useCachedResource entries whose own onActivated revalidates them on every
|
||||
// tab re-entry (staleness-checked, so a quick revisit issues no RPC) — they
|
||||
// need no per-view mount/activation hook of their own.
|
||||
onMounted(() => {
|
||||
marketplaceAnimationDone = true
|
||||
if (communityApps.value.length === 0 && !loadingCommunity.value) {
|
||||
loadCommunityMarketplace()
|
||||
}
|
||||
loadBitcoinPruneStatus()
|
||||
})
|
||||
|
||||
async function loadBitcoinPruneStatus() {
|
||||
try {
|
||||
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
|
||||
if (!res.ok) return
|
||||
const status = await res.json()
|
||||
bitcoinPruned.value = status?.blockchain_info?.pruned === true
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('[Marketplace] Bitcoin prune status unavailable:', e)
|
||||
}
|
||||
/** Force-refresh the Bitcoin prune-status cache entry (deduped with any
|
||||
* in-flight refresh, per useCachedResource). Exposed for tests. */
|
||||
function loadBitcoinPruneStatus() {
|
||||
return pruneStatusResource.refresh()
|
||||
}
|
||||
|
||||
function installBlockedReason(appId: string): string | undefined {
|
||||
@ -399,12 +429,10 @@ function installBlockedReason(appId: string): string | undefined {
|
||||
return electrumxArchiveWarning
|
||||
}
|
||||
|
||||
async function loadCommunityMarketplace() {
|
||||
loadingCommunity.value = true
|
||||
communityError.value = ''
|
||||
if (import.meta.env.DEV) console.log('Loading Docker-based app marketplace')
|
||||
communityApps.value = getCuratedAppList()
|
||||
loadingCommunity.value = false
|
||||
/** Force-refresh the app-catalog cache entry (used by the Retry button and
|
||||
* exposed for tests). */
|
||||
function loadCommunityMarketplace() {
|
||||
return catalogResource.refresh()
|
||||
}
|
||||
|
||||
function viewAppDetails(app: MarketplaceApp) {
|
||||
@ -516,6 +544,10 @@ async function installCommunityApp(app: MarketplaceApp) {
|
||||
}
|
||||
}
|
||||
|
||||
// Exposed for tests only (mirrors Cloud.vue's `defineExpose({ loadPeers })`):
|
||||
// lets a test trigger a background refresh directly without waiting on TTL
|
||||
// staleness or a real KeepAlive round-trip.
|
||||
defineExpose({ loadCommunityMarketplace, loadBitcoinPruneStatus })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
92
neode-ui/src/views/__tests__/MarketplaceRefresh.test.ts
Normal file
92
neode-ui/src/views/__tests__/MarketplaceRefresh.test.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Marketplace from '../Marketplace.vue'
|
||||
|
||||
// Mirrors the CloudPeersRefresh.test.ts pattern (in-repo convention for
|
||||
// mounting a view directly with its heavier deps mocked at the module
|
||||
// boundary) — kept in its own file rather than keepAliveTabs.test.ts because
|
||||
// vi.mock('vue-router', ...) is hoisted file-wide and would otherwise
|
||||
// clobber that file's real createRouter/createMemoryHistory imports used by
|
||||
// the DashboardRouterView tests (Rule 3 auto-fix).
|
||||
const routerPushMock = vi.fn()
|
||||
const toastErrorMock = vi.fn()
|
||||
const toastInfoMock = vi.fn()
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: routerPushMock, replace: vi.fn().mockResolvedValue(undefined) }),
|
||||
useRoute: () => ({ query: {} }),
|
||||
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ data: {}, hasLoadedInitialData: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/server', () => ({
|
||||
useServerStore: () => ({
|
||||
installingApps: new Map(),
|
||||
setInstallProgress: vi.fn(),
|
||||
clearInstallProgress: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appLauncher', () => ({
|
||||
useAppLauncherStore: () => ({ openSession: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useMarketplaceApp', () => ({
|
||||
useMarketplaceApp: () => ({ setCurrentApp: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({ success: vi.fn(), error: toastErrorMock, info: toastInfoMock }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
marketplaceDiscover: vi.fn().mockResolvedValue({ apps: [] }),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() })))
|
||||
routerPushMock.mockClear()
|
||||
toastErrorMock.mockClear()
|
||||
toastInfoMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('keeps the curated catalog on screen and raises no toast when the prune-status background refresh rejects', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ blockchain_info: { pruned: false } }),
|
||||
}))
|
||||
|
||||
const wrapper = mount(Marketplace, { global: { plugins: [createPinia()] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.glass-card').length).toBeGreaterThan(0)
|
||||
|
||||
vi.mocked(fetch).mockRejectedValueOnce(new Error('node unreachable'))
|
||||
await (wrapper.vm as unknown as { loadBitcoinPruneStatus: () => Promise<void> }).loadBitcoinPruneStatus()
|
||||
await flushPromises()
|
||||
|
||||
// Prior catalog content is still rendered — a background refresh failure
|
||||
// on an unrelated resource never wipes the view.
|
||||
expect(wrapper.findAll('.glass-card').length).toBeGreaterThan(0)
|
||||
expect(toastErrorMock).not.toHaveBeenCalled()
|
||||
expect(toastInfoMock).not.toHaveBeenCalled()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@ -4,6 +4,7 @@ import { defineComponent, h, onActivated, onMounted } from 'vue'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import DashboardRouterView from '../DashboardRouterView.vue'
|
||||
import { shouldKeepAlive } from '../keepAliveRoutes'
|
||||
import RefreshIndicator from '@/components/RefreshIndicator.vue'
|
||||
|
||||
let mountCount = 0
|
||||
let activateCount = 0
|
||||
@ -101,3 +102,27 @@ describe('DashboardRouterView keep-alive behavior', () => {
|
||||
expect(shouldKeepAlive({ path: '/dashboard/marketplace/abc' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RefreshIndicator', () => {
|
||||
it('renders nothing for ready, idle and loading, and a labeled status element for refreshing', () => {
|
||||
const states = ['ready', 'idle', 'loading'] as const
|
||||
for (const state of states) {
|
||||
const wrapper = mount(RefreshIndicator, { props: { state } })
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
}
|
||||
|
||||
const refreshing = mount(RefreshIndicator, { props: { state: 'refreshing', label: 'Refreshing things' } })
|
||||
const status = refreshing.find('[role="status"]')
|
||||
expect(status.exists()).toBe(true)
|
||||
expect(status.attributes('aria-live')).toBe('polite')
|
||||
expect(refreshing.text()).toContain('Refreshing things')
|
||||
refreshing.unmount()
|
||||
})
|
||||
|
||||
it('falls back to a non-empty default accessible label when none is given', () => {
|
||||
const wrapper = mount(RefreshIndicator, { props: { state: 'refreshing' } })
|
||||
expect(wrapper.find('[role="status"]').text().length).toBeGreaterThan(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user