From 2791b34e5919d8f59e653b37822c2562d0cb7cd3 Mon Sep 17 00:00:00 2001 From: gbanyan Date: Fri, 13 Feb 2026 12:49:28 +0800 Subject: [PATCH] docs(phase-03): research note history & display implementation --- .../03-note-history-display/03-RESEARCH.md | 558 ++++++++++++++++++ 1 file changed, 558 insertions(+) create mode 100644 .planning/phases/03-note-history-display/03-RESEARCH.md diff --git a/.planning/phases/03-note-history-display/03-RESEARCH.md b/.planning/phases/03-note-history-display/03-RESEARCH.md new file mode 100644 index 0000000..7322fe1 --- /dev/null +++ b/.planning/phases/03-note-history-display/03-RESEARCH.md @@ -0,0 +1,558 @@ +# Phase 03: Note History & Display - Research + +**Researched:** 2026-02-13 +**Domain:** Alpine.js expandable table rows, Laravel query patterns, accessibility +**Confidence:** HIGH + +## Summary + +Phase 3 implements an expandable inline panel in the member list table that displays full note history when clicking the note count badge. This requires Alpine.js state management for expand/collapse behavior, Laravel query optimization for fetching notes with author relationships, client-side search filtering within the displayed notes, and proper accessibility attributes for screen readers. + +The existing architecture already provides the foundation: Phase 1 built the Notes API endpoint (`GET /admin/members/{member}/notes`) that returns notes with author information, and Phase 2 established the per-row Alpine.js pattern with independent `x-data` scopes that work correctly with Laravel pagination. The key technical challenges are: (1) adding expand/collapse state to the existing Alpine component, (2) fetching notes via AJAX when expanding, (3) implementing client-side search filtering, and (4) formatting dates in Traditional Chinese locale. + +**Primary recommendation:** Use Alpine.js x-show with x-collapse plugin for smooth height animation, fetch notes once on first expand and cache in component state, implement client-side filtering with computed property pattern, ensure ARIA accessibility with aria-expanded and aria-controls attributes. + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| Alpine.js | 3.4.2 | Reactive UI state management | Already used in Phase 2 for inline form; lightweight, works with server-rendered HTML | +| @alpinejs/collapse | 3.x | Smooth expand/collapse animation | Official Alpine plugin for height transitions, cleaner than manual CSS | +| Laravel Eloquent | 10.x | Query notes with relationships | Built-in ORM with eager loading prevents N+1 queries | +| Axios | Latest | AJAX requests for notes | Already included in Laravel bootstrap.js | +| Tailwind CSS | 3.1 | Styling and dark mode | Project standard for all UI components | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| Laravel Carbon | 2.x (Laravel default) | Datetime formatting | Format created_at for display, supports locale | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| x-collapse plugin | Manual x-transition with height classes | More boilerplate, harder to maintain smooth animations | +| Client-side search | Server-side filtering with AJAX | More complex, requires additional API endpoint, overkill for small datasets | +| Fetch on expand | Pre-load all notes in page load | Wasteful for members with many notes, degrades pagination performance | + +**Installation:** +```bash +npm install @alpinejs/collapse +``` + +Then register in `resources/js/app.js`: +```javascript +import Alpine from 'alpinejs'; +import collapse from '@alpinejs/collapse'; + +Alpine.plugin(collapse); +window.Alpine = Alpine; +Alpine.start(); +``` + +## Architecture Patterns + +### Recommended Project Structure +``` +resources/views/admin/members/ +├── index.blade.php # Main table with expandable rows +└── partials/ + └── note-history.blade.php # (Optional) Extracted panel markup for clarity +``` + +### Pattern 1: Expandable Row with Lazy-Loaded Content + +**What:** Clicking badge expands panel below the row, fetches notes on first expand only, caches in Alpine state + +**When to use:** When content is not needed immediately, reduces initial page load, prevents N+1 in index query + +**Example:** +```javascript +// Source: https://alpinejs.dev/plugins/collapse +{ + noteFormOpen: false, // From Phase 2 + noteContent: '', // From Phase 2 + noteCount: {{ $member->notes_count }}, // From Phase 1 + + // NEW in Phase 3 + historyOpen: false, // Controls panel visibility + notes: [], // Cached note data + notesLoaded: false, // Tracks if we've fetched + isLoadingNotes: false, // Loading state + searchQuery: '', // Filter text + + async toggleHistory() { + this.historyOpen = !this.historyOpen; + if (this.historyOpen && !this.notesLoaded) { + await this.loadNotes(); + } + }, + + async loadNotes() { + this.isLoadingNotes = true; + try { + const response = await axios.get('{{ route("admin.members.notes.index", $member) }}'); + this.notes = response.data.notes; + this.notesLoaded = true; + } catch (error) { + console.error('Failed to load notes:', error); + } finally { + this.isLoadingNotes = false; + } + }, + + get filteredNotes() { + if (!this.searchQuery.trim()) return this.notes; + const query = this.searchQuery.toLowerCase(); + return this.notes.filter(note => + note.content.toLowerCase().includes(query) || + note.author.name.toLowerCase().includes(query) + ); + } +} +``` + +**Template:** +```html + + + + + + + +
+ 載入中... +
+ + +
+ + + + + + + + + + + +
+ + +``` + +### Pattern 2: Client-Side Search with Computed Property + +**What:** Reactive filtering using Alpine.js getter that automatically updates when searchQuery changes + +**When to use:** Small to medium datasets (< 100 items), instant feedback, no server round-trip needed + +**Example:** +```javascript +// Source: https://github.com/alpinejs/alpine/discussions/484 +{ + searchQuery: '', + notes: [...], + + get filteredNotes() { + if (!this.searchQuery.trim()) return this.notes; + + const query = this.searchQuery.toLowerCase(); + return this.notes.filter(note => { + // Search in content and author name + const searchableText = (note.content + ' ' + note.author.name).toLowerCase(); + return searchableText.includes(query); + }); + } +} +``` + +**Template:** +```html + + +``` + +### Pattern 3: Laravel Query Optimization for Notes Index + +**What:** Fetch notes with author relationship, order by newest first + +**When to use:** Always for the notes index endpoint to prevent N+1 and ensure consistent ordering + +**Example:** +```php +// Source: https://laravel.com/docs/10.x/pagination +public function index(Member $member) +{ + $notes = $member->notes() + ->with('author:id,name') // Eager load only needed author fields + ->latest('created_at') // Newest first (equivalent to orderBy('created_at', 'desc')) + ->get(); + + return response()->json(['notes' => $notes]); +} +``` + +**Why not paginate?** +- Members typically have < 20 notes (based on system context) +- Client-side search/filter requires all notes present +- Pagination adds complexity without meaningful UX benefit for this use case + +### Pattern 4: Datetime Formatting for Traditional Chinese + +**What:** Format created_at in Blade for server-side rendering, or use JavaScript helper for client-side + +**When to use:** When displaying dates in JSON responses consumed by Alpine.js + +**Example (Blade - server-side):** +```blade +{{ $note->created_at->format('Y年m月d日 H:i') }} +``` + +**Example (Alpine.js helper - client-side):** +```javascript +{ + formatDateTime(dateString) { + const date = new Date(dateString); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}年${month}月${day}日 ${hours}:${minutes}`; + } +} +``` + +**Why client-side?** Notes are fetched via AJAX, so Blade can't format them. Carbon's `toIso8601String()` provides consistent JSON serialization, then format in JavaScript. + +### Anti-Patterns to Avoid + +- **Nested x-data scopes within table rows:** Creates complexity with event bubbling and state isolation issues. Keep all row state in a single x-data on the ``. + +- **Fetching notes on every expand:** Wasteful if user toggles multiple times. Cache in `notes` array and use `notesLoaded` flag. + +- **Server-side search for small datasets:** Adds latency and requires new API endpoint. Client-side filtering with computed property is instant and simpler. + +- **Including panel markup in main row ``:** Breaks table semantics. Use separate `` with colspan for the expansion panel. + +- **Forgetting `x-cloak` on conditional content:** Causes flash of unstyled content during Alpine initialization. Add `[x-cloak] { display: none !important; }` in styles. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Smooth height animation | Manual transition classes with max-height hacks | `@alpinejs/collapse` plugin | Official plugin handles edge cases (dynamic content height, nested animations), cleaner API | +| AJAX wrapper | Custom fetch/promise handling | Axios (already in Laravel bootstrap) | Error handling, request/response interceptors, CSRF token auto-injection for Laravel | +| Datetime localization | String concatenation with date parts | Carbon `->format()` on server, or built-in `Intl.DateTimeFormat` in JS | Edge cases with timezones, leap years, DST; Carbon handles locale-aware formatting | +| Search algorithm | Custom string matching logic | Native `String.includes()` with normalization | Built-in, fast, handles Unicode edge cases; for advanced needs use fuse.js or lunr.js | + +**Key insight:** Alpine.js excels at enhancing server-rendered HTML with reactivity. Don't fight this by building complex client-side state synchronization—let Laravel render initial state, use Alpine for interactions only. + +## Common Pitfalls + +### Pitfall 1: Expansion Panel Breaks Table Layout + +**What goes wrong:** Putting panel content inside the main `` distorts column widths, makes styling inconsistent + +**Why it happens:** HTML table layout algorithm treats content as part of the cell's intrinsic size calculation + +**How to avoid:** Use a separate `` immediately after the main row, with a single `` spanning all columns + +**Warning signs:** Uneven column widths when panel is open, horizontal scrollbar appears, adjacent rows shift + +**Example:** +```html + + + ... + + + + ... + + + + + + + + +``` + +### Pitfall 2: Notes Not Ordered Newest First + +**What goes wrong:** Notes display in random or oldest-first order, confusing for users who expect recent notes at top + +**Why it happens:** Eloquent returns results in database order (usually insertion order = oldest first) unless explicitly ordered + +**How to avoid:** Always use `->latest('created_at')` in controller, add test to verify ordering (Phase 1 already includes this test) + +**Warning signs:** Test `test_notes_returned_newest_first()` fails, manual testing shows oldest notes at top + +**Example:** +```php +// WRONG - no ordering +$notes = $member->notes()->with('author')->get(); + +// RIGHT - explicit newest first +$notes = $member->notes()->with('author')->latest('created_at')->get(); +``` + +### Pitfall 3: N+1 Query When Loading Authors + +**What goes wrong:** Loading 10 notes triggers 1 query for notes + 10 queries for authors (11 total), slow page load + +**Why it happens:** Lazy loading relationships fetches author individually for each note when accessed + +**How to avoid:** Use `->with('author')` to eager load, Laravel debugbar shows query count in development + +**Warning signs:** Many duplicate SELECT queries for users table, slow response time for notes endpoint + +**Example:** +```php +// WRONG - N+1 queries +$notes = $member->notes()->latest()->get(); +// When iterating: $notes->each(fn($n) => $n->author->name) triggers N queries + +// RIGHT - 2 queries total +$notes = $member->notes()->with('author')->latest()->get(); +``` + +### Pitfall 4: Stale Note Count After Adding Note + +**What goes wrong:** User adds note via Phase 2 inline form, count badge updates, but history panel shows old data if already loaded + +**Why it happens:** Phase 2 increments `noteCount++` but doesn't update cached `notes` array in Phase 3 + +**How to avoid:** After successful note creation in `submitNote()`, check if `notesLoaded === true`, if so, unshift new note into `notes` array + +**Warning signs:** Count says "3" but panel only shows 2 notes, refreshing page fixes it + +**Example:** +```javascript +async submitNote() { + this.isSubmitting = true; + try { + const response = await axios.post('...', { content: this.noteContent }); + this.noteCount++; + this.noteContent = ''; + this.noteFormOpen = false; + + // IMPORTANT: Update cached notes if panel has been opened + if (this.notesLoaded) { + this.notes.unshift(response.data.note); // Add to beginning (newest first) + } + } catch (error) { + // ... + } finally { + this.isSubmitting = false; + } +} +``` + +### Pitfall 5: Accessibility - Missing ARIA Attributes + +**What goes wrong:** Screen reader users don't know button expands content, can't navigate back to collapsed state + +**Why it happens:** Expandable patterns require explicit ARIA attributes that aren't automatically added by Alpine.js + +**How to avoid:** Add `aria-expanded`, `aria-controls`, and `id` attributes to button and panel + +**Warning signs:** Automated accessibility testing flags missing attributes, manual testing with screen reader shows poor UX + +**Example:** +```html + + + + + + ... + +``` + +**Sources:** +- [aria-expanded - MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-expanded) +- [Table with Expando Rows - Adrian Roselli](https://adrianroselli.com/2019/09/table-with-expando-rows.html) + +### Pitfall 6: Search Doesn't Clear When Closing Panel + +**What goes wrong:** User searches in panel, finds note, closes panel, reopens later—old search term still active, confusing results + +**Why it happens:** `searchQuery` state persists across open/close cycles + +**How to avoid:** Reset `searchQuery = ''` in `toggleHistory()` when closing (when `historyOpen` becomes false) + +**Warning signs:** Reopening panel shows filtered results without visible search query, or search input has old value + +**Example:** +```javascript +toggleHistory() { + this.historyOpen = !this.historyOpen; + + // Clear search when closing + if (!this.historyOpen) { + this.searchQuery = ''; + } + + // Load notes when opening for first time + if (this.historyOpen && !this.notesLoaded) { + this.loadNotes(); + } +} +``` + +## Code Examples + +Verified patterns from official sources and existing codebase: + +### Alpine.js Collapse Plugin Usage +```javascript +// Source: https://alpinejs.dev/plugins/collapse +
+ +
+ Content here +
+
+``` + +### Laravel Latest (Newest First) with Eager Loading +```php +// Source: https://laravel.com/docs/10.x/pagination +$notes = Note::with('author:id,name') + ->latest('created_at') // Same as orderBy('created_at', 'desc') + ->get(); +``` + +### Alpine.js Client-Side Search Pattern +```javascript +// Source: https://github.com/alpinejs/alpine/discussions/484 +{ + search: '', + items: [...], + + get filteredItems() { + if (!this.search) return this.items; + return this.items.filter(item => + item.name.toLowerCase().includes(this.search.toLowerCase()) + ); + } +} +``` + +### ARIA Expanded Pattern +```html + + +
+ Panel content +
+``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| jQuery slideToggle() | Alpine.js x-collapse plugin | Alpine 3.x (2021) | Lighter bundle, reactive state, better DX | +| Server-side pagination for notes | Client-side filtering | Modern SPA patterns (2020+) | Instant feedback, reduced server load | +| Manual height transitions | x-collapse with automatic height detection | @alpinejs/collapse v3 | Handles dynamic content, smoother animations | +| aria-hidden for hiding | x-show (uses display: none) | Alpine 3.x | Better screen reader support, x-show doesn't need aria-hidden | + +**Deprecated/outdated:** +- `x-show.transition` modifier: Replaced by x-collapse plugin for height animations (x-transition is for opacity/scale) +- Storing notes in global Alpine.store(): Per-row state is cleaner for table rows, avoids complexity +- Using `v-if` / `v-show` from Vue.js syntax: Alpine uses `x-if` / `x-show` + +## Open Questions + +1. **Should notes be paginated on the backend?** + - What we know: Current API returns all notes with `->get()`, client-side filtering requires all data + - What's unclear: If a member could have 100+ notes, would pagination be needed? + - Recommendation: Start without pagination (simpler UX, matches requirement DISP-04 "search within member's history"). Add pagination later if performance issue emerges in real usage. Consider limit of 50 notes per member as reasonable threshold. + +2. **Should the expansion panel be a separate Blade component?** + - What we know: Current member list has all markup inline in `index.blade.php`, panel adds ~50 lines of markup + - What's unclear: Project preference for inline vs. extracted partials + - Recommendation: Start inline for simplicity (keeps all row logic in one file), extract to `partials/note-history-panel.blade.php` if it grows beyond 100 lines or if reused elsewhere. + +3. **Should datetime formatting use JavaScript Intl.DateTimeFormat or manual formatting?** + - What we know: Project uses `->format('Y年m月d日 H:i')` pattern in Blade (seen in documents views) + - What's unclear: Whether to replicate this exact format in JavaScript or use Intl API + - Recommendation: Use manual formatting helper to match existing project style (`2026年02月13日 14:30`), ensures consistency with server-rendered dates. Intl API would be more flexible for future i18n but adds complexity. + +## Sources + +### Primary (HIGH confidence) +- [Alpine.js Collapse Plugin Official Docs](https://alpinejs.dev/plugins/collapse) - x-collapse usage, modifiers +- [Laravel 10.x Pagination Docs](https://laravel.com/docs/10.x/pagination) - orderBy, latest, pagination patterns +- [Alpine.js Transition Directive](https://alpinejs.dev/directives/transition) - x-show animations +- Existing codebase: + - `/Users/gbanyan/Project/usher-manage-stack/app/Http/Controllers/Admin/MemberNoteController.php` - Current API structure + - `/Users/gbanyan/Project/usher-manage-stack/resources/views/admin/members/index.blade.php` - Phase 2 Alpine.js patterns + - `/Users/gbanyan/Project/usher-manage-stack/tests/Feature/Admin/MemberNoteTest.php` - Test coverage, ordering expectations + +### Secondary (MEDIUM confidence) +- [Adrian Roselli - Table with Expando Rows](https://adrianroselli.com/2019/09/table-with-expando-rows.html) - Accessibility best practices verified with MDN ARIA docs +- [MDN ARIA: aria-expanded](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-expanded) - Official W3C attribute specification +- [Alpine.js GitHub Discussion #484 - Search Multiple Keys](https://github.com/alpinejs/alpine/discussions/484) - Community pattern for client-side search +- [Raymond Camden - Table Sorting and Pagination in Alpine.js](https://www.raymondcamden.com/2022/05/02/building-table-sorting-and-pagination-in-alpinejs) - Practical implementation examples + +### Tertiary (LOW confidence) +- Alpine Toolbox examples - Community-contributed patterns (useful for inspiration, verify before using) +- GitHub search results for Alpine.js table patterns - Various implementations, quality varies + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH - Alpine.js 3.4.2 already in use, @alpinejs/collapse is official plugin, Laravel patterns are documented +- Architecture: HIGH - Patterns verified in existing codebase (Phase 1 & 2), official docs confirm syntax +- Pitfalls: MEDIUM to HIGH - Expansion panel layout and N+1 queries are well-known issues (HIGH), ARIA patterns verified with official specs (HIGH), stale cache sync is inferred from Alpine reactivity model (MEDIUM) + +**Research date:** 2026-02-13 +**Valid until:** ~30 days (Alpine.js and Laravel 10 are stable, no breaking changes expected)