Dashboard: search and filter studies by tag
Build and Push Docker Image / build-and-push (push) Failing after 20s
Build and Push Docker Image / build-and-push (push) Failing after 20s
- The search box matches tags as well as title and passage; "#tag" searches tags only - Tag chips (with counts) replace the Tag dropdown; several can be on at once and a study must have all of them - Tags on each study card are clickable filters - "No studies match" message with a Clear filters link Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dc825f0112
commit
88bc59bac0
4 files changed
+138
-21
No files matched your search
+39
-21
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { filterStudies, isTagQuery, tagCounts } from '../utils/studyFilter.js';
|
||||||
import BookCover from '../components/BookCover.jsx';
|
import BookCover from '../components/BookCover.jsx';
|
||||||
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
|
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
|
||||||
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
|
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
|
||||||
@@ -50,7 +51,9 @@ export default function HomePage() {
|
|||||||
const [homeSort, setHomeSort] = useState(() => {
|
const [homeSort, setHomeSort] = useState(() => {
|
||||||
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
|
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
|
||||||
}); // 'recent' | 'title' | 'passage'
|
}); // 'recent' | 'title' | 'passage'
|
||||||
const [homeTagFilter, setHomeTagFilter] = useState('');
|
const [homeTags, setHomeTags] = useState([]); // tag chips switched on: a study must have all of them
|
||||||
|
const [showAllTags, setShowAllTags] = useState(false);
|
||||||
|
const toggleTag = (tag) => setHomeTags((cur) => (cur.includes(tag) ? cur.filter((t) => t !== tag) : [...cur, tag]));
|
||||||
const [homeView, setHomeView] = useState(() => {
|
const [homeView, setHomeView] = useState(() => {
|
||||||
try { return localStorage.getItem('homeView') === 'list' ? 'list' : 'grid'; } catch { return 'grid'; }
|
try { return localStorage.getItem('homeView') === 'list' ? 'list' : 'grid'; } catch { return 'grid'; }
|
||||||
}); // 'grid' | 'list'
|
}); // 'grid' | 'list'
|
||||||
@@ -70,7 +73,7 @@ export default function HomePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearTimeout(fullTextTimerRef.current);
|
clearTimeout(fullTextTimerRef.current);
|
||||||
const q = homeSearch.trim().toLowerCase();
|
const q = homeSearch.trim().toLowerCase();
|
||||||
if (q.length < 3) { setHomeFullTextResults(null); return; }
|
if (q.length < 3 || isTagQuery(q)) { setHomeFullTextResults(null); return; }
|
||||||
fullTextTimerRef.current = window.setTimeout(() => {
|
fullTextTimerRef.current = window.setTimeout(() => {
|
||||||
const index = loadProjectIndex();
|
const index = loadProjectIndex();
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -98,15 +101,9 @@ export default function HomePage() {
|
|||||||
}, 300);
|
}, 300);
|
||||||
}, [homeSearch]);
|
}, [homeSearch]);
|
||||||
|
|
||||||
const allTags = Array.from(new Set(projectIndex.flatMap((entry) => entry.tags ?? []))).sort((a, b) => a.localeCompare(b));
|
const tagList = tagCounts(projectIndex);
|
||||||
const visible = projectIndex
|
const shownTags = showAllTags ? tagList : tagList.slice(0, 12);
|
||||||
.slice()
|
const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags })
|
||||||
.filter((entry) => {
|
|
||||||
if (homeTagFilter && !(entry.tags ?? []).includes(homeTagFilter)) return false;
|
|
||||||
const q = homeSearch.trim().toLowerCase();
|
|
||||||
if (!q) return true;
|
|
||||||
return entry.title?.toLowerCase().includes(q) || entry.chapterSummary?.toLowerCase().includes(q);
|
|
||||||
})
|
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
|
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
|
||||||
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
|
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
|
||||||
@@ -160,7 +157,7 @@ export default function HomePage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||||
<input type="text" value={homeSearch} onChange={(e) => setHomeSearch(e.target.value)} placeholder="Search projects by title or passage…"
|
<input type="text" value={homeSearch} onChange={(e) => setHomeSearch(e.target.value)} placeholder="Search projects by title, passage or #tag…"
|
||||||
className={`${fieldCls} w-full max-w-sm`} />
|
className={`${fieldCls} w-full max-w-sm`} />
|
||||||
<label className="text-sm text-[var(--r-sub)]">Sort by{' '}
|
<label className="text-sm text-[var(--r-sub)]">Sort by{' '}
|
||||||
<select value={homeSort} onChange={(e) => setHomeSort(e.target.value)} className={`${fieldCls} ml-1`}>
|
<select value={homeSort} onChange={(e) => setHomeSort(e.target.value)} className={`${fieldCls} ml-1`}>
|
||||||
@@ -175,16 +172,34 @@ export default function HomePage() {
|
|||||||
className={`px-3 py-1.5 ${homeView === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{label}</button>
|
className={`px-3 py-1.5 ${homeView === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{label}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{allTags.length > 0 && (
|
|
||||||
<label className="text-sm text-[var(--r-sub)]">Tag{' '}
|
|
||||||
<select value={homeTagFilter} onChange={(e) => setHomeTagFilter(e.target.value)} className={`${fieldCls} ml-1`}>
|
|
||||||
<option value="">All tags</option>
|
|
||||||
{allTags.map((tag) => <option key={tag} value={tag}>{tag}</option>)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{tagList.length > 0 && (
|
||||||
|
<div className="-mt-1 mb-4 flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by tag">
|
||||||
|
<span className="mr-1 text-xs text-[var(--r-sub)]">Tags</span>
|
||||||
|
{shownTags.map(({ tag, count }) => {
|
||||||
|
const on = homeTags.includes(tag);
|
||||||
|
return (
|
||||||
|
<button key={tag} type="button" aria-pressed={on} onClick={() => toggleTag(tag)}
|
||||||
|
className={`rounded-full border px-2.5 py-0.5 text-xs ${on ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||||
|
{tag} <span className="opacity-70">{count}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{tagList.length > 12 && (
|
||||||
|
<button type="button" onClick={() => setShowAllTags((o) => !o)} className="text-xs text-[var(--r-accent)] hover:underline">{showAllTags ? 'Fewer' : `All ${tagList.length} tags`}</button>
|
||||||
|
)}
|
||||||
|
{homeTags.length > 0 && <button type="button" onClick={() => setHomeTags([])} className="text-xs text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Clear</button>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visible.length === 0 && (
|
||||||
|
<p role="status" className="mb-4 rounded-xl border border-dashed border-[var(--r-line)] px-4 py-6 text-center text-sm text-[var(--r-sub)]">
|
||||||
|
No studies match{homeTags.length ? ` ${homeTags.map((t) => `“${t}”`).join(' + ')}` : ''}{homeSearch.trim() ? ` “${homeSearch.trim()}”` : ''}.{' '}
|
||||||
|
<button type="button" onClick={() => { setHomeTags([]); setHomeSearch(''); }} className="text-[var(--r-accent)] hover:underline">Clear filters</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Full-text note search results */}
|
{/* Full-text note search results */}
|
||||||
{homeFullTextResults !== null && (
|
{homeFullTextResults !== null && (
|
||||||
<div className="mb-4 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
<div className="mb-4 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
||||||
@@ -241,7 +256,10 @@ export default function HomePage() {
|
|||||||
{entry.tags?.length > 0 && (
|
{entry.tags?.length > 0 && (
|
||||||
// A few tags and a "+N" for the rest (hover for all; the Tag filter above finds any of them)
|
// A few tags and a "+N" for the rest (hover for all; the Tag filter above finds any of them)
|
||||||
<div className="mt-2 flex flex-wrap gap-1" title={entry.tags.length > 3 ? entry.tags.join(', ') : undefined}>
|
<div className="mt-2 flex flex-wrap gap-1" title={entry.tags.length > 3 ? entry.tags.join(', ') : undefined}>
|
||||||
{entry.tags.slice(0, 3).map((tag) => <span key={tag} className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]">{tag}</span>)}
|
{entry.tags.slice(0, 3).map((tag) => (
|
||||||
|
<button key={tag} type="button" onClick={() => toggleTag(tag)} aria-pressed={homeTags.includes(tag)} title={`Show studies tagged “${tag}”`}
|
||||||
|
className={`rounded-full border px-2 py-0.5 text-[11px] ${homeTags.includes(tag) ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>{tag}</button>
|
||||||
|
))}
|
||||||
{entry.tags.length > 3 && <span className="rounded-full px-1.5 py-0.5 text-[11px] text-[var(--r-sub)]">+{entry.tags.length - 3} more</span>}
|
{entry.tags.length > 3 && <span className="rounded-full px-1.5 py-0.5 text-[11px] text-[var(--r-sub)]">+{entry.tags.length - 3} more</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -27,6 +27,45 @@ describe('Home page', () => {
|
|||||||
localStorage.setItem('bible-study-index', JSON.stringify(index));
|
localStorage.setItem('bible-study-index', JSON.stringify(index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('studies can be found by tag: chips, clicking a tag on a card, and #tag search', async () => {
|
||||||
|
const add = (id, title, tags) => {
|
||||||
|
seedProject({ id, title });
|
||||||
|
const index = JSON.parse(localStorage.getItem('bible-study-index'));
|
||||||
|
index.find((e) => e.id === id).tags = tags;
|
||||||
|
localStorage.setItem('bible-study-index', JSON.stringify(index));
|
||||||
|
};
|
||||||
|
add('p1', 'Titus Notes', ['hope', 'leadership']);
|
||||||
|
add('p2', 'New Birth', ['grace', 'hope']);
|
||||||
|
add('p3', 'Grace Alone', []);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
const titles = () => screen.queryAllByRole('heading', { level: 2 }).map((h) => h.textContent).filter((t) => ['Titus Notes', 'New Birth', 'Grace Alone'].includes(t)).sort();
|
||||||
|
const bar = screen.getByRole('group', { name: /filter by tag/i });
|
||||||
|
expect(titles()).toHaveLength(3);
|
||||||
|
|
||||||
|
await user.click(within(bar).getByRole('button', { name: /^hope/ }));
|
||||||
|
expect(titles()).toEqual(['New Birth', 'Titus Notes']);
|
||||||
|
await user.click(within(bar).getByRole('button', { name: /^grace/ })); // both chips: a study needs both
|
||||||
|
expect(titles()).toEqual(['New Birth']);
|
||||||
|
await user.click(within(bar).getByRole('button', { name: 'Clear' }));
|
||||||
|
expect(titles()).toHaveLength(3);
|
||||||
|
|
||||||
|
await user.click(screen.getAllByRole('button', { name: 'leadership' }).find((b) => b.closest('h2, div.rounded-2xl')?.textContent.includes('Titus Notes'))); // a tag on a card
|
||||||
|
expect(titles()).toEqual(['Titus Notes']);
|
||||||
|
await user.click(within(bar).getByRole('button', { name: 'Clear' }));
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText(/search projects/i), '#grace'); // tags only: not the study named "Grace Alone"
|
||||||
|
expect(titles()).toEqual(['New Birth']);
|
||||||
|
await user.clear(screen.getByPlaceholderText(/search projects/i));
|
||||||
|
await user.type(screen.getByPlaceholderText(/search projects/i), 'lead'); // plain words match tags too
|
||||||
|
expect(titles()).toEqual(['Titus Notes']);
|
||||||
|
await user.clear(screen.getByPlaceholderText(/search projects/i));
|
||||||
|
await user.type(screen.getByPlaceholderText(/search projects/i), '#nothing');
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent(/No studies match/);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Clear filters' }));
|
||||||
|
expect(titles()).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
test('studies can be shown as a grid or a list, and the choice is remembered', async () => {
|
test('studies can be shown as a grid or a list, and the choice is remembered', async () => {
|
||||||
seedProject();
|
seedProject();
|
||||||
seedProject({ id: 'p2', title: 'Second Study' });
|
seedProject({ id: 'p2', title: 'Second Study' });
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Searching and filtering the studies on the dashboard. The search box matches a study's title, its
|
||||||
|
// passages and its tags; starting it with "#" searches tags only ("#faith"). Chosen tag chips narrow
|
||||||
|
// the list to studies that have every one of them.
|
||||||
|
|
||||||
|
export const isTagQuery = (query) => String(query ?? '').trim().startsWith('#');
|
||||||
|
|
||||||
|
// -> [{ tag, count }], most-used first, then alphabetical
|
||||||
|
export function tagCounts(index) {
|
||||||
|
const counts = new Map();
|
||||||
|
for (const entry of index) for (const tag of new Set(entry.tags ?? [])) counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||||||
|
return [...counts].map(([tag, count]) => ({ tag, count })).sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterStudies(index, { query = '', tags = [] } = {}) {
|
||||||
|
const raw = String(query).trim().toLowerCase();
|
||||||
|
const tagOnly = raw.startsWith('#');
|
||||||
|
const q = tagOnly ? raw.slice(1).trim() : raw;
|
||||||
|
return index.filter((entry) => {
|
||||||
|
const own = (entry.tags ?? []).map((t) => t.toLowerCase());
|
||||||
|
if (tags.length && !tags.every((t) => own.includes(t.toLowerCase()))) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
if (own.some((t) => t.includes(q))) return true;
|
||||||
|
return !tagOnly && ((entry.title ?? '').toLowerCase().includes(q) || (entry.chapterSummary ?? '').toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { filterStudies, tagCounts, isTagQuery } from './studyFilter.js';
|
||||||
|
|
||||||
|
const index = [
|
||||||
|
{ id: 'a', title: 'Titus Notes', chapterSummary: 'Titus 1', tags: ['hope', 'leadership'] },
|
||||||
|
{ id: 'b', title: 'New Birth', chapterSummary: 'John 3', tags: ['grace', 'hope'] },
|
||||||
|
{ id: 'c', title: 'Grace Alone', chapterSummary: 'Romans 3', tags: [] },
|
||||||
|
];
|
||||||
|
const ids = (list) => list.map((e) => e.id);
|
||||||
|
|
||||||
|
test('the search box finds a study by title, passage or tag', () => {
|
||||||
|
expect(ids(filterStudies(index, { query: 'titus' }))).toEqual(['a']);
|
||||||
|
expect(ids(filterStudies(index, { query: 'romans' }))).toEqual(['c']);
|
||||||
|
expect(ids(filterStudies(index, { query: 'lead' }))).toEqual(['a']); // a tag
|
||||||
|
expect(ids(filterStudies(index, { query: 'grace' }))).toEqual(['b', 'c']); // a tag on one, a title on the other
|
||||||
|
});
|
||||||
|
|
||||||
|
test('starting with # searches tags only', () => {
|
||||||
|
expect(isTagQuery(' #grace')).toBe(true);
|
||||||
|
expect(ids(filterStudies(index, { query: '#grace' }))).toEqual(['b']); // not the study named "Grace Alone"
|
||||||
|
expect(ids(filterStudies(index, { query: '#HOP' }))).toEqual(['a', 'b']);
|
||||||
|
expect(ids(filterStudies(index, { query: '#' }))).toEqual(['a', 'b', 'c']);
|
||||||
|
expect(ids(filterStudies(index, { query: '#nothing' }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chosen tags must all be present, and combine with the search', () => {
|
||||||
|
expect(ids(filterStudies(index, { tags: ['hope'] }))).toEqual(['a', 'b']);
|
||||||
|
expect(ids(filterStudies(index, { tags: ['hope', 'grace'] }))).toEqual(['b']);
|
||||||
|
expect(ids(filterStudies(index, { tags: ['hope'], query: 'john' }))).toEqual(['b']);
|
||||||
|
expect(ids(filterStudies(index, { tags: ['Hope'] }))).toEqual(['a', 'b']); // case does not matter
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tags are counted, most used first', () => {
|
||||||
|
expect(tagCounts(index)).toEqual([{ tag: 'hope', count: 2 }, { tag: 'grace', count: 1 }, { tag: 'leadership', count: 1 }]);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user