- Wire GenerateMenuButton component into WorkspaceToolbar.vue - Update WorkspaceToolbar.test.ts to open dropdown before clicking menu items - Update WorkspaceView.test.ts to open dropdown before clicking generate buttons Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
437 lines
16 KiB
Markdown
437 lines
16 KiB
Markdown
# Merge Generate 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 "批量生成" / "生成一篇" toolbar buttons with a single "生成教案 ▾" button that opens a dropdown menu offering both actions.
|
|
|
|
**Architecture:** Extract a new self-contained Vue component `GenerateMenuButton.vue` that owns the open/closed state and outside-click/Escape dismissal, and emits the same `generate` / `batchGenerate` events the toolbar already emits today. `WorkspaceToolbar.vue` swaps its two `<button>` elements for this component and forwards its events unchanged, so `WorkspaceView.vue` requires no changes at all.
|
|
|
|
**Tech Stack:** Vue 3 (`<script setup lang="ts">`), Vitest + `@vue/test-utils` for component tests, plain CSS (no UI component library) in `src/style.css`.
|
|
|
|
## Global Constraints
|
|
|
|
- Button label for the merged button is exactly "生成教案 ▾" (text "生成教案" + a down-caret).
|
|
- Clicking the main button only toggles the dropdown; it never directly fires `generate` or `batchGenerate`.
|
|
- The two menu item buttons keep their existing `data-testid` values: `data-testid="generate"` and `data-testid="batch-generate"`.
|
|
- The new toggle button uses `data-testid="generate-menu-toggle"`.
|
|
- `WorkspaceToolbar`'s public `defineEmits` (`generate`, `batchGenerate`, plus the other existing events) and `WorkspaceView.vue`'s `@generate` / `@batch-generate` listeners must NOT change.
|
|
- Reuse existing CSS design tokens (`var(--line)`, `var(--radius-md)`, `var(--green-100)`, `var(--green-600)`) — do not introduce new color/radius values.
|
|
- No changes to `BatchGenerateDialog.vue`, `GenerateLessonDialog.vue`, or any generation/API logic.
|
|
|
|
---
|
|
|
|
### Task 1: Create `GenerateMenuButton.vue` with tests
|
|
|
|
**Files:**
|
|
- Create: `src/components/GenerateMenuButton.vue`
|
|
- Create: `src/components/GenerateMenuButton.test.ts`
|
|
|
|
**Interfaces:**
|
|
- Produces: `GenerateMenuButton` component with `defineEmits<{ generate: []; batchGenerate: [] }>()`. No props. Default export is the `.vue` SFC, imported as `import GenerateMenuButton from './GenerateMenuButton.vue'`.
|
|
- DOM contract later tasks rely on:
|
|
- Toggle button: `button[data-testid="generate-menu-toggle"]`
|
|
- Menu item buttons (only present in DOM while menu is open): `button[data-testid="generate"]`, `button[data-testid="batch-generate"]`
|
|
- Menu list root: `ul.generate-menu-list`
|
|
- Component root wrapper: `div.generate-menu`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `src/components/GenerateMenuButton.test.ts`:
|
|
|
|
```ts
|
|
import { mount } from '@vue/test-utils'
|
|
import { describe, expect, it } from 'vitest'
|
|
import GenerateMenuButton from './GenerateMenuButton.vue'
|
|
|
|
describe('GenerateMenuButton', () => {
|
|
it('renders the toggle button with the menu closed by default', () => {
|
|
const wrapper = mount(GenerateMenuButton, { attachTo: document.body })
|
|
expect(wrapper.get('button[data-testid="generate-menu-toggle"]').text()).toContain('生成教案')
|
|
expect(wrapper.find('[data-testid="generate"]').exists()).toBe(false)
|
|
expect(wrapper.find('[data-testid="batch-generate"]').exists()).toBe(false)
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('opens the menu when the toggle button is clicked', async () => {
|
|
const wrapper = mount(GenerateMenuButton, { attachTo: document.body })
|
|
await wrapper.get('button[data-testid="generate-menu-toggle"]').trigger('click')
|
|
expect(wrapper.get('[data-testid="generate"]').isVisible()).toBe(true)
|
|
expect(wrapper.get('[data-testid="batch-generate"]').isVisible()).toBe(true)
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('emits generate and closes the menu when "生成一篇" is clicked', async () => {
|
|
const wrapper = mount(GenerateMenuButton, { attachTo: document.body })
|
|
await wrapper.get('button[data-testid="generate-menu-toggle"]').trigger('click')
|
|
await wrapper.get('button[data-testid="generate"]').trigger('click')
|
|
expect(wrapper.emitted('generate')).toHaveLength(1)
|
|
expect(wrapper.find('[data-testid="generate"]').exists()).toBe(false)
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('emits batchGenerate and closes the menu when "批量生成" is clicked', async () => {
|
|
const wrapper = mount(GenerateMenuButton, { attachTo: document.body })
|
|
await wrapper.get('button[data-testid="generate-menu-toggle"]').trigger('click')
|
|
await wrapper.get('button[data-testid="batch-generate"]').trigger('click')
|
|
expect(wrapper.emitted('batchGenerate')).toHaveLength(1)
|
|
expect(wrapper.find('[data-testid="batch-generate"]').exists()).toBe(false)
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('closes the menu when clicking outside the component', async () => {
|
|
const wrapper = mount(GenerateMenuButton, { attachTo: document.body })
|
|
await wrapper.get('button[data-testid="generate-menu-toggle"]').trigger('click')
|
|
expect(wrapper.find('[data-testid="generate"]').exists()).toBe(true)
|
|
|
|
document.body.click()
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(wrapper.find('[data-testid="generate"]').exists()).toBe(false)
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('closes the menu when Escape is pressed', async () => {
|
|
const wrapper = mount(GenerateMenuButton, { attachTo: document.body })
|
|
await wrapper.get('button[data-testid="generate-menu-toggle"]').trigger('click')
|
|
expect(wrapper.find('[data-testid="generate"]').exists()).toBe(true)
|
|
|
|
await wrapper.get('div.generate-menu').trigger('keydown', { key: 'Escape' })
|
|
|
|
expect(wrapper.find('[data-testid="generate"]').exists()).toBe(false)
|
|
wrapper.unmount()
|
|
})
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run: `npx vitest run src/components/GenerateMenuButton.test.ts`
|
|
Expected: FAIL — `Failed to resolve import "./GenerateMenuButton.vue"` (file doesn't exist yet).
|
|
|
|
- [ ] **Step 3: Write the component implementation**
|
|
|
|
Create `src/components/GenerateMenuButton.vue`:
|
|
|
|
```vue
|
|
<script setup lang="ts">
|
|
import { onMounted, onUnmounted, ref } from 'vue'
|
|
|
|
const emit = defineEmits<{
|
|
generate: []
|
|
batchGenerate: []
|
|
}>()
|
|
|
|
const open = ref(false)
|
|
const rootRef = ref<HTMLElement | null>(null)
|
|
|
|
function toggle(): void {
|
|
open.value = !open.value
|
|
}
|
|
|
|
function close(): void {
|
|
open.value = false
|
|
}
|
|
|
|
function select(action: 'generate' | 'batchGenerate'): void {
|
|
emit(action)
|
|
close()
|
|
}
|
|
|
|
function handleDocumentClick(event: MouseEvent): void {
|
|
if (!rootRef.value) return
|
|
if (!rootRef.value.contains(event.target as Node)) {
|
|
close()
|
|
}
|
|
}
|
|
|
|
function handleKeydown(event: KeyboardEvent): void {
|
|
if (event.key === 'Escape') {
|
|
close()
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('click', handleDocumentClick)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('click', handleDocumentClick)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="rootRef" class="generate-menu" @keydown="handleKeydown">
|
|
<button
|
|
type="button"
|
|
data-testid="generate-menu-toggle"
|
|
:aria-expanded="open"
|
|
@click.stop="toggle"
|
|
>
|
|
生成教案 ▾
|
|
</button>
|
|
<ul v-if="open" class="generate-menu-list" role="menu">
|
|
<li role="menuitem">
|
|
<button type="button" data-testid="generate" @click="select('generate')">生成一篇</button>
|
|
</li>
|
|
<li role="menuitem">
|
|
<button type="button" data-testid="batch-generate" @click="select('batchGenerate')">
|
|
批量生成
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
Note: `@click.stop` on the toggle button prevents the same click that opens the menu from also being seen by `handleDocumentClick` as an "outside" click (the document listener is registered on `document`, and Vue's synthetic click on the button would otherwise bubble to it in the same tick before `open` is read — `.stop` keeps behavior deterministic regardless of listener order).
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run: `npx vitest run src/components/GenerateMenuButton.test.ts`
|
|
Expected: PASS (6 tests).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/components/GenerateMenuButton.vue src/components/GenerateMenuButton.test.ts
|
|
git commit -m "feat: add GenerateMenuButton dropdown component"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Wire `GenerateMenuButton` into `WorkspaceToolbar.vue`
|
|
|
|
**Files:**
|
|
- Modify: `src/components/WorkspaceToolbar.vue:1-32`
|
|
- Modify: `src/components/WorkspaceToolbar.test.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `GenerateMenuButton` from Task 1, with events `generate` and `batchGenerate`, and DOM testids `generate-menu-toggle`, `generate`, `batch-generate` (only present once toggled open).
|
|
- Produces: `WorkspaceToolbar` keeps emitting `generate` and `batchGenerate` exactly as before — no change to its own `defineEmits` block or to how `WorkspaceView.vue` listens to it.
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Update `src/components/WorkspaceToolbar.test.ts` — replace the existing `generate`-related tests (lines 17-21 and 29-33) with versions that open the dropdown first:
|
|
|
|
```ts
|
|
import { mount } from '@vue/test-utils'
|
|
import { describe, expect, it } from 'vitest'
|
|
import WorkspaceToolbar from './WorkspaceToolbar.vue'
|
|
|
|
function mountToolbar(lessonCount: number): ReturnType<typeof mount> {
|
|
return mount(WorkspaceToolbar, {
|
|
props: { lessonCount, warningCount: 0, saveStatus: 'idle' },
|
|
})
|
|
}
|
|
|
|
describe('WorkspaceToolbar', () => {
|
|
it('renders the lesson count', () => {
|
|
const wrapper = mountToolbar(3)
|
|
expect(wrapper.text()).toContain('共 3 课')
|
|
})
|
|
|
|
it('emits generate when the generate menu item is clicked', async () => {
|
|
const wrapper = mountToolbar(3)
|
|
await wrapper.get('button[data-testid="generate-menu-toggle"]').trigger('click')
|
|
await wrapper.get('button[data-testid="generate"]').trigger('click')
|
|
expect(wrapper.emitted('generate')).toHaveLength(1)
|
|
})
|
|
|
|
it('emits batchGenerate when the batch-generate menu item is clicked', async () => {
|
|
const wrapper = mountToolbar(3)
|
|
await wrapper.get('button[data-testid="generate-menu-toggle"]').trigger('click')
|
|
await wrapper.get('button[data-testid="batch-generate"]').trigger('click')
|
|
expect(wrapper.emitted('batchGenerate')).toHaveLength(1)
|
|
})
|
|
|
|
it('emits back when the back button is clicked', async () => {
|
|
const wrapper = mountToolbar(0)
|
|
await wrapper.get('button[data-testid="back"]').trigger('click')
|
|
expect(wrapper.emitted('back')).toHaveLength(1)
|
|
})
|
|
|
|
it('keeps the generate menu toggle and back button enabled even with no lessons', () => {
|
|
const wrapper = mountToolbar(0)
|
|
expect(
|
|
wrapper.get('button[data-testid="generate-menu-toggle"]').attributes('disabled'),
|
|
).toBeUndefined()
|
|
expect(wrapper.get('button[data-testid="back"]').attributes('disabled')).toBeUndefined()
|
|
})
|
|
|
|
it('disables print, export and clear when there are no lessons', () => {
|
|
const wrapper = mountToolbar(0)
|
|
expect(wrapper.get('button[data-testid="print"]').attributes('disabled')).toBeDefined()
|
|
expect(wrapper.get('button[data-testid="export"]').attributes('disabled')).toBeDefined()
|
|
expect(wrapper.get('button[data-testid="clear"]').attributes('disabled')).toBeDefined()
|
|
})
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run: `npx vitest run src/components/WorkspaceToolbar.test.ts`
|
|
Expected: FAIL — `generate-menu-toggle` testid not found (toolbar still has the old two buttons).
|
|
|
|
- [ ] **Step 3: Update the toolbar template**
|
|
|
|
In `src/components/WorkspaceToolbar.vue`, add the import at the top of the `<script setup>` block (after the existing `SaveStatus` import on line 2):
|
|
|
|
```ts
|
|
import GenerateMenuButton from './GenerateMenuButton.vue'
|
|
```
|
|
|
|
Replace lines 31-32:
|
|
|
|
```vue
|
|
<button type="button" data-testid="batch-generate" @click="$emit('batchGenerate')">批量生成</button>
|
|
<button type="button" data-testid="generate" @click="$emit('generate')">生成一篇</button>
|
|
```
|
|
|
|
with:
|
|
|
|
```vue
|
|
<GenerateMenuButton @generate="$emit('generate')" @batch-generate="$emit('batchGenerate')" />
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run: `npx vitest run src/components/WorkspaceToolbar.test.ts`
|
|
Expected: PASS (6 tests).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/components/WorkspaceToolbar.vue src/components/WorkspaceToolbar.test.ts
|
|
git commit -m "feat: merge generate buttons into a single dropdown in WorkspaceToolbar"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Update `WorkspaceView.test.ts` for the new dropdown flow
|
|
|
|
**Files:**
|
|
- Modify: `src/components/WorkspaceView.test.ts:95` and `:131`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `data-testid="generate-menu-toggle"` from `GenerateMenuButton` (Task 1), rendered inside `WorkspaceToolbar` (Task 2) which is rendered inside `WorkspaceView`.
|
|
|
|
- [ ] **Step 1: Run the existing suite to confirm the current failure**
|
|
|
|
Run: `npx vitest run src/components/WorkspaceView.test.ts`
|
|
Expected: FAIL on the two tests that click `[data-testid="generate"]` / `[data-testid="batch-generate"]` directly, because those buttons are no longer in the DOM until the dropdown is opened (`TestingLibraryElementError`-style "unable to find" error from `wrapper.get`).
|
|
|
|
- [ ] **Step 2: Update the two call sites**
|
|
|
|
In `src/components/WorkspaceView.test.ts`, change line 95 from:
|
|
|
|
```ts
|
|
await wrapper.get('[data-testid="generate"]').trigger('click')
|
|
```
|
|
|
|
to:
|
|
|
|
```ts
|
|
await wrapper.get('[data-testid="generate-menu-toggle"]').trigger('click')
|
|
await wrapper.get('[data-testid="generate"]').trigger('click')
|
|
```
|
|
|
|
And change line 131 from:
|
|
|
|
```ts
|
|
await wrapper.get('[data-testid="batch-generate"]').trigger('click')
|
|
```
|
|
|
|
to:
|
|
|
|
```ts
|
|
await wrapper.get('[data-testid="generate-menu-toggle"]').trigger('click')
|
|
await wrapper.get('[data-testid="batch-generate"]').trigger('click')
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests to verify they pass**
|
|
|
|
Run: `npx vitest run src/components/WorkspaceView.test.ts`
|
|
Expected: PASS (all tests in the file, including the two updated ones).
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/components/WorkspaceView.test.ts
|
|
git commit -m "test: open the generate dropdown before clicking generate actions in WorkspaceView tests"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Style the dropdown menu
|
|
|
|
**Files:**
|
|
- Modify: `src/style.css`
|
|
|
|
**Interfaces:**
|
|
- Consumes: existing design tokens `var(--line)`, `var(--radius-md)`, `var(--green-100)`, `var(--green-600)` (already defined elsewhere in `src/style.css`); existing selector `.workspace-toolbar button` (`src/style.css:251-264`) which still applies to the toggle button and menu item buttons since they are descendants of `.workspace-toolbar`.
|
|
- Produces: `.generate-menu`, `.generate-menu-list` classes used by `GenerateMenuButton.vue` (Task 1).
|
|
|
|
- [ ] **Step 1: Add the dropdown styles**
|
|
|
|
In `src/style.css`, immediately after the `.workspace-toolbar button:disabled` rule (after line 271, before the `.workspace-toolbar-count` block at line 273), insert:
|
|
|
|
```css
|
|
.generate-menu {
|
|
position: relative;
|
|
display: inline-flex;
|
|
}
|
|
|
|
.generate-menu-list {
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
margin: 4px 0 0;
|
|
padding: 4px;
|
|
min-width: 120px;
|
|
list-style: none;
|
|
background: #fff;
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius-md);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
|
z-index: 10;
|
|
}
|
|
|
|
.generate-menu-list button {
|
|
display: block;
|
|
width: 100%;
|
|
border: none;
|
|
background: transparent;
|
|
border-radius: var(--radius-md);
|
|
padding: 8px 12px;
|
|
text-align: left;
|
|
color: var(--green-700);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.generate-menu-list button:hover {
|
|
background: var(--green-100);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Make the dropdown wrapper behave like a toolbar button at narrow widths**
|
|
|
|
In `src/style.css`, inside the existing `@media (max-width: 600px)` block (`src/style.css:729-745`), add a rule next to `.workspace-toolbar button { flex: 0 0 auto; }`:
|
|
|
|
```css
|
|
.workspace-toolbar .generate-menu {
|
|
flex: 0 0 auto;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run the full test suite to confirm no regressions**
|
|
|
|
Run: `npx vitest run`
|
|
Expected: PASS — all existing suites (including `WorkspaceToolbar.test.ts`, `WorkspaceView.test.ts`, `GenerateMenuButton.test.ts`) still pass. CSS changes don't affect Vitest/jsdom assertions, this is a safety check that nothing else broke.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/style.css
|
|
git commit -m "style: add dropdown styling for the merged generate menu button"
|
|
```
|