Files
teaching-design/docs/superpowers/plans/2026-06-22-merge-generate-buttons.md
yuetsh 2a647ee1ad feat: merge generate buttons into a single dropdown in WorkspaceToolbar
- 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>
2026-06-22 18:57:23 -06:00

16 KiB

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:

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:

<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
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:

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):

import GenerateMenuButton from './GenerateMenuButton.vue'

Replace lines 31-32:

    <button type="button" data-testid="batch-generate" @click="$emit('batchGenerate')">批量生成</button>
    <button type="button" data-testid="generate" @click="$emit('generate')">生成一篇</button>

with:

    <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
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:

    await wrapper.get('[data-testid="generate"]').trigger('click')

to:

    await wrapper.get('[data-testid="generate-menu-toggle"]').trigger('click')
    await wrapper.get('[data-testid="generate"]').trigger('click')

And change line 131 from:

    await wrapper.get('[data-testid="batch-generate"]').trigger('click')

to:

    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
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:

.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; }:

  .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
git add src/style.css
git commit -m "style: add dropdown styling for the merged generate menu button"