Implement Search feature (P6.4)

- Add psf-memo-db /search endpoint with SearchQuery adapter, SearchAll use case,
  and REST controller/router.
- Add psf-memo-client SearchPage service, Search component, route, and nav link.
- Wire search support into the acceptance handler fake MemoDb.
- Add unit tests for the new adapter, use case, controller, and page service.

By coder.
This commit is contained in:
Chris Troutner
2026-08-29 07:18:39 -07:00
parent d11365c057
commit 8508fdaff0
17 changed files with 1053 additions and 1 deletions
+117 -1
View File
@@ -39,6 +39,7 @@ const ProfilePage = require('../../src/services/profile-page')
const ThreadPage = require('../../src/services/thread-page')
const TopicDiscoveryPage = require('../../src/services/topic-discovery-page')
const TopicFeedPage = require('../../src/services/topic-feed-page')
const SearchPage = require('../../src/services/search-page')
const MemoTopicFollow = require('../../src/services/memo-topic-follow')
const MemoTopicPost = require('../../src/services/memo-topic-post')
const TopicPostPage = require('../../src/services/topic-post-page')
@@ -178,9 +179,12 @@ function makeThread () {
}
// A fake psf-memo-db API backing the read-only feed, profile, thread,
// and topic pages used to verify read-side behavior.
// topic, and search pages used to verify read-side behavior.
function makeMemoDb () {
const posts = []
const profiles = []
const searchPosts = []
const searchProfiles = []
const threads = {}
const followState = {}
const muteState = {}
@@ -191,6 +195,9 @@ function makeMemoDb () {
return {
posts,
profiles,
searchPosts,
searchProfiles,
threads,
followState,
topics,
@@ -199,6 +206,12 @@ function makeMemoDb () {
addPost (post) {
posts.push(post)
},
addSearchPost (post) {
searchPosts.push(post)
},
addSearchProfile (profile) {
searchProfiles.push(profile)
},
addTopic (room, postCount) {
topicCounts.set(room, postCount)
topicPosts[room] = []
@@ -240,6 +253,25 @@ function makeMemoDb () {
}
return addrs
},
async search (q) {
const normalized = String(q).trim().toLowerCase()
if (normalized.length === 0) {
return { posts: [], profiles: [], pagination: { total: 0, hasMore: false } }
}
const matchedPosts = searchPosts.filter((p) =>
typeof p.text === 'string' && p.text.toLowerCase().includes(normalized)
)
const matchedProfiles = searchProfiles.filter((p) =>
(typeof p.name === 'string' && p.name.toLowerCase().includes(normalized)) ||
(typeof p.text === 'string' && p.text.toLowerCase().includes(normalized))
)
const total = matchedPosts.length + matchedProfiles.length
return {
posts: matchedPosts,
profiles: matchedProfiles,
pagination: { total, hasMore: false }
}
},
async getRecentPosts ({ limit = 100, offset = 0 } = {}) {
const page = posts.slice(offset, offset + limit)
return { posts: page, pagination: { total: posts.length, limit, offset, hasMore: offset + page.length < posts.length } }
@@ -316,6 +348,10 @@ function createWorld () {
memoDb,
navigate: (path) => { world.currentPath = path }
})
world.searchPage = new SearchPage({
memoDb,
navigate: (path) => { world.currentPath = path }
})
// The New Post Page controller wraps the memo post behavior. Its navigate
// adapter updates the world's current path so navigation can be asserted.
@@ -2088,6 +2124,86 @@ const handlers = [
throw new Error(`Expected ${expectedCode}, got ${world.pollVotePage.submitError}.`)
}
}
},
{
name: 'API has search post',
pattern: /^the psf-memo-db API has a post with the text "(.+)"$/,
run (m, example, world) {
const text = resolveParam(m[1], example)
const txid = require('crypto').createHash('sha256').update(text).digest('hex')
world.memoDb.addSearchPost({
txid,
addr: `addr-${txid.slice(0, 8)}`,
text,
blockHeight: 100
})
}
},
{
name: 'API has search profile',
pattern: /^the psf-memo-db API has a profile named "(.+)" with the bio "(.+)"$/,
run (m, example, world) {
const name = resolveParam(m[1], example)
const text = resolveParam(m[2], example)
const addr = `addr-${require('crypto').createHash('sha256').update(name).digest('hex').slice(0, 8)}`
world.memoDb.addSearchProfile({ addr, name, text, blockHeight: 100 })
}
},
{
name: 'open search page',
pattern: /^I open the Search page$/,
async run (m, example, world) {
world.currentPath = SearchPage.SEARCH_PATH
}
},
{
name: 'submit search',
pattern: /^I submit a search for (.+)$/,
async run (m, example, world) {
const query = resolveParam(m[1], example)
world.searchPage.setQuery(query)
await world.searchPage.submit()
}
},
{
name: 'search results include post text',
pattern: /^the search results include a post with the text (.+)$/,
run (m, example, world) {
const expected = resolveParam(m[1], example)
const found = world.searchPage.posts.find((p) => p.text === expected)
if (!found) {
throw new Error(`Search results do not include a post with text "${expected}".`)
}
}
},
{
name: 'search results include profile name',
pattern: /^the search results include a profile named (.+)$/,
run (m, example, world) {
const expected = resolveParam(m[1], example)
const found = world.searchPage.profiles.find((p) => p.name === expected)
if (!found) {
throw new Error(`Search results do not include a profile named "${expected}".`)
}
}
},
{
name: 'search results include no posts',
pattern: /^the search results include no posts$/,
run (m, example, world) {
if (world.searchPage.posts.length !== 0) {
throw new Error(`Expected no posts in search results, got ${world.searchPage.posts.length}.`)
}
}
},
{
name: 'search results include no profiles',
pattern: /^the search results include no profiles$/,
run (m, example, world) {
if (world.searchPage.profiles.length !== 0) {
throw new Error(`Expected no profiles in search results, got ${world.searchPage.profiles.length}.`)
}
}
}
]
@@ -33,6 +33,7 @@ import SetAvatarUrl from './set-avatar-url'
import Account from './account'
import Topics from './topics'
import TopicFeed from './topic-feed'
import Search from './search'
function AppBody (props) {
// Dependency injection through props
@@ -52,6 +53,7 @@ function AppBody (props) {
<Route path='/posts/new' element={<NewPost appData={appData} />} />
<Route path='/topics' element={<Topics />} />
<Route path='/topics/:room' element={<TopicFeed appData={appData} />} />
<Route path='/search' element={<Search />} />
<Route path='/memo/set-name' element={<SetName appData={appData} />} />
<Route path='/memo/set-bio' element={<SetBio appData={appData} />} />
<Route path='/memo/set-avatar-url' element={<SetAvatarUrl appData={appData} />} />
@@ -0,0 +1,132 @@
/*
Search page: submit a query and display matching posts and profiles.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Container, Row, Col, Form, Button, Spinner, ListGroup } from 'react-bootstrap'
import { Link } from 'react-router-dom'
// Local libraries
import MemoDb from '../../../services/memo-db'
import SearchPage from '../../../services/search-page'
import '../../../App.css'
function SearchResults (props) {
const { posts, profiles, searched } = props
if (!searched) return null
if (posts.length === 0 && profiles.length === 0) {
return <p className='search-empty mt-4'>No results found.</p>
}
return (
<>
{posts.length > 0 && (
<>
<h2 className='mt-4'>Posts</h2>
<ListGroup>
{posts.map((post) => (
<ListGroup.Item key={post.txid}>
<p>{post.text}</p>
<p className='text-muted' style={{ fontFamily: 'monospace' }}>
{post.addr}
</p>
</ListGroup.Item>
))}
</ListGroup>
</>
)}
{profiles.length > 0 && (
<>
<h2 className='mt-4'>Profiles</h2>
<ListGroup>
{profiles.map((profile) => (
<ListGroup.Item key={profile.addr}>
<Link to={`/profile/${encodeURIComponent(profile.addr)}`}>
{profile.name || profile.addr}
</Link>
{profile.text && <p className='text-muted'>{profile.text}</p>}
</ListGroup.Item>
))}
</ListGroup>
</>
)}
</>
)
}
function Search (props) {
const [query, setQuery] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [posts, setPosts] = useState([])
const [profiles, setProfiles] = useState([])
const [searched, setSearched] = useState(false)
const handleSubmit = async (event) => {
event.preventDefault()
setLoading(true)
setError(null)
setSearched(true)
try {
const memoDb = new MemoDb()
const page = new SearchPage({ memoDb })
page.setQuery(query)
const result = await page.submit()
setPosts(result.posts || [])
setProfiles(result.profiles || [])
} catch (err) {
setError(err.message || 'Search failed')
setPosts([])
setProfiles([])
}
setLoading(false)
}
return (
<Container className='search-page'>
<Row className='justify-content-center'>
<Col lg={8} md={10} xs={12}>
<header className='search-heading'>
<h1>Search</h1>
<p>Find posts and profiles on Memo.</p>
</header>
<Form onSubmit={handleSubmit}>
<Form.Group controlId='searchQuery' className='mb-3'>
<Form.Control
type='text'
placeholder='Search posts and profiles...'
value={query}
onChange={(e) => setQuery(e.target.value)}
disabled={loading}
/>
</Form.Group>
<Button type='submit' variant='primary' disabled={loading}>
Search
</Button>
</Form>
{error && <p className='text-danger mt-3'>{error}</p>}
{loading && (
<div className='text-center my-5'>
<Spinner animation='border' role='status'>
<span className='visually-hidden'>Loading...</span>
</Spinner>
</div>
)}
{!loading && <SearchResults posts={posts} profiles={profiles} searched={searched} />}
</Col>
</Row>
</Container>
)
}
export default Search
@@ -78,6 +78,14 @@ function NavMenu (props) {
Topics
</NavLink>
<NavLink
className={currentPath === '/search' ? 'nav-link-active' : 'nav-link-inactive'}
to='/search'
onClick={handleClickEvent}
>
Search
</NavLink>
<NavLink
className={currentPath === '/posts/new' ? 'nav-link-active' : 'nav-link-inactive'}
to='/posts/new'
+13
View File
@@ -58,6 +58,19 @@ class MemoDb {
return this._getList(`/topics/${encodeURIComponent(room)}/followers`, 'getTopicFollowers', 'followers')
}
async search (q, { limit = 100, offset = 0 } = {}) {
try {
const result = await this.axios.get(`${config.backend}/search`, {
params: { q, limit, offset }
})
return result.data
} catch (err) {
console.error('Error in search()')
throw err
}
}
// GET a boolean state endpoint and coerce the named field to a boolean.
async _getState (path, name, params, field) {
try {
@@ -0,0 +1,54 @@
/*
Search Page behavior: capture a query, submit it, and display results.
This is the testable controller behind the React Search page. It wraps the
MemoDb client and exposes the returned posts and profiles so the view can
render them. Search is read-only and does not require a wallet.
*/
const SEARCH_PATH = '/search'
class SearchPage {
constructor (deps = {}) {
this.memoDb = deps.memoDb || null
this.navigate = deps.navigate || (() => {})
this.query = ''
this.posts = []
this.profiles = []
this.pagination = null
}
setQuery (q) {
this.query = typeof q === 'string' ? q.trim() : ''
return this
}
async submit ({ limit = 100, offset = 0 } = {}) {
if (!this.memoDb) {
throw new Error('Search page requires a memo db client.')
}
const data = await this.memoDb.search(this.query, { limit, offset })
this.posts = data.posts || []
this.profiles = data.profiles || []
this.pagination = data.pagination || null
return {
posts: this.posts,
profiles: this.profiles,
pagination: this.pagination
}
}
getPost (txid) {
return this.posts.find((post) => post.txid === txid) || null
}
getProfile (addr) {
return this.profiles.find((profile) => profile.addr === addr) || null
}
}
SearchPage.SEARCH_PATH = SEARCH_PATH
module.exports = SearchPage
@@ -0,0 +1,83 @@
/*
Unit tests for the Search page controller.
The search page is a thin, testable wrapper around the MemoDb client. It
captures a query, submits it to the search endpoint, and exposes the returned
posts and profiles so the view can render them.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const SearchPage = require('../../src/services/search-page')
function makeMemoDb (posts, profiles, pagination) {
return {
async search (q, { limit, offset }) {
return { posts, profiles, pagination }
}
}
}
test('load returns posts and profiles', async () => {
const posts = [{ txid: 'a'.repeat(64), text: 'hello world' }]
const profiles = [{ addr: 'addr1', name: 'Alice Trout' }]
const page = new SearchPage({ memoDb: makeMemoDb(posts, profiles, { total: 2 }) })
page.setQuery('hello')
const result = await page.submit()
assert.deepEqual(result.posts, posts)
assert.deepEqual(result.profiles, profiles)
assert.equal(result.pagination.total, 2)
})
test('submit forwards query, limit and offset to the memo db client', async () => {
const calls = []
const memoDb = {
async search (q, params) {
calls.push({ q, params })
return { posts: [], profiles: [], pagination: {} }
}
}
const page = new SearchPage({ memoDb })
page.setQuery('alice')
await page.submit({ limit: 10, offset: 20 })
assert.deepEqual(calls, [{ q: 'alice', params: { limit: 10, offset: 20 } }])
})
test('submit defaults limit to 100 and offset to 0', async () => {
const calls = []
const memoDb = {
async search (q, params) {
calls.push({ q, params })
return { posts: [], profiles: [], pagination: {} }
}
}
const page = new SearchPage({ memoDb })
page.setQuery('memo')
await page.submit()
assert.deepEqual(calls, [{ q: 'memo', params: { limit: 100, offset: 0 } }])
})
test('submit throws when no memo db client is provided', async () => {
const page = new SearchPage({})
await assert.rejects(
() => page.submit(),
/requires a memo db client/
)
})
test('setQuery stores the trimmed query', () => {
const page = new SearchPage({ memoDb: { async search () { return {} } } })
page.setQuery(' hello ')
assert.equal(page.query, 'hello')
})