Skip to content

feat(p3): implement scoped, field-specific, and fuzzy search#15

Merged
ryota-murakami merged 4 commits intomainfrom
feat/p3-search-f1-f2-f5
Feb 16, 2026
Merged

feat(p3): implement scoped, field-specific, and fuzzy search#15
ryota-murakami merged 4 commits intomainfrom
feat/p3-search-f1-f2-f5

Conversation

@ryota-murakami
Copy link
Contributor

@ryota-murakami ryota-murakami commented Feb 16, 2026

Summary

  • Implement Issue P3: Search (F1 Scoped, F2 Field-Specific, F5 Fuzzy) #3 end-to-end by adding scoped search (F1), field-specific search with highlighting (F2), and Fuse-based fuzzy collection search (F5) across main content, Cmd+K, sidebar, and collection selector flows.
  • Add shared search utilities (buildSearchQuery, filterByScope, highlight helpers, fuzzy helpers), wire Redux-backed search mode/scope/query state through the app, and update E2E API mocks to honor search operators.
  • Add comprehensive tests including new e2e/specs/search.spec.ts coverage for F1/F2/F5 plus an F5.3 performance assertion for 100 collections (<10ms average).

Closes #3.

Test plan

  • pnpm lint
  • pnpm typecheck
  • pnpm test
  • pnpm build
  • pnpm knip
  • pnpm test:e2e
  • pnpm coverage:e2e-spec

Summary by CodeRabbit

  • 新機能

    • グローバル/スコープ検索の切替と統合、コレクション内検索のファジー検索とハイライト表示
    • 検索クエリの正規化とスコープに基づくフィルタリング(クライアント側・モックで適用)
    • 検索結果の部分強調表示とコレクション候補のファジー候補表示
  • テスト

    • 検索ユーティリティの単体テスト追加と包括的E2E検索テスト、ファジー検索性能測定
    • Detailパネル描画同期チェックを追加しレイアウト検証を安定化

Deliver Issue #3 search UX end-to-end with persisted search state, API/query integration, Fuse-powered collection matching, and E2E coverage so search behavior is consistent and verifiable across views.
@coderabbitai
Copy link

coderabbitai bot commented Feb 16, 2026

Warning

Rate limit exceeded

@ryota-murakami has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 11 minutes and 6 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

クライアント/UI、Redux、検索ユーティリティ、コレクションファジー検索、E2E モックとテストにまたがる新規検索機能(スコープ、フィールド指定、Fuse.js ファジー、ハイライト、API クエリ変換)を追加。検索は API モックで事前フィルタ→ソート→ページングされる。

Changes

Cohort / File(s) Summary
検索ライブラリ & 型 / テスト
src/lib/search.ts, src/lib/search.test.ts, src/lib/types.ts
新規検索ユーティリティを追加(buildSearchQuery, filterByScope, ハイライトビルダー、fuzzySearchByName 等)と単体テスト。SearchMode 型を追加。
Redux スライス
src/store/slices/searchSlice.ts
検索 state に mode: SearchMode を追加し、setSearchMode アクションを導入。
アプリ本体の接続
src/components/main-app.tsx, src/components/raindrop/main-content.tsx
Redux 検索状態を注入し、searchQuery/scope/mode をプロップ化。API 呼び出しで buildSearchQuery を利用。
グローバル検索 UI
src/components/raindrop/global-search-command.tsx
Redux 管理への移行、スコープ切替 UI、filterByScope によるクライアントフィルタ、検索履歴管理、ハイライト表示を実装。
表示コンポーネントのハイライト対応
src/components/raindrop/raindrop-card.tsx, src/components/raindrop/raindrop-list-item.tsx
searchQuery/searchScope プロップ追加、buildSubstringHighlightSegments を用いたハイライト描画を導入。
コレクションファジー検索
src/components/raindrop/collection-search.tsx, src/components/raindrop/collection-selector.tsx
Fuse.js 統合、FlatCollectionSearchResult 型とインデックスベースのハイライト表示へ変更。
E2E モック & テスト
e2e/api-mock.ts, e2e/specs/search.spec.ts, e2e/specs/crud.spec.ts, e2e/specs/smoke.spec.ts
API モックに Raindrop 検索フィルタ(unquoteSearchTerm, includesTerm, matchesFreeText, applySearchFilter)を追加。検索 E2E テスト群追加。テストの要素選択ロジック("All Bookmarks")と Detail Panel 同期チェックを更新。

Sequence Diagram

sequenceDiagram
    actor User
    participant UI as MainContent / GlobalSearch
    participant Redux as Redux Store
    participant API as GET /raindrops (E2E mock)
    participant Filter as filterByScope()
    participant Highlight as buildSubstringHighlightSegments()
    participant Display as RaindropCard/ListItem

    User->>UI: 入力 (query, scope, mode)
    UI->>Redux: dispatch setSearchQuery / setSearchScope / setSearchMode
    Redux-->>UI: updated state
    UI->>API: GET /raindrops/{collectionId}?search=buildSearchQuery(...)
    API->>API: applySearchFilter(items, rawSearch)
    API-->>UI: filtered + paged raindrops
    UI->>Filter: filterByScope(items, query, scope)
    Filter-->>UI: scope-filtered items
    UI->>Highlight: buildSubstringHighlightSegments(text, query)
    Highlight-->>Display: HighlightSegment[]
    Display->>User: ハイライト付き検索結果表示
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed タイトルは P3 の検索機能(F1 スコープ検索、F2 フィールド検索、F5 ファジー検索)実装という主要な変更を正確に反映している。
Linked Issues check ✅ Passed PR はリンク Issue #3 の全コーディング要件を網羅:searchSlice 実装、main-content/global-search-command UI統合、buildSearchQuery/filterByScope ユーティリティ、Fuse.js ファジー検索、ハイライト表示、E2E/ユニットテスト。
Out of Scope Changes check ✅ Passed E2E テスト定位ロケータ調整(smoke.spec.ts, crud.spec.ts)は P3 検索実装の安定化に必要。API モック検索フィルタ統合も Issue #3 スコープ内。
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/p3-search-f1-f2-f5

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/raindrop/collection-search.tsx (1)

209-215: ⚠️ Potential issue | 🟡 Minor

閉じるボタンに aria-label がない

X アイコンのみのボタンにアクセシブルなラベルがない。スクリーンリーダーがボタンの目的を認識できない。

修正案
         <button
           type="button"
           onClick={onClose}
+          aria-label="Close search"
           className="hover:bg-accent absolute top-1/2 right-1.5 -translate-y-1/2 rounded p-0.5"
         >

As per coding guidelines, 「Accessibility (WCAG 2.2 AA+, Apple HIG, 44×44px tap targets)」。

🤖 Fix all issues with AI agents
In `@src/components/raindrop/raindrop-card.tsx`:
- Line 193: The code computes domainText via "const domainText = raindrop.domain
|| new URL(raindrop.url).hostname" which throws if raindrop.url is an invalid
URL; update the logic in raindrop-card.tsx to safely parse the hostname (same
fix as raindrop-list-item.tsx) by attempting to construct a URL inside a
try/catch or using an existing safeParseUrl helper, and fall back to
raindrop.url or an empty string when parsing fails so rendering never throws.

In `@src/components/raindrop/raindrop-list-item.tsx`:
- Line 186: The line computing domainText can throw when raindrop.url is empty
or invalid; update the logic in raindrop-list-item.tsx (the domainText
assignment that uses raindrop.domain and new URL(raindrop.url).hostname) to
safely parse the URL: check that raindrop.url is a non-empty string and wrap new
URL(...) in a try/catch (or validate with a regex/URL constructor test) and fall
back to raindrop.url or an empty string when parsing fails; ensure you still
prefer raindrop.domain when truthy and only attempt hostname extraction when
parsing succeeds.
🧹 Nitpick comments (12)
src/lib/search.ts (1)

267-297: Fuseインスタンスが毎回再生成される点

fuzzySearchByName は呼び出しのたびに new Fuse(items, ...) を生成する。呼び出し元が useMemo で適切にメモ化しているため、コレクション数が妥当な範囲(E2Eで100件 <10ms を検証済み)では問題ない。

大規模データセットで頻繁に呼ばれる場合は、Fuseインスタンスのキャッシュを検討してもよい。

src/lib/search.test.ts (1)

105-131: ハイライト関連テストのエッジケースカバレッジが薄い

buildSubstringHighlightSegmentsbuildIndexedHighlightSegments のテストが各1ケースのみ。以下のエッジケースの追加を推奨:

  • 空テキスト / 空クエリ
  • マッチなし
  • 複数マッチ(buildSubstringHighlightSegments で同じ語が複数回出現)
  • 重複・隣接インデックス(buildIndexedHighlightSegments のマージロジック)
追加テスト例
 describe('buildSubstringHighlightSegments', () => {
   it('builds matched and unmatched segments', () => {
     // ... existing test
   })
+
+  it('returns single unmatched segment when no match found', () => {
+    const segments = buildSubstringHighlightSegments('React Documentation', 'zzz')
+    expect(segments).toEqual([{ text: 'React Documentation', matched: false }])
+  })
+
+  it('returns empty array for empty text', () => {
+    expect(buildSubstringHighlightSegments('', 'test')).toEqual([])
+  })
+
+  it('returns unmatched segment for empty query', () => {
+    const segments = buildSubstringHighlightSegments('React', '')
+    expect(segments).toEqual([{ text: 'React', matched: false }])
+  })
 })

 describe('buildIndexedHighlightSegments', () => {
   it('builds segments from inclusive indices', () => {
     // ... existing test
   })
+
+  it('returns single unmatched segment for empty indices', () => {
+    const segments = buildIndexedHighlightSegments('React', [])
+    expect(segments).toEqual([{ text: 'React', matched: false }])
+  })
+
+  it('merges overlapping ranges', () => {
+    const segments = buildIndexedHighlightSegments('React', [[0, 2], [1, 3]])
+    expect(segments).toEqual([
+      { text: 'Reac', matched: true },
+      { text: 't', matched: false },
+    ])
+  })
 })
src/components/raindrop/collection-selector.tsx (2)

32-112: collection-search.tsx とのコード重複

FlatCollectionOptionFlatCollectionSearchResultflattenCollections、ハイライトレンダリング関数が collection-search.tsx とほぼ同一の実装で重複している。共通モジュールへの抽出を推奨。

#!/bin/bash
# 両ファイルの重複箇所を確認
echo "=== collection-selector.tsx ==="
rg -n 'flattenCollections|FlatCollection|renderHighlightedName|highlightMatch' src/components/raindrop/collection-selector.tsx

echo ""
echo "=== collection-search.tsx ==="
rg -n 'flattenCollections|FlatCollection|highlightMatch' src/components/raindrop/collection-search.tsx

169-179: hasSearchQueryuseMemo の依存配列に含めることについて

hasSearchQuerysearchQuery から派生した値なので、useMemo の依存配列に searchQuery があれば hasSearchQuery は不要。ただし、空クエリ時の早期リターンとして意図的に使っているなら実害はない。

依存配列の簡略化案
   const fuzzyCollections = useMemo(() => {
-    if (!hasSearchQuery) return []
+    if (!searchQuery.trim()) return []
     return fuzzySearchByName(flatCollections, searchQuery).map(
       (result): FlatCollectionSearchResult => ({
         item: result.item,
         indices: result.indices,
       }),
     )
-  }, [flatCollections, hasSearchQuery, searchQuery])
+  }, [flatCollections, searchQuery])
e2e/specs/search.spec.ts (4)

42-57: waitForTimeout(100) はフレーキーテストの原因になります。

Line 47 の waitForTimeout(100) は固定待機であり、CI環境では不安定になりがちです。ダイアログが閉じたことを明示的に待つ方が堅牢です。

♻️ 提案
   if (await commandDialog.isVisible()) {
     await page.keyboard.press('Escape')
-    await page.waitForTimeout(100)
+    await expect(commandDialog).not.toBeVisible()
   }

As per coding guidelines, "Appropriate timeouts and waitFor usage".


209-211: CSSクラスベースのセレクタは脆弱です。

button:has(svg.lucide-search) はアイコンライブラリの内部クラスに依存しており、バージョンアップで壊れる可能性があります。data-testidrole + name の使用を推奨します。

As per coding guidelines, "Resilient selectors (role, data-slot, data-testid)".


231-234: page.locator('strong').first() はスコープが広すぎます。

ページ全体から最初の <strong> を取得しているため、意図しない要素にマッチする可能性があります。親コンテナでスコープを絞るか、data-testid の付与を検討してください。

As per coding guidelines, "Resilient selectors (role, data-slot, data-testid)".


268-294: パフォーマンステストはE2EではなくユニットテストとしてFuse.jsを直接実行しています。

このテストはアプリのUIを経由せず、Node.js上でFuse.jsを直接呼び出しています。E2Eテストスイートに含める意図は理解できますが、実行環境の違い(Node vs Chromium renderer)により、実際のアプリ内パフォーマンスとの乖離が生じる可能性があります。ユニットテストファイルへの移動も検討してください。

src/components/main-app.tsx (1)

481-481: groups.flatMap(...) が毎レンダーで新しい配列参照を生成しています。

groups.flatMap((g) => g.collections) が3箇所(Line 481, 514, 556)で呼ばれており、それぞれ新しい配列参照を生成します。useMemo で一度だけ計算し、共有することでメモ化された子コンポーネントの不要な再レンダーを防げます。

♻️ 提案
+ const allCollections = useMemo(
+   () => groups.flatMap((g) => g.collections),
+   [groups],
+ )

そして各箇所で allCollections を参照するように変更してください。

Also applies to: 514-514, 556-556

src/components/raindrop/raindrop-list-item.tsx (1)

72-93: renderHighlightedText が3ファイルで重複しています。

同一の実装が raindrop-card.tsxraindrop-list-item.tsxglobal-search-command.tsx に存在します。src/lib/search.ts や共有ユーティリティに抽出することでDRY原則に準拠できます。

src/components/raindrop/global-search-command.tsx (1)

328-332: toggleSearchModeuseCallback でラップされていません。

他のハンドラーは useCallback でメモ化されていますが、toggleSearchMode はプレーンな関数宣言です。React.memo された親コンポーネント内で一貫性を保つため、useCallback の使用を推奨します。

♻️ 提案
- const toggleSearchMode = () => {
-   if (!canToggleGlobalSearch) return
-   const nextMode: SearchMode = isScopedSearch ? 'global' : 'scoped'
-   dispatch(setSearchModeAction(nextMode))
- }
+ const toggleSearchMode = useCallback(() => {
+   if (!canToggleGlobalSearch) return
+   const nextMode: SearchMode = isScopedSearch ? 'global' : 'scoped'
+   dispatch(setSearchModeAction(nextMode))
+ }, [canToggleGlobalSearch, isScopedSearch, dispatch])
src/components/raindrop/main-content.tsx (1)

395-409: handleSearchInputClearuseCallback でラップされていません。

Line 399-402 の handleSearchInputClear がプレーンなアロー関数として定義されています。同ファイル内の他のハンドラー(handleToggleSearchModehandleSearchGloballyclearSearch)は useCallback を使用しているため、一貫性を保つべきです。

♻️ 提案
- const handleSearchInputClear = () => {
-   onSearchQueryChange('')
-   setIsAdvancedSearchOpen(false)
- }
+ const handleSearchInputClear = useCallback(() => {
+   onSearchQueryChange('')
+   setIsAdvancedSearchOpen(false)
+ }, [onSearchQueryChange])

Prevent crashes in list and card render paths by using safe domain extraction with URL fallback when bookmark URLs are invalid.
Use text-first locators in smoke and CRUD E2E suites so readiness checks work consistently across environments where the accessible role can differ.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
e2e/specs/smoke.spec.ts (1)

17-19: getByText().first() はセレクタの堅牢性を下げる

getByRole('link', { name: 'All Bookmarks' }) から getByText('All Bookmarks').first() への変更は、コーディングガイドラインが推奨する「resilient selectors (role, data-slot, data-testid)」から離れています。.first() は DOM の順序に依存するため、レイアウト変更でサイレントに誤った要素をマッチする恐れがあります。

環境間でロールが異なる問題への対処であれば、data-testid="all-bookmarks-breadcrumb" のような専用属性を付与する方がより安定します。

💡 提案: data-testid を使ったアプローチ
-    await expect(page.getByText('All Bookmarks').first()).toBeVisible({
-      timeout: 10_000,
-    })
+    await expect(page.getByTestId('all-bookmarks-breadcrumb')).toBeVisible({
+      timeout: 10_000,
+    })

(対応する UI コンポーネント側に data-testid="all-bookmarks-breadcrumb" の追加が必要です)

As per coding guidelines, e2e/**/*.{ts,spec.ts}: "Resilient selectors (role, data-slot, data-testid)".

Use breadcrumb-scoped readiness checks in E2E specs so tests verify the active collection context instead of matching sidebar text alone.
@ryota-murakami ryota-murakami merged commit 36a34aa into main Feb 16, 2026
8 checks passed
@ryota-murakami ryota-murakami deleted the feat/p3-search-f1-f2-f5 branch February 16, 2026 19:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

P3: Search (F1 Scoped, F2 Field-Specific, F5 Fuzzy)

1 participant