docs(02-inline-quick-add-ui): create phase plan
This commit is contained in:
378
.planning/phases/02-inline-quick-add-ui/02-01-PLAN.md
Normal file
378
.planning/phases/02-inline-quick-add-ui/02-01-PLAN.md
Normal file
@@ -0,0 +1,378 @@
|
||||
---
|
||||
phase: 02-inline-quick-add-ui
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- resources/views/admin/members/index.blade.php
|
||||
- tests/Feature/Admin/MemberNoteInlineUITest.php
|
||||
autonomous: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Each member row displays a note count badge with the number of notes"
|
||||
- "Admin can click a button to expand an inline note form within a member row"
|
||||
- "Submitting a note via the inline form does not reload the page (AJAX via axios)"
|
||||
- "After successful submission, the badge count increments and the form clears and closes"
|
||||
- "Submit button shows disabled/loading state during AJAX request"
|
||||
- "Validation errors from Laravel 422 display in Traditional Chinese below the textarea"
|
||||
- "All note UI elements render correctly in both light and dark mode"
|
||||
- "Inline note forms work independently across paginated member list pages"
|
||||
artifacts:
|
||||
- path: "resources/views/admin/members/index.blade.php"
|
||||
provides: "Member list with inline note form and badge per row"
|
||||
contains: "x-data.*noteFormOpen"
|
||||
- path: "tests/Feature/Admin/MemberNoteInlineUITest.php"
|
||||
provides: "Feature tests verifying Blade output includes Alpine.js note UI elements"
|
||||
contains: "notes_count"
|
||||
key_links:
|
||||
- from: "resources/views/admin/members/index.blade.php"
|
||||
to: "/admin/members/{member}/notes"
|
||||
via: "axios.post in Alpine.js submitNote() method"
|
||||
pattern: "axios\\.post.*admin/members"
|
||||
- from: "resources/views/admin/members/index.blade.php"
|
||||
to: "noteCount"
|
||||
via: "Alpine.js reactive x-text binding incremented on success"
|
||||
pattern: "x-text.*noteCount"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add inline note quick-add UI to the admin member list, delivering the core value: admins can annotate any member with a timestamped note directly from the member list without navigating away.
|
||||
|
||||
Purpose: This is the primary user-facing deliverable — the chairman can quickly jot notes on any member while reviewing the list. The backend API (Phase 1) is complete; this plan wires the UI to it.
|
||||
|
||||
Output: Modified member list Blade template with per-row Alpine.js inline forms, note count badges, AJAX submission, loading states, validation error display, and dark mode support. Plus feature tests verifying the UI elements render correctly.
|
||||
</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/01-database-schema-backend-api/01-02-SUMMARY.md
|
||||
@.planning/phases/02-inline-quick-add-ui/02-RESEARCH.md
|
||||
@resources/views/admin/members/index.blade.php
|
||||
@app/Http/Controllers/Admin/MemberNoteController.php
|
||||
@app/Http/Controllers/AdminMemberController.php
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add note count badge and Alpine.js inline note form to member list</name>
|
||||
<files>resources/views/admin/members/index.blade.php</files>
|
||||
<action>
|
||||
Modify the existing member list Blade template to add inline note-taking capability. The backend API is already complete (POST /admin/members/{member}/notes returns 201 JSON, validated by StoreNoteRequest with Chinese error messages). The controller already provides `notes_count` via `withCount('notes')`.
|
||||
|
||||
**Step 1: Add "備忘錄" column header** after the "狀態" (status) column header and before the "操作" (actions) column header:
|
||||
```html
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300">
|
||||
備忘錄
|
||||
</th>
|
||||
```
|
||||
|
||||
**Step 2: Wrap each `<tr>` inside the `@forelse` loop with Alpine.js x-data scope.** Change the existing `<tr>` to:
|
||||
```html
|
||||
<tr x-data="{
|
||||
noteFormOpen: false,
|
||||
noteContent: '',
|
||||
isSubmitting: false,
|
||||
errors: {},
|
||||
noteCount: {{ $member->notes_count ?? 0 }},
|
||||
async submitNote() {
|
||||
this.isSubmitting = true;
|
||||
this.errors = {};
|
||||
try {
|
||||
await axios.post('{{ route('admin.members.notes.store', $member) }}', {
|
||||
content: this.noteContent
|
||||
});
|
||||
this.noteCount++;
|
||||
this.noteContent = '';
|
||||
this.noteFormOpen = false;
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 422) {
|
||||
this.errors = error.response.data.errors || {};
|
||||
}
|
||||
} finally {
|
||||
this.isSubmitting = false;
|
||||
}
|
||||
}
|
||||
}">
|
||||
```
|
||||
|
||||
**Step 3: Add the note count badge + inline form cell** after the status `<td>` and before the actions `<td>`. This new `<td>` contains:
|
||||
1. A badge showing the note count (reactive via `x-text="noteCount"`)
|
||||
2. A toggle button to open/close the inline form
|
||||
3. The inline form itself (conditionally shown via `x-show="noteFormOpen"`)
|
||||
|
||||
```html
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Note count badge -->
|
||||
<span 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">
|
||||
<span x-text="noteCount"></span>
|
||||
</span>
|
||||
<!-- Toggle button -->
|
||||
<button
|
||||
@click="noteFormOpen = !noteFormOpen"
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400"
|
||||
:title="noteFormOpen ? '收合' : '新增備忘錄'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Inline note form -->
|
||||
<div x-show="noteFormOpen" x-cloak
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
class="mt-2">
|
||||
<form @submit.prevent="submitNote()">
|
||||
<textarea
|
||||
x-model="noteContent"
|
||||
rows="2"
|
||||
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"
|
||||
:class="{ 'border-red-500 dark:border-red-400': errors.content }"
|
||||
placeholder="輸入備忘錄..."
|
||||
></textarea>
|
||||
<p x-show="errors.content" x-text="errors.content?.[0]"
|
||||
class="mt-1 text-xs text-red-600 dark:text-red-400"></p>
|
||||
<div class="mt-1 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="noteFormOpen = false; noteContent = ''; errors = {};"
|
||||
class="inline-flex items-center rounded-md px-2.5 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isSubmitting || noteContent.trim() === ''"
|
||||
class="inline-flex items-center rounded-md border border-transparent bg-indigo-600 dark:bg-indigo-500 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 dark:hover:bg-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!isSubmitting">儲存</span>
|
||||
<span x-show="isSubmitting">儲存中...</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
```
|
||||
|
||||
**Step 4: Update the empty state `<td>` colspan** from `7` to `8` (since we added the 備忘錄 column).
|
||||
|
||||
**Step 5: Add `x-cloak` CSS support.** Add a `@push('styles')` block at the top of the template (after `<x-app-layout>`) with:
|
||||
```html
|
||||
@push('styles')
|
||||
<style>[x-cloak] { display: none !important; }</style>
|
||||
@endpush
|
||||
```
|
||||
Check if the layout already includes an x-cloak style. If it does, skip this step. If not, add it. This prevents flash of unstyled content on the inline form.
|
||||
|
||||
**Important implementation notes:**
|
||||
- The `noteCount` initializes from `{{ $member->notes_count ?? 0 }}` — this comes from `withCount('notes')` in the controller (already done in Phase 1)
|
||||
- The axios.post URL uses `{{ route('admin.members.notes.store', $member) }}` — this named route was registered in Phase 1
|
||||
- CSRF token is automatically included by axios (configured in bootstrap.js)
|
||||
- Each row has its own independent Alpine.js scope — pagination works because each page renders fresh x-data
|
||||
- The `x-cloak` directive ensures the form is hidden until Alpine initializes
|
||||
- Dark mode is covered by `dark:` prefixed Tailwind classes on every element
|
||||
- The submit button is disabled when `isSubmitting` is true OR `noteContent` is empty (prevents submitting blank notes)
|
||||
- The cancel button clears content, closes form, and resets errors
|
||||
</action>
|
||||
<verify>
|
||||
1. Run `php artisan route:list | grep notes` to confirm the note routes exist (from Phase 1)
|
||||
2. Open the member list view file and verify:
|
||||
- x-data with noteFormOpen, noteContent, isSubmitting, errors, noteCount on each `<tr>`
|
||||
- submitNote() async method calls axios.post to the correct route
|
||||
- Note count badge with `x-text="noteCount"`
|
||||
- Form with `@submit.prevent="submitNote()"`
|
||||
- Textarea with `x-model="noteContent"` and dark mode classes
|
||||
- Error display with `x-show="errors.content"` and `x-text="errors.content?.[0]"`
|
||||
- Submit button with `:disabled="isSubmitting || noteContent.trim() === ''"`
|
||||
- Loading text toggle between 儲存 and 儲存中...
|
||||
- Cancel button that resets state
|
||||
- colspan updated to 8 on empty state row
|
||||
3. Run `php artisan view:cache` and `php artisan view:clear` to verify the Blade template compiles without errors
|
||||
</verify>
|
||||
<done>
|
||||
Member list Blade template includes per-row Alpine.js inline note form with: badge showing reactive note count, toggle button to expand form, textarea with dark mode support, AJAX submission via axios with CSRF, loading state on submit button, validation error display in Chinese, cancel button that resets state, and correct colspan on empty row.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add feature tests verifying inline note UI renders correctly</name>
|
||||
<files>tests/Feature/Admin/MemberNoteInlineUITest.php</files>
|
||||
<action>
|
||||
Create a feature test class that verifies the member list Blade template includes the necessary Alpine.js note UI elements. These are server-side rendering tests — they verify the HTML output contains the right Alpine directives and data, not that JavaScript executes (that would be a browser test).
|
||||
|
||||
Create `tests/Feature/Admin/MemberNoteInlineUITest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Admin;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\Note;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MemberNoteInlineUITest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->artisan('db:seed', ['--class' => 'RoleSeeder']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function member_list_renders_note_count_badge_for_each_member()
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin');
|
||||
|
||||
$member = Member::factory()->create();
|
||||
|
||||
// Create 3 notes for this member
|
||||
Note::factory()->count(3)->forMember($member)->byAuthor($admin)->create();
|
||||
|
||||
$response = $this->actingAs($admin)->get(route('admin.members.index'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
// Verify the Alpine.js noteCount is initialized with 3
|
||||
$response->assertSee('noteCount: 3', false);
|
||||
// Verify badge rendering element exists
|
||||
$response->assertSee('x-text="noteCount"', false);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function member_list_renders_inline_note_form_with_alpine_directives()
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin');
|
||||
|
||||
Member::factory()->create();
|
||||
|
||||
$response = $this->actingAs($admin)->get(route('admin.members.index'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
// Verify Alpine.js form directives are present
|
||||
$response->assertSee('noteFormOpen', false);
|
||||
$response->assertSee('@submit.prevent="submitNote()"', false);
|
||||
$response->assertSee('x-model="noteContent"', false);
|
||||
$response->assertSee(':disabled="isSubmitting', false);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function member_list_renders_note_column_header()
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin');
|
||||
|
||||
$response = $this->actingAs($admin)->get(route('admin.members.index'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('備忘錄', false);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function member_with_zero_notes_shows_zero_count()
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin');
|
||||
|
||||
Member::factory()->create();
|
||||
|
||||
$response = $this->actingAs($admin)->get(route('admin.members.index'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('noteCount: 0', false);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function member_list_includes_correct_note_store_route_for_each_member()
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin');
|
||||
|
||||
$member = Member::factory()->create();
|
||||
|
||||
$response = $this->actingAs($admin)->get(route('admin.members.index'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
// Verify the axios.post URL contains the correct member route
|
||||
$expectedRoute = route('admin.members.notes.store', $member);
|
||||
$response->assertSee($expectedRoute, false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Test coverage rationale:**
|
||||
1. `member_list_renders_note_count_badge_for_each_member` — Verifies noteCount initialization from withCount and badge element exists (DISP-01)
|
||||
2. `member_list_renders_inline_note_form_with_alpine_directives` — Verifies Alpine.js form directives are in the HTML (NOTE-01, NOTE-03, UI-03)
|
||||
3. `member_list_renders_note_column_header` — Verifies 備忘錄 column header exists (UI-01)
|
||||
4. `member_with_zero_notes_shows_zero_count` — Edge case: zero notes shows badge with 0
|
||||
5. `member_list_includes_correct_note_store_route_for_each_member` — Verifies AJAX URL targets correct endpoint per member (NOTE-03, key link)
|
||||
|
||||
Use the same test patterns as existing `MemberNoteTest.php` (setUp with RoleSeeder, actingAs admin, assertSee).
|
||||
</action>
|
||||
<verify>
|
||||
Run: `php artisan test --filter=MemberNoteInlineUITest`
|
||||
|
||||
All 5 tests must pass. Expected output:
|
||||
```
|
||||
PASS Tests\Feature\Admin\MemberNoteInlineUITest
|
||||
✓ member list renders note count badge for each member
|
||||
✓ member list renders inline note form with alpine directives
|
||||
✓ member list renders note column header
|
||||
✓ member with zero notes shows zero count
|
||||
✓ member list includes correct note store route for each member
|
||||
```
|
||||
|
||||
Also run: `php artisan test --filter=MemberNoteTest` to verify no regressions in the existing Phase 1 tests (all 7 should still pass).
|
||||
</verify>
|
||||
<done>
|
||||
5 feature tests pass verifying: note count badge renders with correct count from withCount, Alpine.js directives (noteFormOpen, submitNote, x-model, :disabled) present in HTML, 備忘錄 column header renders, zero-note members show 0 count, and correct note store route URL embedded for each member. No regressions in Phase 1 tests.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `php artisan test --filter=MemberNoteInlineUITest` — All 5 new tests pass
|
||||
2. `php artisan test --filter=MemberNoteTest` — All 7 Phase 1 tests still pass (no regressions)
|
||||
3. `php artisan view:clear && php artisan view:cache` — Blade template compiles without errors
|
||||
4. `php artisan route:list | grep notes` — Note routes exist and are accessible
|
||||
5. Manual review of `resources/views/admin/members/index.blade.php`:
|
||||
- Every `dark:` prefix class has a corresponding light-mode class
|
||||
- x-cloak on the inline form div
|
||||
- @submit.prevent (not @submit) on the form
|
||||
- :disabled binding on submit button
|
||||
- Error display with x-show and x-text
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. Member list page at /admin/members renders with a "備忘錄" column containing per-row note count badges
|
||||
2. Each badge displays the correct note count (from withCount, no N+1 queries)
|
||||
3. Each row has an expand button that reveals an inline note form (Alpine.js x-show)
|
||||
4. The inline form includes textarea, cancel button, and submit button with loading state
|
||||
5. Axios POST URL correctly targets /admin/members/{member}/notes for each row
|
||||
6. Validation error display element exists with x-show binding to errors.content
|
||||
7. All Tailwind classes include dark: equivalents for dark mode
|
||||
8. 12 total tests pass (5 new UI + 7 existing API) with zero regressions
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-inline-quick-add-ui/02-01-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user