mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Render topics page as a table
Replace the topics page flex list with a react-bootstrap Table driven by a testable topics-table view model: a labeled header row, aligned cells for name, relative time, post count, and follower count, feed links on each row, and a horizontal-scroll wrapper for narrow screens. By coder.
This commit is contained in:
@@ -39,6 +39,7 @@ const FollowingFeedPage = require('../../src/services/following-feed-page')
|
||||
const ProfilePage = require('../../src/services/profile-page')
|
||||
const ThreadPage = require('../../src/services/thread-page')
|
||||
const TopicDiscoveryPage = require('../../src/services/topic-discovery-page')
|
||||
const { buildTopicsTable } = require('../../src/services/topics-table')
|
||||
const TopicFeedPage = require('../../src/services/topic-feed-page')
|
||||
const SearchPage = require('../../src/services/search-page')
|
||||
const NotificationsPage = require('../../src/services/notifications-page')
|
||||
@@ -645,6 +646,17 @@ function resolveText (value, example) {
|
||||
return resolveParam(value, example)
|
||||
}
|
||||
|
||||
// Resolve a step value that may contain one or more <parameter> placeholders
|
||||
// embedded in literal text, e.g. "<displayPostCount> posts".
|
||||
function resolveTemplate (value, example) {
|
||||
return String(value).replace(/<([A-Za-z0-9_]+)>/g, (match, param) => {
|
||||
if (!(param in example)) {
|
||||
throw new Error(`Missing example value for "${param}"`)
|
||||
}
|
||||
return example[param]
|
||||
})
|
||||
}
|
||||
|
||||
// Look up a post that has been loaded onto one of the read-only pages.
|
||||
function findDisplayedPost (txid, world) {
|
||||
const fromThread = world.threadPage.getPost(txid)
|
||||
@@ -2281,6 +2293,59 @@ const handlers = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topics table has column headers',
|
||||
pattern: /^the topics table has the column headers "([^"]+)", "([^"]+)", "([^"]+)", "([^"]+)"$/,
|
||||
run (m, example, world) {
|
||||
const expected = [m[1], m[2], m[3], m[4]].map((value) => resolveTemplate(value, example))
|
||||
const table = buildTopicsTable(world.topicDiscoveryPage.topics, { now: world.currentTime ?? Date.now() })
|
||||
if (table.headers.join('|') !== expected.join('|')) {
|
||||
throw new Error(`Expected headers ${expected.join(', ')}, got ${table.headers.join(', ')}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topics table row has cells',
|
||||
pattern: /^the topics table row for "([^"]+)" has the cells "([^"]*)", "([^"]*)", "([^"]*)" and "([^"]*)"$/,
|
||||
run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const expected = [m[2], m[3], m[4], m[5]].map((value) => resolveTemplate(value, example))
|
||||
const table = buildTopicsTable(world.topicDiscoveryPage.topics, { now: world.currentTime ?? Date.now() })
|
||||
const row = table.rows.find((candidate) => candidate.room === room)
|
||||
if (!row) {
|
||||
throw new Error(`Topic ${room} is not shown in the topics table.`)
|
||||
}
|
||||
if (row.cells.join('|') !== expected.join('|')) {
|
||||
throw new Error(`Expected cells ${expected.join(', ')}, got ${row.cells.join(', ')}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topics table links topic',
|
||||
pattern: /^the topics table links the topic "([^"]+)" to "([^"]+)"$/,
|
||||
run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const expected = resolveTemplate(m[2], example)
|
||||
const table = buildTopicsTable(world.topicDiscoveryPage.topics, { now: world.currentTime ?? Date.now() })
|
||||
const row = table.rows.find((candidate) => candidate.room === room)
|
||||
if (!row) {
|
||||
throw new Error(`Topic ${room} is not shown in the topics table.`)
|
||||
}
|
||||
if (row.href !== expected) {
|
||||
throw new Error(`Expected ${room} link "${expected}", got "${row.href}".`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topics table scrolls horizontally',
|
||||
pattern: /^the topics table scrolls horizontally on narrow screens$/,
|
||||
run (m, example, world) {
|
||||
const table = buildTopicsTable(world.topicDiscoveryPage.topics, { now: world.currentTime ?? Date.now() })
|
||||
if (!String(table.wrapperClass).includes('table-responsive')) {
|
||||
throw new Error(`Expected a horizontal-scroll wrapper, got "${table.wrapperClass}".`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'click topic',
|
||||
pattern: /^I click the topic (<topic>)$/,
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Container, Row, Col, Spinner, ListGroup, Button } from 'react-bootstrap'
|
||||
import { Container, Row, Col, Spinner, Table, Button } from 'react-bootstrap'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
// Local libraries
|
||||
import MemoDb from '../../../services/memo-db'
|
||||
import TopicDiscoveryPage from '../../../services/topic-discovery-page'
|
||||
import { relativeTime } from '../../../services/relative-time'
|
||||
import { buildTopicsTable } from '../../../services/topics-table'
|
||||
import '../../../App.css'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
@@ -52,6 +52,7 @@ function Topics (props) {
|
||||
|
||||
const canGoBack = offset > 0
|
||||
const canGoNext = pagination?.hasMore ?? false
|
||||
const table = buildTopicsTable(topics)
|
||||
|
||||
const handlePrevious = () => {
|
||||
setOffset((prev) => Math.max(0, prev - PAGE_SIZE))
|
||||
@@ -96,21 +97,37 @@ function Topics (props) {
|
||||
)}
|
||||
|
||||
{!loading && !error && topics.length > 0 && (
|
||||
<ListGroup>
|
||||
{topics.map((topic) => (
|
||||
<ListGroup.Item
|
||||
key={topic.room}
|
||||
action
|
||||
onClick={() => handleClick(topic.room)}
|
||||
className='d-flex justify-content-between align-items-center'
|
||||
>
|
||||
<span className='topic-name'>#{topic.room}</span>
|
||||
<span className='topic-last-seen text-muted'>{relativeTime(topic.lastSeen, Date.now())}</span>
|
||||
<span className='topic-post-count text-muted'>{topic.postCount} posts</span>
|
||||
<span className='topic-follower-count text-muted'>{topic.followerCount} followers</span>
|
||||
</ListGroup.Item>
|
||||
<div className={table.wrapperClass}>
|
||||
<Table hover className='topics-table align-middle'>
|
||||
<thead>
|
||||
<tr>
|
||||
{table.headers.map((header) => (
|
||||
<th key={header} scope='col'>{header}</th>
|
||||
))}
|
||||
</ListGroup>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.rows.map((row) => (
|
||||
<tr key={row.room}>
|
||||
<td className='topic-name'>
|
||||
<a
|
||||
href={row.href}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
handleClick(row.room)
|
||||
}}
|
||||
>
|
||||
{row.cells[0]}
|
||||
</a>
|
||||
</td>
|
||||
<td className='topic-last-seen text-muted'>{row.cells[1]}</td>
|
||||
<td className='topic-post-count text-muted'>{row.cells[2]}</td>
|
||||
<td className='topic-follower-count text-muted'>{row.cells[3]}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (pagination || offset > 0) && (
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
Build the topics page table view model.
|
||||
|
||||
The React Topics page renders a react-bootstrap Table from this model so the
|
||||
column layout stays testable without a DOM. The header row labels the four
|
||||
columns in order, and every body row carries the topic name, relative-time
|
||||
label, post count, and follower count in the same order so a value never
|
||||
drifts into another column. Each row also carries the link to its topic feed.
|
||||
*/
|
||||
|
||||
const { relativeTime } = require('./relative-time')
|
||||
const TopicDiscoveryPage = require('./topic-discovery-page')
|
||||
|
||||
const TOPICS_TABLE_HEADERS = ['Topic', 'Most recent post', 'Posts', 'Followers']
|
||||
const TOPICS_TABLE_WRAPPER_CLASS = 'table-responsive'
|
||||
|
||||
function buildTopicsTable (topics = [], { now = Date.now() } = {}) {
|
||||
const rows = topics.map((topic) => ({
|
||||
room: topic.room,
|
||||
href: TopicDiscoveryPage.topicFeedPath(topic.room),
|
||||
cells: [
|
||||
`#${topic.room}`,
|
||||
relativeTime(topic.lastSeen, now),
|
||||
`${topic.postCount ?? 0} posts`,
|
||||
`${topic.followerCount ?? 0} followers`
|
||||
]
|
||||
}))
|
||||
|
||||
return {
|
||||
headers: [...TOPICS_TABLE_HEADERS],
|
||||
rows,
|
||||
wrapperClass: TOPICS_TABLE_WRAPPER_CLASS
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TOPICS_TABLE_HEADERS,
|
||||
TOPICS_TABLE_WRAPPER_CLASS,
|
||||
buildTopicsTable
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
Unit tests for the topics table view model.
|
||||
|
||||
The React Topics page renders a react-bootstrap Table from buildTopicsTable,
|
||||
so these tests pin down the header order, the per-row cell order, the row
|
||||
link, and the horizontal-scroll wrapper without a DOM.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const {
|
||||
buildTopicsTable,
|
||||
TOPICS_TABLE_HEADERS,
|
||||
TOPICS_TABLE_WRAPPER_CLASS
|
||||
} = require('../../src/services/topics-table')
|
||||
|
||||
const NOW = 1800000000000
|
||||
|
||||
test('lists the four column headers in order', () => {
|
||||
const table = buildTopicsTable([], { now: NOW })
|
||||
|
||||
assert.deepEqual(table.headers, ['Topic', 'Most recent post', 'Posts', 'Followers'])
|
||||
assert.deepEqual(table.headers, TOPICS_TABLE_HEADERS)
|
||||
})
|
||||
|
||||
test('builds a row with name, relative time, post count, and follower count', () => {
|
||||
const table = buildTopicsTable([
|
||||
{ room: 'bitcoin', postCount: 5, followerCount: 3, lastSeen: 1799996400000 }
|
||||
], { now: NOW })
|
||||
|
||||
assert.deepEqual(table.rows, [{
|
||||
room: 'bitcoin',
|
||||
href: '/topics/bitcoin',
|
||||
cells: ['#bitcoin', '1 hour ago', '5 posts', '3 followers']
|
||||
}])
|
||||
})
|
||||
|
||||
test('renders "No posts" for a topic with no last-seen time', () => {
|
||||
const table = buildTopicsTable([
|
||||
{ room: 'lone', postCount: 0, followerCount: 7, lastSeen: 0 }
|
||||
], { now: NOW })
|
||||
|
||||
assert.deepEqual(table.rows[0].cells, ['#lone', 'No posts', '0 posts', '7 followers'])
|
||||
})
|
||||
|
||||
test('percent-encodes the room name in the row link', () => {
|
||||
const table = buildTopicsTable([
|
||||
{ room: 'space room', postCount: 1, followerCount: 0, lastSeen: NOW }
|
||||
], { now: NOW })
|
||||
|
||||
assert.equal(table.rows[0].href, '/topics/space%20room')
|
||||
})
|
||||
|
||||
test('defaults missing counts and last-seen time to safe values', () => {
|
||||
const table = buildTopicsTable([{ room: 'legacy' }], { now: NOW })
|
||||
|
||||
assert.deepEqual(table.rows[0].cells, ['#legacy', 'No posts', '0 posts', '0 followers'])
|
||||
})
|
||||
|
||||
test('wraps the table so it scrolls horizontally on narrow screens', () => {
|
||||
const table = buildTopicsTable([], { now: NOW })
|
||||
|
||||
assert.equal(table.wrapperClass, TOPICS_TABLE_WRAPPER_CLASS)
|
||||
assert.equal(table.wrapperClass, 'table-responsive')
|
||||
})
|
||||
Reference in New Issue
Block a user