diff --git a/docs/superpowers/plans/2026-06-22-merge-print-export-buttons.md b/docs/superpowers/plans/2026-06-22-merge-print-export-buttons.md new file mode 100644 index 0000000..fafa15e --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-merge-print-export-buttons.md @@ -0,0 +1,662 @@ +# Merge Print/Export Buttons Into Dropdown Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the two separate "打印整册" / "导出 MD" toolbar buttons with a single "导出 ▾" button that opens a dropdown menu offering both actions, and extract the dropdown logic that's now used by two buttons into a shared `ToolbarMenuButton.vue` component. + +**Architecture:** Extract `ToolbarMenuButton.vue` — a generic dropdown wrapper that owns open/close state, outside-click/Escape dismissal, and `disabled` handling, exposing menu items via a scoped default slot (`{ close }`). Refactor the existing `GenerateMenuButton.vue` to be a thin wrapper around it (no behavior change, same public DOM contract). Add a new `ExportMenuButton.vue`, also a thin wrapper, for "打印整册"/"导出 MD". `WorkspaceToolbar.vue` swaps its two standalone buttons for `ExportMenuButton`; `WorkspaceView.vue` requires no changes. + +**Tech Stack:** Vue 3 (` + + +``` + +Note: a native `disabled` button never dispatches `click` events, so `toggle()` cannot run while `disabled` is true — no extra guard needed in `toggle()` itself. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/components/ToolbarMenuButton.test.ts` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/components/ToolbarMenuButton.vue src/components/ToolbarMenuButton.test.ts +git commit -m "feat: add generic ToolbarMenuButton dropdown component" +``` + +--- + +### Task 2: Refactor `GenerateMenuButton.vue` to use `ToolbarMenuButton` + +**Files:** +- Modify: `src/components/GenerateMenuButton.vue` (full rewrite, ~21 lines) +- Modify: `src/components/GenerateMenuButton.test.ts:54` (one assertion) + +**Interfaces:** +- Consumes: `ToolbarMenuButton` from Task 1 — props `label`, `toggleTestid`, `disabled?`; default slot scope `{ close }`. +- Produces: `GenerateMenuButton` keeps emitting `generate` / `batchGenerate` exactly as before, with identical DOM contract (`generate-menu-toggle`, `generate`, `batch-generate`, label "生成教案 ▾"). No prior consumer of `GenerateMenuButton` (i.e. `WorkspaceToolbar.vue`) needs to change. + +This task is a pure refactor: the existing `GenerateMenuButton.test.ts` (6 tests, unchanged behavior asserted through testids) must still pass except for the one assertion that inspects the internal root class name. + +- [ ] **Step 1: Update the one test assertion that touches internal implementation** + +In `src/components/GenerateMenuButton.test.ts`, line 54 currently reads: + +```ts + await wrapper.get('div.generate-menu').trigger('keydown', { key: 'Escape' }) +``` + +Change it to: + +```ts + await wrapper.get('div.toolbar-menu').trigger('keydown', { key: 'Escape' }) +``` + +- [ ] **Step 2: Run the existing test to verify it fails for the expected reason** + +Run: `npx vitest run src/components/GenerateMenuButton.test.ts` +Expected: FAIL on `closes the menu when Escape is pressed` — `div.toolbar-menu` does not exist yet (component still renders `div.generate-menu`). The other 5 tests still pass at this point since the component hasn't changed yet. + +- [ ] **Step 3: Rewrite the component to wrap `ToolbarMenuButton`** + +Replace the full contents of `src/components/GenerateMenuButton.vue` with: + +```vue + + + +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/components/GenerateMenuButton.test.ts` +Expected: PASS (6 tests) — identical behavior, now backed by `ToolbarMenuButton`. + +Also run the consumers to confirm no regression: +Run: `npx vitest run src/components/WorkspaceToolbar.test.ts src/components/WorkspaceView.test.ts` +Expected: same pass/fail counts as before this task (the 3 pre-existing unrelated `WorkspaceView.test.ts` failures about file-upload placeholder text and batch-generate concurrency ordering are unaffected; `WorkspaceToolbar.test.ts` fully passes). + +- [ ] **Step 5: Commit** + +```bash +git add src/components/GenerateMenuButton.vue src/components/GenerateMenuButton.test.ts +git commit -m "refactor: rebuild GenerateMenuButton on top of ToolbarMenuButton" +``` + +--- + +### Task 3: Create `ExportMenuButton.vue` with tests + +**Files:** +- Create: `src/components/ExportMenuButton.vue` +- Create: `src/components/ExportMenuButton.test.ts` + +**Interfaces:** +- Consumes: `ToolbarMenuButton` from Task 1. +- Produces: `ExportMenuButton` component: + ```ts + defineProps<{ disabled?: boolean }>() + defineEmits<{ print: []; export: [] }>() + ``` + DOM contract: toggle `button[data-testid="export-menu-toggle"]` with label "导出 ▾"; menu items `button[data-testid="print"]` ("打印整册") and `button[data-testid="export"]` ("导出 MD"), only present while the dropdown is open. + +- [ ] **Step 1: Write the failing tests** + +Create `src/components/ExportMenuButton.test.ts`: + +```ts +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import ExportMenuButton from './ExportMenuButton.vue' + +describe('ExportMenuButton', () => { + it('renders the toggle button with the menu closed by default', () => { + const wrapper = mount(ExportMenuButton, { attachTo: document.body }) + expect(wrapper.get('button[data-testid="export-menu-toggle"]').text()).toContain('导出') + expect(wrapper.find('[data-testid="print"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="export"]').exists()).toBe(false) + wrapper.unmount() + }) + + it('emits print and closes the menu when "打印整册" is clicked', async () => { + const wrapper = mount(ExportMenuButton, { attachTo: document.body }) + await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click') + await wrapper.get('button[data-testid="print"]').trigger('click') + expect(wrapper.emitted('print')).toHaveLength(1) + expect(wrapper.find('[data-testid="print"]').exists()).toBe(false) + wrapper.unmount() + }) + + it('emits export and closes the menu when "导出 MD" is clicked', async () => { + const wrapper = mount(ExportMenuButton, { attachTo: document.body }) + await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click') + await wrapper.get('button[data-testid="export"]').trigger('click') + expect(wrapper.emitted('export')).toHaveLength(1) + expect(wrapper.find('[data-testid="export"]').exists()).toBe(false) + wrapper.unmount() + }) + + it('disables the toggle button when disabled prop is true', () => { + const wrapper = mount(ExportMenuButton, { + props: { disabled: true }, + attachTo: document.body, + }) + expect( + wrapper.get('button[data-testid="export-menu-toggle"]').attributes('disabled'), + ).toBeDefined() + wrapper.unmount() + }) + + it('keeps the toggle button enabled when disabled prop is false', () => { + const wrapper = mount(ExportMenuButton, { + props: { disabled: false }, + attachTo: document.body, + }) + expect( + wrapper.get('button[data-testid="export-menu-toggle"]').attributes('disabled'), + ).toBeUndefined() + wrapper.unmount() + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/components/ExportMenuButton.test.ts` +Expected: FAIL — `Failed to resolve import "./ExportMenuButton.vue"` (file doesn't exist yet). + +- [ ] **Step 3: Write the component implementation** + +Create `src/components/ExportMenuButton.vue`: + +```vue + + + +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/components/ExportMenuButton.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/components/ExportMenuButton.vue src/components/ExportMenuButton.test.ts +git commit -m "feat: add ExportMenuButton dropdown component" +``` + +--- + +### Task 4: Wire `ExportMenuButton` into `WorkspaceToolbar.vue` + +**Files:** +- Modify: `src/components/WorkspaceToolbar.vue` +- Modify: `src/components/WorkspaceToolbar.test.ts` +- Modify: `src/components/WorkspaceView.test.ts:207` + +**Interfaces:** +- Consumes: `ExportMenuButton` from Task 3 — props `disabled?`, events `print`/`export`, testids `export-menu-toggle`/`print`/`export`. +- Produces: `WorkspaceToolbar` keeps emitting `print` and `export` exactly as before — no change to its own `defineEmits` block or to how `WorkspaceView.vue` listens to it. + +- [ ] **Step 1: Write the failing tests** + +In `src/components/WorkspaceToolbar.test.ts`, replace the `disables print, export and clear when there are no lessons` test (currently the last test in the file, asserting on `data-testid="print"` / `"export"` directly) with: + +```ts + it('disables the export menu toggle and clear button when there are no lessons', () => { + const wrapper = mountToolbar(0) + expect( + wrapper.get('button[data-testid="export-menu-toggle"]').attributes('disabled'), + ).toBeDefined() + expect(wrapper.get('button[data-testid="clear"]').attributes('disabled')).toBeDefined() + }) + + it('emits print when the print menu item is clicked', async () => { + const wrapper = mountToolbar(3) + await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click') + await wrapper.get('button[data-testid="print"]').trigger('click') + expect(wrapper.emitted('print')).toHaveLength(1) + }) + + it('emits export when the export menu item is clicked', async () => { + const wrapper = mountToolbar(3) + await wrapper.get('button[data-testid="export-menu-toggle"]').trigger('click') + await wrapper.get('button[data-testid="export"]').trigger('click') + expect(wrapper.emitted('export')).toHaveLength(1) + }) +``` + +(Keep every other existing test in the file unchanged.) + +In `src/components/WorkspaceView.test.ts`, line 207 currently reads: + +```ts + await wrapper.get('[data-testid="export"]').trigger('click') +``` + +Change it to: + +```ts + await wrapper.get('[data-testid="export-menu-toggle"]').trigger('click') + await wrapper.get('[data-testid="export"]').trigger('click') +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/components/WorkspaceToolbar.test.ts src/components/WorkspaceView.test.ts` +Expected: FAIL — `export-menu-toggle` testid not found (toolbar still has the old two standalone buttons). + +- [ ] **Step 3: Update the toolbar template** + +In `src/components/WorkspaceToolbar.vue`, add the import in `