mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
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:
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user