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 |
|
true |
|
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 visibilitynotes: []— cached note data arraynotesLoaded: false— tracks if notes have been fetchedisLoadingNotes: false— loading spinner statesearchQuery: ''— 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">·</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.
npm run buildcompletes without errorsphp artisan test --filter=MemberNoteTest— all existing tests pass (especiallytest_notes_returned_newest_firstwhich now relies on explicitlatest())- Manual check: Open
resources/js/app.jsand verifyAlpine.plugin(collapse)is registered beforeAlpine.start() - Manual check: Open
app/Http/Controllers/Admin/MemberNoteController.phpand verify->latest('created_at')is present in index method - Manual check: Open
resources/views/admin/members/index.blade.phpand verify:- Badge is a
<button>with@click="toggleHistory()"andaria-expanded - Expansion
<tr>exists withx-show="historyOpen"andx-collapse - Search input with
x-model="searchQuery"is present - Empty state text "尚無備註" is present
formatDateTimemethod exists in x-datasubmitNote()hasthis.notes.unshift(response.data.note)cache sync
- Badge is a
- Alpine.js collapse plugin installed and registered in app.js
- Controller orders notes newest first with
->latest('created_at') - Note count badge is clickable and toggles expansion panel
- Expansion panel shows loading state, then notes with author and formatted datetime
- Search input filters notes by content or author name
- Empty state shows "尚無備註" for members with no notes
- "找不到符合的備忘錄" shows when search has no matches
- Closing panel resets search query
- Adding a note via inline form updates the cached notes array
- All existing tests still pass
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, andauthor.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
noteskey 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.nameandnote.contentandnote.idandnote.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']);
}
<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>