Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/assets/js/docsSearchData.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions docs/assets/screenshots/fingerprints.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file modified docs/assets/screenshots/statoblast/pool-after-mint.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/screenshots/zoltar/discover-questions.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/tutorials/zoltar-first-question.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ <h2>Create it</h2>
<h2>Find it in the registry</h2>
<ol>
<li>Select <strong>Browse questions</strong>.</li>
<li>Select <strong>Discover questions</strong>. The browser scans the registry and lists your question under <strong>Downloaded</strong>.</li>
<li>Your question appears automatically under <strong>Downloaded (1)</strong>.</li>
</ol>
<figure class="screenshot" id="fig-zoltar-discover" aria-labelledby="fig-zoltar-discover-caption">
<a class="screenshot-link" href="../assets/screenshots/zoltar/discover-questions.png"><img width="800" height="434" src="../assets/screenshots/zoltar/discover-questions.png" alt="The Questions list with the Downloaded tab selected, showing Will it rain in Lisbon on 1 June 2027?, marked Binary, with its end time and a Use for fork button." loading="lazy" /></a>
Expand Down
4 changes: 2 additions & 2 deletions tooling/docs/ui-screenshot-specs/zoltar.mts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ export const ZOLTAR_SCREENSHOTS: readonly UiScreenshotSpec[] = [
id: 'discover-questions',
app: 'zoltar',
scenario: 'deployed',
steps: [...createQuestion, { click: 'Browse questions' }, { click: 'Discover questions' }, { waitForText: 'Will it rain in Lisbon on 1 June 2027?' }],
expectText: ['Questions', 'Downloaded', 'Will it rain in Lisbon on 1 June 2027?'],
steps: [...createQuestion, { click: 'Browse questions' }, { waitForText: 'Will it rain in Lisbon on 1 June 2027?' }],
expectText: ['Questions', 'Downloaded (1)', 'Will it rain in Lisbon on 1 June 2027?'],
viewport: pageViewport,
crop: { selector: '.section-block', containing: 'Will it rain in Lisbon on 1 June 2027?' },
usedBy: tutorial,
Expand Down
5 changes: 0 additions & 5 deletions ui/coreShared/css/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -930,11 +930,6 @@ button.quiet:disabled {
text-transform: inherit;
color: inherit;
white-space: inherit;
opacity: 0.72;
}

button .loading-value {
opacity: 1;
}

.currency-value-wrap {
Expand Down
29 changes: 11 additions & 18 deletions ui/coreShared/ts/components/LocalBrowseControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,17 @@ type DiscoveryState = {
totalCount: bigint | undefined
}

/** Scanning the chain is an explicit, one-page-at-a-time action so public RPC users only pay for what they ask for. */
function DiscoveryControl({ discovery, discoverLabel, disabled = false, emphasize = false, nounPlural }: { disabled?: boolean; discoverLabel: string; discovery: DiscoveryState; emphasize?: boolean; nounPlural: string }) {
const label = (() => {
if (!discovery.hasScanned) return discoverLabel
return discovery.hasMore ? favoritesCopy.discoverMore : favoritesCopy.rescan
})()
/** The first page loads automatically; users request only additional bounded pages. */
function DiscoveryControl({ discovery, disabled = false, emphasize = false, nounPlural }: { disabled?: boolean; discovery: DiscoveryState; emphasize?: boolean; nounPlural: string }) {
if (!discovery.hasScanned) return discovery.loading ? <LoadingText>{favoritesCopy.discovering}</LoadingText> : undefined
if (!discovery.hasMore) return undefined
return (
<div className='discovery-control'>
{/* Progress only matters while pages remain; a finished scan is already told by the Scan again label. */}
{discovery.totalCount === undefined || !discovery.hasMore ? undefined : (
<p className='detail' role='status'>
{favoritesCopy.formatDiscoveredProgress(discovery.scannedItemCount.toString(), discovery.totalCount.toString(), nounPlural)}
</p>
)}
<p className='detail' role='status'>
{favoritesCopy.formatDiscoveredProgress(discovery.scannedItemCount.toString(), discovery.totalCount?.toString() ?? '', nounPlural)}
</p>
<button className={emphasize ? 'primary' : 'secondary'} type='button' disabled={disabled || discovery.loading} onClick={discovery.discoverNext}>
{discovery.loading ? <LoadingText>{favoritesCopy.discovering}</LoadingText> : label}
{discovery.loading ? <LoadingText>{favoritesCopy.discovering}</LoadingText> : favoritesCopy.discoverMore}
</button>
</div>
)
Expand All @@ -66,19 +61,18 @@ type LocalBrowseDirectoryState = {

type LocalBrowseBarProps = {
directory: LocalBrowseDirectoryState
discoverLabel: string
disabled?: boolean
/** Shown once a scan has run. */
freshness?: DataFreshness | undefined
nounPlural: string
}

export function LocalBrowseBar({ directory, discoverLabel, disabled = false, freshness, nounPlural }: LocalBrowseBarProps) {
export function LocalBrowseBar({ directory, disabled = false, freshness, nounPlural }: LocalBrowseBarProps) {
return (
<div className='local-browse-bar'>
<LocalCollectionSwitcher collection={directory.collection} downloadedCount={directory.downloaded.entries.length} favoritesCount={directory.favoriteEntries.length} onChange={directory.setCollection} />
{directory.discovery.hasScanned && freshness !== undefined ? <UpdatedAgo {...freshness} /> : undefined}
<DiscoveryControl discovery={directory.discovery} discoverLabel={discoverLabel} disabled={disabled} emphasize={directory.downloaded.entries.length === 0} nounPlural={nounPlural} />
<DiscoveryControl discovery={directory.discovery} disabled={disabled} emphasize={directory.downloaded.entries.length === 0} nounPlural={nounPlural} />
</div>
)
}
Expand All @@ -98,7 +92,6 @@ type LocalCollectionEmptyStateProps = {
action?: ComponentChildren
copy: {
downloadedEmpty: string
downloadedEmptyDetail: string
favoritesEmpty: string
favoritesEmptyDetail: string
favoritesEmptyWithDownloadsDetail: string
Expand Down Expand Up @@ -127,5 +120,5 @@ export function LocalCollectionEmptyState({ action, copy, directory, registryEmp
)
if (directory.discovery.hasScanned && directory.discovery.totalCount === 0n) return <>{registryEmpty}</>
if (directory.collection === 'favorites') return <EmptyState title={copy.favoritesEmpty} detail={copy.favoritesEmptyDetail} actions={action} />
return <EmptyState title={copy.downloadedEmpty} detail={copy.downloadedEmptyDetail} actions={action} />
return <EmptyState title={copy.downloadedEmpty} actions={action} />
}
5 changes: 2 additions & 3 deletions ui/coreShared/ts/copy/favorites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ export const downloaded = 'Downloaded'
export const formatCollectionTab = (label: CopyTemplateValue, count: CopyTemplateValue) => `${label} (${count})`
export const collectionAriaLabel = 'Favorites collection'
export const recentlySaved = 'Recently added'
export const discoverMore = 'Discover more'
export const discovering = 'Discovering…'
export const rescan = 'Discover again'
export const discoverMore = 'Show more'
export const discovering = 'Loading…'
export const formatDiscoveredProgress = (discovered: CopyTemplateValue, total: CopyTemplateValue, noun: CopyTemplateValue) => `${discovered} of ${total} ${noun} checked`
5 changes: 2 additions & 3 deletions ui/coreShared/ts/hooks/useLocalBrowseDirectory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,13 @@ type LocalBrowseDirectoryOptions<TItem, TData> = {
}

/**
* Browsing runs over entities already downloaded to this browser: favorites by default, every downloaded summary on
* request. Scanning the chain is an explicit, paged action whose results join the downloaded cache.
* Browsing runs over entities already downloaded to this browser: favorites when available, or downloaded summaries otherwise. The first registry page loads automatically; additional pages join the downloaded cache on request.
*/
export function useLocalBrowseDirectory<TItem, TData>({ app, contextKey, externalLoading = false, kind, loadPage, pageSize, receivedPage, refreshedItems, store, toDownloadedItem }: LocalBrowseDirectoryOptions<TItem, TData>) {
const [collection, setCollection] = useState<LocalBrowseCollection>('favorites')
const [searchText, setSearchText] = useState('')
const favorites = useFavorites(app, kind)
const downloaded = useDownloadedEntities(app, kind, store)
const [collection, setCollection] = useState<LocalBrowseCollection>(() => (favorites.entries.length > 0 ? 'favorites' : 'downloaded'))
const discovery = usePagedDiscovery({
contextKey,
loadPage,
Expand Down
63 changes: 37 additions & 26 deletions ui/coreShared/ts/hooks/usePagedDiscovery.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'preact/hooks'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'

export type DiscoveredPage<TItem> = Readonly<{
items: readonly TItem[]
Expand Down Expand Up @@ -31,8 +31,8 @@ function matchesDiscoveryRequest<TItem>(page: DiscoveredPage<TItem>, request: Di
}

/**
* Scans a paged on-chain registry one page per user request. Received items are handed to `onItems` (normally the
* downloaded cache) exactly once per request, so browsing and search run over the local cache instead of the page.
* Loads the first registry page automatically, then one additional page per user request.
* Each result joins the downloaded cache once, so search covers every downloaded page.
*/
export function usePagedDiscovery<TItem>({ contextKey, loadPage, onItems, pageSize, receivedPage }: PagedDiscoveryOptions<TItem>) {
const [scan, setScan] = useState<{ contextKey: string; scannedPageCount: number; totalCount: bigint | undefined }>({ contextKey, scannedPageCount: 0, totalCount: undefined })
Expand All @@ -41,40 +41,51 @@ export function usePagedDiscovery<TItem>({ contextKey, loadPage, onItems, pageSi
const lastRequestRef = useRef<DiscoveryRequest | undefined>(undefined)
const requestNonceRef = useRef(0)
const onItemsRef = useRef(onItems)
const loadPageRef = useRef(loadPage)
const receivedPageRef = useRef(receivedPage)
const pageBeforeRequestRef = useRef<DiscoveredPage<TItem> | undefined>(undefined)
onItemsRef.current = onItems
loadPageRef.current = loadPage
receivedPageRef.current = receivedPage
const currentScan = scan.contextKey === contextKey ? scan : { contextKey, scannedPageCount: 0, totalCount: undefined }

useEffect(() => {
if (scan.contextKey === contextKey) return
lastRequestRef.current = undefined
setPendingRequestKey(undefined)
setLoadFailed(false)
setScan({ contextKey, scannedPageCount: 0, totalCount: undefined })
}, [contextKey, scan.contextKey])

const loading = pendingRequestKey !== undefined
useEffect(() => {
const request = lastRequestRef.current
if (receivedPage === undefined || request?.contextKey !== contextKey || !matchesDiscoveryRequest(receivedPage, request, pageSize, loading)) return
if (receivedPage === undefined || receivedPage === pageBeforeRequestRef.current || request?.contextKey !== contextKey || !matchesDiscoveryRequest(receivedPage, request, pageSize, loading)) return
lastRequestRef.current = undefined
onItemsRef.current(receivedPage.items)
setScan({ contextKey, scannedPageCount: receivedPage.pageIndex + 1, totalCount: receivedPage.totalCount })
}, [contextKey, loading, pageSize, receivedPage])

const requestPage = (pageIndex: number) => {
requestNonceRef.current += 1
const requestKey = getDiscoveryRequestKey(contextKey, pageIndex, pageSize, requestNonceRef.current)
lastRequestRef.current = { contextKey, key: requestKey, pageIndex }
setLoadFailed(false)
setPendingRequestKey(requestKey)
void Promise.resolve(loadPage(pageIndex, requestKey))
.catch(() => {
if (lastRequestRef.current?.key === requestKey) setLoadFailed(true)
})
.finally(() => {
setPendingRequestKey(current => (current === requestKey ? undefined : current))
})
}
const requestPage = useCallback(
(pageIndex: number) => {
requestNonceRef.current += 1
const requestKey = getDiscoveryRequestKey(contextKey, pageIndex, pageSize, requestNonceRef.current)
lastRequestRef.current = { contextKey, key: requestKey, pageIndex }
pageBeforeRequestRef.current = receivedPageRef.current
setLoadFailed(false)
setPendingRequestKey(requestKey)
const load = loadPageRef.current
void Promise.resolve()
.then(() => load(pageIndex, requestKey))
.catch(() => {
if (lastRequestRef.current?.key === requestKey) setLoadFailed(true)
})
.finally(() => {
setPendingRequestKey(current => (current === requestKey ? undefined : current))
})
},
[contextKey, pageSize],
)

useEffect(() => {
setScan({ contextKey, scannedPageCount: 0, totalCount: undefined })
requestPage(0)
return () => {
lastRequestRef.current = undefined
}
}, [contextKey, requestPage])

const scannedItemCount = (() => {
const scanned = BigInt(currentScan.scannedPageCount * pageSize)
Expand Down
66 changes: 54 additions & 12 deletions ui/zoltar/ts/tests/features/questions/questionsView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { readFavoriteEntries, resetLocalEntityStoreForTesting, setEntityFavorite
import { installDomTestLifecycle } from '@zoltar/ui-core-shared/tests/testUtils/domTestLifecycle.js'
import { fireEvent, within } from '@zoltar/ui-core-shared/tests/testUtils/queries.js'
import { renderIntoDocument } from '@zoltar/ui-core-shared/tests/testUtils/renderIntoDocument.js'
import { createDeferred } from '@zoltar/ui-core-shared/tests/testUtils/deferred.js'
import type { MarketDetails, MarketDetailsPage } from '@zoltar/ui-core-shared/types/contracts.js'
import { QuestionsView } from '@zoltar/ui-zoltar-shared/features/zoltarSurface/components/QuestionsView.js'
import { questionDownloadStore } from '@zoltar/ui-zoltar-shared/lib/questionBrowse.js'
Expand Down Expand Up @@ -87,7 +88,54 @@ describe('QuestionsView', () => {
},
})

test('renders favorite questions from the local cache without cross-application actions or a chain scan', async () => {
test('loads the first registry page on entry without a discovery click', async () => {
const loadPage = mock(async () => undefined)
const renderedComponent = await renderIntoDocument(view({ loadPage }))
cleanupRenderedComponent = renderedComponent.cleanup

expect(loadPage).toHaveBeenCalledTimes(1)
expect(loadPage).toHaveBeenCalledWith(0, 10)
await act(async () => {
render(view({ loadPage, zoltarQuestionPage: { pageIndex: 0, pageSize: 10, questionCount: 1n, questions: [question] } }), renderedComponent.container)
await Promise.resolve()
await Promise.resolve()
})
expect(getRenderedQuestionTitles()).toEqual([question.title])
expect(loadPage).toHaveBeenCalledTimes(1)
})

test('keeps cached questions visible while the initial page loads', async () => {
seedQuestions([question])
const request = createDeferred<void>()
const loadPage = mock(() => request.promise)
const renderedComponent = await renderIntoDocument(view({ loadPage }))
cleanupRenderedComponent = renderedComponent.cleanup

expect(getRenderedQuestionTitles()).toEqual([question.title])
expect(within(document.body).getByRole('status').textContent).toContain('Loading')
expect(loadPage).toHaveBeenCalledTimes(1)
await act(async () => request.resolve(undefined))
})

test('ignores an old request failure after switching contexts', async () => {
const oldRequest = createDeferred<void>()
const oldLoadPage = mock(() => oldRequest.promise)
const renderedComponent = await renderIntoDocument(view({ loadPage: oldLoadPage }))
cleanupRenderedComponent = renderedComponent.cleanup
const newLoadPage = mock(async () => undefined)
await act(async () => {
render(view({ loadPage: newLoadPage, requestContextKey: 1 }), renderedComponent.container)
await Promise.resolve()
})
expect(newLoadPage).toHaveBeenCalledTimes(1)
await act(async () => {
oldRequest.reject(new Error('Old network failed'))
await Promise.resolve()
})
expect(document.body.textContent).not.toContain('Questions could not be loaded.')
})

test('renders favorite questions from the local cache while refreshing without cross-application actions', async () => {
seedQuestions([question])
const loadPage = mock(async () => undefined)
const activeViews: string[] = []
Expand All @@ -113,18 +161,17 @@ describe('QuestionsView', () => {
})
expect(selectedQuestionIds).toEqual([question.questionId])
expect(activeViews).toEqual(['fork'])
expect(loadPage).not.toHaveBeenCalled()
expect(loadPage).toHaveBeenCalledTimes(1)
})

test('scans the registry on request and favorites a question when its details are opened', async () => {
test('loads the registry automatically and favorites a question when its details are opened', async () => {
const loadPage = mock(async () => undefined)
const renderedComponent = await renderIntoDocument(view({ loadPage }))
cleanupRenderedComponent = renderedComponent.cleanup
const documentQueries = within(document.body)
expect(documentQueries.getByText('No favorite questions yet')).not.toBeNull()
expect(loadPage).toHaveBeenCalledWith(0, 10)

await act(async () => {
fireEvent.click(documentQueries.getByRole('button', { name: 'Discover questions' }))
await Promise.resolve()
})
expect(loadPage).toHaveBeenCalledWith(0, 10)
Expand All @@ -146,7 +193,7 @@ describe('QuestionsView', () => {
expect(documentQueries.getByRole('button', { name: 'Favorites (1)' })).not.toBeNull()
})

test('records refreshed question pages and shows their age only after a scan', async () => {
test('records refreshed question pages and shows their age after its automatic initial read', async () => {
const loadPage = mock(async () => undefined)
const freshness = { refreshing: false, updatedAt: Date.now() - 15_000 }
const page = (title: string): MarketDetailsPage => ({ pageIndex: 0, pageSize: 10, questionCount: 1n, questions: [{ ...createQuestion(1), title }] })
Expand All @@ -158,7 +205,6 @@ describe('QuestionsView', () => {
expect(documentQueries.getByRole('button', { name: 'Downloaded (0)' })).not.toBeNull()

await act(async () => {
fireEvent.click(documentQueries.getByRole('button', { name: 'Discover questions' }))
await Promise.resolve()
})
await act(async () => {
Expand Down Expand Up @@ -211,7 +257,6 @@ describe('QuestionsView', () => {
cleanupRenderedComponent = renderedComponent.cleanup
const documentQueries = within(document.body)
await act(async () => {
fireEvent.click(documentQueries.getByRole('button', { name: 'Discover questions' }))
await Promise.resolve()
await Promise.resolve()
})
Expand All @@ -231,7 +276,6 @@ describe('QuestionsView', () => {
cleanupRenderedComponent = renderedComponent.cleanup
const documentQueries = within(document.body)
await act(async () => {
fireEvent.click(documentQueries.getByRole('button', { name: 'Discover questions' }))
await Promise.resolve()
})
await act(async () => {
Expand Down Expand Up @@ -275,15 +319,14 @@ describe('QuestionsView', () => {
cleanupRenderedComponent = renderedComponent.cleanup
const documentQueries = within(document.body)
await act(async () => {
fireEvent.click(documentQueries.getByRole('button', { name: 'Discover questions' }))
await Promise.resolve()
})
await act(async () => {
render(view({ loadPage, zoltarQuestionPage: { pageIndex: 0, pageSize: 10, questionCount: 41n, questions: [question] } }), renderedComponent.container)
await Promise.resolve()
})
await act(async () => {
fireEvent.click(documentQueries.getByRole('button', { name: 'Discover more' }))
fireEvent.click(documentQueries.getByRole('button', { name: 'Show more' }))
await Promise.resolve()
})
expect(loadPage).toHaveBeenLastCalledWith(1, 10)
Expand All @@ -293,7 +336,6 @@ describe('QuestionsView', () => {
await Promise.resolve()
})
await act(async () => {
fireEvent.click(documentQueries.getByRole('button', { name: 'Discover questions' }))
await Promise.resolve()
})
expect(loadPage).toHaveBeenLastCalledWith(0, 10)
Expand Down
Loading
Loading