docs(03): create phase plan for note history display
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
This roadmap delivers inline note-taking capabilities for the Taiwan NPO admin member list, enabling quick annotation without page navigation. The implementation follows a foundation-first approach: database schema and backend API (Phase 1), followed by inline quick-add UI delivering core value (Phase 2), and concluding with full note history and display features (Phase 3). All work leverages the existing Laravel 10 + Alpine.js + Tailwind stack with zero new dependencies.
|
This roadmap delivers inline note-taking capabilities for the Taiwan NPO admin member list, enabling quick annotation without page navigation. The implementation follows a foundation-first approach: database schema and backend API (Phase 1), followed by inline quick-add UI delivering core value (Phase 2), and concluding with full note history and display features (Phase 3). All work leverages the existing Laravel 10 + Alpine.js + Tailwind stack (Phase 3 adds @alpinejs/collapse, the official Alpine.js collapse plugin).
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
|
|
||||||
@@ -73,10 +73,10 @@ Plans:
|
|||||||
4. Admin can filter/search notes by text content within a member's note history
|
4. Admin can filter/search notes by text content within a member's note history
|
||||||
5. Expanded panel collapses cleanly without affecting other member rows
|
5. Expanded panel collapses cleanly without affecting other member rows
|
||||||
|
|
||||||
**Plans**: TBD
|
**Plans:** 1 plan
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] TBD (will be defined in plan-phase)
|
- [ ] 03-01-PLAN.md — Expandable note history panel with search, collapse plugin, controller ordering fix, and feature tests
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ Phases execute in numeric order: 1 → 2 → 3
|
|||||||
|-------|----------------|--------|-----------|
|
|-------|----------------|--------|-----------|
|
||||||
| 1. Database Schema & Backend API | 2/2 | ✓ Complete | 2026-02-13 |
|
| 1. Database Schema & Backend API | 2/2 | ✓ Complete | 2026-02-13 |
|
||||||
| 2. Inline Quick-Add UI | 1/1 | ✓ Complete | 2026-02-13 |
|
| 2. Inline Quick-Add UI | 1/1 | ✓ Complete | 2026-02-13 |
|
||||||
| 3. Note History & Display | 0/TBD | Not started | - |
|
| 3. Note History & Display | 0/1 | Not started | - |
|
||||||
|
|
||||||
---
|
---
|
||||||
*Roadmap created: 2026-02-13*
|
*Roadmap created: 2026-02-13*
|
||||||
|
|||||||
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