Files
usher-manage-stack/.planning/phases/03-note-history-display/03-01-PLAN.md

20 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
03-note-history-display 01 execute 1
resources/js/app.js
resources/views/admin/members/index.blade.php
app/Http/Controllers/Admin/MemberNoteController.php
tests/Feature/Admin/MemberNoteTest.php
package.json
true
truths artifacts key_links
Admin clicks note count badge and an inline panel expands below the row showing all notes for that member
Notes display newest first with author name and formatted datetime (YYYY年MM月DD日 HH:mm)
Panel shows '尚無備註' when member has no notes
Admin can type in a search field to filter notes by text content or author name
Panel collapses cleanly when badge is clicked again, search query resets, other rows are unaffected
After adding a note via inline form, the history panel (if previously opened) shows the new note immediately without re-fetching
path provides contains
resources/js/app.js Alpine.js collapse plugin registration Alpine.plugin(collapse)
path provides contains
resources/views/admin/members/index.blade.php Expandable note history panel with search toggleHistory
path provides contains
app/Http/Controllers/Admin/MemberNoteController.php Notes endpoint with newest-first ordering and eager-loaded author latest
path provides
tests/Feature/Admin/MemberNoteTest.php Tests verifying ordering, empty state, and search-related data
from to via pattern
resources/views/admin/members/index.blade.php GET /admin/members/{member}/notes axios.get in Alpine.js toggleHistory() method admin.members.notes.index
from to via pattern
resources/views/admin/members/index.blade.php resources/js/app.js Alpine.plugin(collapse) enables x-collapse directive x-collapse
from to via pattern
resources/views/admin/members/index.blade.php submitNote → notes.unshift After note creation, new note injected into cached notes array this.notes.unshift
Add expandable note history panel to member list with search filtering, complete the note feature.

Purpose: Admins can view full note history for any member by clicking the note count badge, filter notes by content, and see notes displayed with author attribution and timestamps — all inline without leaving the member list page.

Output: Working expandable panel with lazy-loaded notes, client-side search, empty state, and proper cache sync with the inline add form.

<execution_context> @/Users/gbanyan/.claude/get-shit-done/workflows/execute-plan.md @/Users/gbanyan/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/03-note-history-display/03-RESEARCH.md @.planning/phases/02-inline-quick-add-ui/02-01-SUMMARY.md @resources/views/admin/members/index.blade.php @resources/js/app.js @app/Http/Controllers/Admin/MemberNoteController.php @tests/Feature/Admin/MemberNoteTest.php Task 1: Install collapse plugin, fix controller ordering, build expandable history panel with search package.json resources/js/app.js app/Http/Controllers/Admin/MemberNoteController.php resources/views/admin/members/index.blade.php **Step 1: Install @alpinejs/collapse**

Run npm install @alpinejs/collapse to add the plugin.

Step 2: Register collapse plugin in app.js

In resources/js/app.js, import and register the collapse plugin BEFORE Alpine.start():

import './bootstrap';
import Alpine from 'alpinejs';
import collapse from '@alpinejs/collapse';

Alpine.plugin(collapse);
window.Alpine = Alpine;
Alpine.start();

Run npm run build to verify the build succeeds.

Step 3: Fix controller ordering

In app/Http/Controllers/Admin/MemberNoteController.php, update the index method to order notes newest first:

Change:

$notes = $member->notes()->with('author')->get();

To:

$notes = $member->notes()->with('author:id,name')->latest('created_at')->get();

This fixes a latent bug where ordering was only working by coincidence (SQLite insertion order). Also narrows author eager load to only id and name fields.

Step 4: Extend Alpine.js x-data scope in member row

In resources/views/admin/members/index.blade.php, extend the existing per-row x-data object to add history panel state. The existing x-data on the <tr> (line ~196) currently has noteFormOpen, noteContent, isSubmitting, errors, noteCount, and submitNote(). Add these new properties and methods:

New state properties (add after noteCount):

  • historyOpen: false — controls panel visibility
  • notes: [] — cached note data array
  • notesLoaded: false — tracks if notes have been fetched
  • isLoadingNotes: false — loading spinner state
  • searchQuery: '' — search input value

New methods:

toggleHistory() {
    this.historyOpen = !this.historyOpen;
    if (!this.historyOpen) {
        this.searchQuery = '';
    }
    if (this.historyOpen && !this.notesLoaded) {
        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)
    );
},
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;
}

Step 5: Update submitNote() for cache sync

Inside the existing submitNote() method's success block (after this.noteCount++), add cache sync logic:

// After noteCount++, noteContent = '', noteFormOpen = false:
if (this.notesLoaded) {
    this.notes.unshift(response.data.note);
}

This ensures the history panel (if already opened) shows the new note immediately. The response.data.note already includes author from the store endpoint (Phase 1 returns $note->load('author')).

Step 6: Make the note count badge clickable

Replace the existing static <span> badge in the 備忘錄 column with a clickable <button>:

<button @click="toggleHistory()"
        type="button"
        :aria-expanded="historyOpen.toString()"
        :aria-controls="'notes-panel-{{ $member->id }}'"
        class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 hover:bg-blue-200 dark:hover:bg-blue-800 cursor-pointer transition-colors">
    <span x-text="noteCount"></span>
</button>

Keep the existing pencil icon toggle button for the add form unchanged.

Step 7: Add expansion panel as a separate <tr> after the main row

Immediately after the closing </tr> of the main member row (before the @empty directive), add a new <tr> for the expansion panel. This <tr> must be OUTSIDE the main row's <tr> but needs to share Alpine.js state.

IMPORTANT: The expansion panel <tr> cannot access the main row's x-data if it's on a sibling <tr>. To solve this, wrap BOTH the main <tr> and the expansion <tr> in a <template> tag with x-data instead. Move the x-data from the main <tr> to a wrapping <template x-data="..."> element.

Structure:

@forelse ($members as $member)
    <template x-data="{ ... all state and methods ... }">
        <!-- Main row -->
        <tr>
            ... existing cells ...
        </tr>
        <!-- Expansion panel row -->
        <tr x-show="historyOpen" x-collapse
            :id="'notes-panel-{{ $member->id }}'"
            class="bg-gray-50 dark:bg-gray-900">
            <td colspan="8" class="px-6 py-4">
                <!-- Loading state -->
                <div x-show="isLoadingNotes" class="flex justify-center py-4">
                    <svg class="animate-spin h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                        <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                        <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
                    </svg>
                    <span class="ml-2 text-sm text-gray-500 dark:text-gray-400">載入中...</span>
                </div>

                <!-- Loaded content -->
                <div x-show="!isLoadingNotes" x-cloak>
                    <!-- Search input (only show if notes exist) -->
                    <template x-if="notes.length > 0">
                        <div class="mb-3">
                            <input type="text"
                                   x-model="searchQuery"
                                   placeholder="搜尋備忘錄內容或作者..."
                                   class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-indigo-500 dark:focus:border-indigo-500 focus:ring-indigo-500 dark:focus:ring-indigo-500 sm:text-sm dark:bg-gray-700 dark:text-gray-200">
                        </div>
                    </template>

                    <!-- Notes list -->
                    <template x-if="filteredNotes.length > 0">
                        <div class="space-y-3 max-h-64 overflow-y-auto">
                            <template x-for="note in filteredNotes" :key="note.id">
                                <div class="border-l-4 border-blue-500 dark:border-blue-400 pl-3 py-2">
                                    <p class="text-sm text-gray-900 dark:text-gray-100 whitespace-pre-line" x-text="note.content"></p>
                                    <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
                                        <span x-text="note.author.name"></span>
                                        <span class="mx-1">&middot;</span>
                                        <span x-text="formatDateTime(note.created_at)"></span>
                                    </p>
                                </div>
                            </template>
                        </div>
                    </template>

                    <!-- Empty state: no notes at all -->
                    <template x-if="notesLoaded && notes.length === 0">
                        <p class="text-sm text-gray-500 dark:text-gray-400 py-2">尚無備註</p>
                    </template>

                    <!-- No search results -->
                    <template x-if="notes.length > 0 && filteredNotes.length === 0">
                        <p class="text-sm text-gray-500 dark:text-gray-400 py-2">找不到符合的備忘錄</p>
                    </template>
                </div>
            </td>
        </tr>
    </template>
@empty

NOTE: Using <template> as a wrapper inside <tbody> is valid in Alpine.js — Alpine treats <template> as a transparent wrapper and the browser renders the child <tr> elements directly into the table.

Step 8: Add max-height scroll and panel styling

The notes list container has max-h-64 overflow-y-auto to keep the panel compact when there are many notes (scrollable after ~5-6 notes). The whitespace-pre-line on note content preserves line breaks from multi-line notes.

Step 9: Run npm run build to compile assets with the new collapse plugin.

  1. npm run build completes without errors
  2. php artisan test --filter=MemberNoteTest — all existing tests pass (especially test_notes_returned_newest_first which now relies on explicit latest())
  3. Manual check: Open resources/js/app.js and verify Alpine.plugin(collapse) is registered before Alpine.start()
  4. Manual check: Open app/Http/Controllers/Admin/MemberNoteController.php and verify ->latest('created_at') is present in index method
  5. Manual check: Open resources/views/admin/members/index.blade.php and verify:
    • Badge is a <button> with @click="toggleHistory()" and aria-expanded
    • Expansion <tr> exists with x-show="historyOpen" and x-collapse
    • Search input with x-model="searchQuery" is present
    • Empty state text "尚無備註" is present
    • formatDateTime method exists in x-data
    • submitNote() has this.notes.unshift(response.data.note) cache sync
  6. Alpine.js collapse plugin installed and registered in app.js
  7. Controller orders notes newest first with ->latest('created_at')
  8. Note count badge is clickable and toggles expansion panel
  9. Expansion panel shows loading state, then notes with author and formatted datetime
  10. Search input filters notes by content or author name
  11. Empty state shows "尚無備註" for members with no notes
  12. "找不到符合的備忘錄" shows when search has no matches
  13. Closing panel resets search query
  14. Adding a note via inline form updates the cached notes array
  15. All existing tests still pass
Task 2: Add feature tests for note history panel rendering and search behavior tests/Feature/Admin/MemberNoteTest.php Add the following tests to the existing `tests/Feature/Admin/MemberNoteTest.php` file. These tests verify the backend supports the history panel behavior and that the Blade view renders the necessary Alpine.js directives.

Test 1: test_notes_index_returns_author_name_and_created_at

Verify the notes index endpoint returns properly structured data for the history panel:

  • Create a member with 2 notes by different authors
  • GET the notes index endpoint
  • Assert each note has content, created_at, and author.name
  • Assert author name matches the actual user name
public function test_notes_index_returns_author_name_and_created_at(): void
{
    $admin = $this->createAdminUser();
    $otherAdmin = User::factory()->create(['name' => '測試管理員']);
    $otherAdmin->assignRole('admin');
    $member = Member::factory()->create();

    Note::factory()->forMember($member)->byAuthor($admin)->create(['content' => 'Note by admin']);
    Note::factory()->forMember($member)->byAuthor($otherAdmin)->create(['content' => 'Note by other']);

    $response = $this->actingAs($admin)->getJson(
        route('admin.members.notes.index', $member)
    );

    $response->assertStatus(200);
    $notes = $response->json('notes');

    // Each note must have author.name and created_at for display
    foreach ($notes as $note) {
        $this->assertNotEmpty($note['author']['name']);
        $this->assertNotEmpty($note['created_at']);
    }

    // Verify different authors are represented
    $authorNames = array_column(array_column($notes, 'author'), 'name');
    $this->assertContains($admin->name, $authorNames);
    $this->assertContains('測試管理員', $authorNames);
}

Test 2: test_notes_index_returns_empty_array_for_member_with_no_notes

Verify the empty state data contract:

  • Create a member with no notes
  • GET the notes index endpoint
  • Assert response has notes key with empty array
public function test_notes_index_returns_empty_array_for_member_with_no_notes(): void
{
    $admin = $this->createAdminUser();
    $member = Member::factory()->create();

    $response = $this->actingAs($admin)->getJson(
        route('admin.members.notes.index', $member)
    );

    $response->assertStatus(200);
    $response->assertJson(['notes' => []]);
    $this->assertCount(0, $response->json('notes'));
}

Test 3: test_member_list_renders_history_panel_directives

Verify the Blade view contains the necessary Alpine.js directives for the history panel:

  • Create a member
  • GET the member list page
  • Assert the HTML contains: toggleHistory, historyOpen, x-collapse, searchQuery, filteredNotes, 尚無備註, aria-expanded, notes-panel-
public function test_member_list_renders_history_panel_directives(): void
{
    $admin = $this->createAdminUser();
    Member::factory()->create();

    $response = $this->actingAs($admin)->get(route('admin.members.index'));

    $response->assertStatus(200);
    $response->assertSee('toggleHistory', false);
    $response->assertSee('historyOpen', false);
    $response->assertSee('x-collapse', false);
    $response->assertSee('searchQuery', false);
    $response->assertSee('filteredNotes', false);
    $response->assertSee('尚無備註', false);
    $response->assertSee('aria-expanded', false);
    $response->assertSee('notes-panel-', false);
}

Test 4: test_store_note_returns_note_with_author_for_cache_sync

Verify the store endpoint returns the note with author data that the frontend needs for cache sync:

  • Create a note via POST
  • Assert the response includes note.author.name and note.content and note.id and note.created_at
public function test_store_note_returns_note_with_author_for_cache_sync(): void
{
    $admin = $this->createAdminUser();
    $member = Member::factory()->create();

    $response = $this->actingAs($admin)->postJson(
        route('admin.members.notes.store', $member),
        ['content' => 'Cache sync test note']
    );

    $response->assertStatus(201);
    $note = $response->json('note');

    // All fields needed for frontend cache sync
    $this->assertArrayHasKey('id', $note);
    $this->assertArrayHasKey('content', $note);
    $this->assertArrayHasKey('created_at', $note);
    $this->assertArrayHasKey('author', $note);
    $this->assertArrayHasKey('name', $note['author']);
    $this->assertEquals($admin->name, $note['author']['name']);
}
Run `php artisan test --filter=MemberNoteTest` — all tests pass (existing 7 + new 4 = 11 total). 1. 4 new tests added verifying: author+datetime in API response, empty state response, history panel Alpine directives in HTML, and store endpoint returns data needed for cache sync 2. All 11 tests pass (7 existing + 4 new) 1. `npm run build` succeeds (collapse plugin compiled) 2. `php artisan test --filter=MemberNoteTest` — all 11 tests pass 3. Blade view has clickable badge with aria-expanded, expansion panel with x-collapse, search input, empty state, no-results state 4. Controller index method uses `->latest('created_at')` and `->with('author:id,name')` 5. submitNote() syncs cache with `this.notes.unshift(response.data.note)`

<success_criteria>

  • Admin can click note count badge to expand inline panel showing all notes for that member
  • Notes display newest first with author name and formatted datetime (YYYY年MM月DD日 HH:mm)
  • Panel shows "尚無備註" when member has no notes
  • Admin can filter notes by text content or author name via search input
  • Closing panel resets search query; other member rows unaffected
  • Adding a note via inline form immediately appears in the history panel without re-fetch
  • All 11 MemberNoteTest tests pass </success_criteria>
After completion, create `.planning/phases/03-note-history-display/03-01-SUMMARY.md`