diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 8d391b0..8632260 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -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 placeholders +// embedded in literal text, e.g. " 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 ()$/, diff --git a/psf-memo-client/src/components/app-body/topics/index.js b/psf-memo-client/src/components/app-body/topics/index.js index c4d6c63..84e49a7 100644 --- a/psf-memo-client/src/components/app-body/topics/index.js +++ b/psf-memo-client/src/components/app-body/topics/index.js @@ -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 && ( - - {topics.map((topic) => ( - handleClick(topic.room)} - className='d-flex justify-content-between align-items-center' - > - #{topic.room} - {relativeTime(topic.lastSeen, Date.now())} - {topic.postCount} posts - {topic.followerCount} followers - - ))} - +
+ + + + {table.headers.map((header) => ( + + ))} + + + + {table.rows.map((row) => ( + + + + + + + ))} + +
{header}
+ { + event.preventDefault() + handleClick(row.room) + }} + > + {row.cells[0]} + + {row.cells[1]}{row.cells[2]}{row.cells[3]}
+
)} {!loading && !error && (pagination || offset > 0) && ( diff --git a/psf-memo-client/src/services/topics-table.js b/psf-memo-client/src/services/topics-table.js new file mode 100644 index 0000000..48ce4f7 --- /dev/null +++ b/psf-memo-client/src/services/topics-table.js @@ -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 +} diff --git a/psf-memo-client/test/unit/topics-table.test.js b/psf-memo-client/test/unit/topics-table.test.js new file mode 100644 index 0000000..9eeeb48 --- /dev/null +++ b/psf-memo-client/test/unit/topics-table.test.js @@ -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') +})