docs(03): create phase plan for note history display
This commit is contained in:
469
.planning/phases/03-note-history-display/03-01-PLAN.md
Normal file
469
.planning/phases/03-note-history-display/03-01-PLAN.md
Normal 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">·</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>
|
||||
Reference in New Issue
Block a user