docs(03): create phase plan for note history display

This commit is contained in:
2026-02-13 12:53:17 +08:00
parent 2791b34e59
commit 14bab518dd
2 changed files with 473 additions and 4 deletions

View File

@@ -0,0 +1,469 @@
---
phase: 03-note-history-display
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- resources/js/app.js
- resources/views/admin/members/index.blade.php
- app/Http/Controllers/Admin/MemberNoteController.php
- tests/Feature/Admin/MemberNoteTest.php
- package.json
autonomous: true
must_haves:
truths:
- "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"
artifacts:
- path: "resources/js/app.js"
provides: "Alpine.js collapse plugin registration"
contains: "Alpine.plugin(collapse)"
- path: "resources/views/admin/members/index.blade.php"
provides: "Expandable note history panel with search"
contains: "toggleHistory"
- path: "app/Http/Controllers/Admin/MemberNoteController.php"
provides: "Notes endpoint with newest-first ordering and eager-loaded author"
contains: "latest"
- path: "tests/Feature/Admin/MemberNoteTest.php"
provides: "Tests verifying ordering, empty state, and search-related data"
key_links:
- from: "resources/views/admin/members/index.blade.php"
to: "GET /admin/members/{member}/notes"
via: "axios.get in Alpine.js toggleHistory() method"
pattern: "admin.members.notes.index"
- from: "resources/views/admin/members/index.blade.php"
to: "resources/js/app.js"
via: "Alpine.plugin(collapse) enables x-collapse directive"
pattern: "x-collapse"
- from: "resources/views/admin/members/index.blade.php"
to: "submitNote → notes.unshift"
via: "After note creation, new note injected into cached notes array"
pattern: "this.notes.unshift"
---
<objective>
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.
</objective>
<execution_context>
@/Users/gbanyan/.claude/get-shit-done/workflows/execute-plan.md
@/Users/gbanyan/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
</context>
<tasks>
<task type="auto">
<name>Task 1: Install collapse plugin, fix controller ordering, build expandable history panel with search</name>
<files>
package.json
resources/js/app.js
app/Http/Controllers/Admin/MemberNoteController.php
resources/views/admin/members/index.blade.php
</files>
<action>
**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()`:
```javascript
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:
```php
$notes = $member->notes()->with('author')->get();
```
To:
```php
$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:
```javascript
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:
```javascript
// 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>`:
```html
<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:
```html
@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.
</action>
<verify>
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
</verify>
<done>
1. Alpine.js collapse plugin installed and registered in app.js
2. Controller orders notes newest first with `->latest('created_at')`
3. Note count badge is clickable and toggles expansion panel
4. Expansion panel shows loading state, then notes with author and formatted datetime
5. Search input filters notes by content or author name
6. Empty state shows "尚無備註" for members with no notes
7. "找不到符合的備忘錄" shows when search has no matches
8. Closing panel resets search query
9. Adding a note via inline form updates the cached notes array
10. All existing tests still pass
</done>
</task>
<task type="auto">
<name>Task 2: Add feature tests for note history panel rendering and search behavior</name>
<files>
tests/Feature/Admin/MemberNoteTest.php
</files>
<action>
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
```php
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
```php
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-`
```php
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`
```php
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']);
}
```
</action>
<verify>
Run `php artisan test --filter=MemberNoteTest` — all tests pass (existing 7 + new 4 = 11 total).
</verify>
<done>
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)
</done>
</task>
</tasks>
<verification>
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)`
</verification>
<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>
<output>
After completion, create `.planning/phases/03-note-history-display/03-01-SUMMARY.md`
</output>