mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
## Summary
Adds bulk asset export with ZIP download for cloud users. When selecting
2+ assets and clicking download, the frontend now requests a server-side
ZIP export instead of triggering individual file downloads.
## Changes
### New files
- **`AssetExportProgressDialog.vue`** — HoneyToast-based progress dialog
showing per-job export status with progress percentages, error
indicators, and a manual re-download button for completed exports
- **`assetExportStore.ts`** — Pinia store that tracks export jobs,
handles `asset_export` WebSocket events for real-time progress, polls
stale exports via the task API as a fallback, and auto-triggers ZIP
download on completion
### Modified files
- **`useMediaAssetActions.ts`** — `downloadMultipleAssets` now routes to
ZIP export (via `createAssetExport`) in cloud mode when 2+ assets are
selected; single assets and OSS mode still use direct download
- **`assetService.ts`** — Added `createAssetExport()` and
`getExportDownloadUrl()` endpoints
- **`apiSchema.ts`** — Added `AssetExportWsMessage` type for the
WebSocket event
- **`api.ts`** — Wired up `asset_export` WebSocket event
- **`GraphView.vue`** — Mounted `AssetExportProgressDialog`
- **`main.json`** — Added i18n keys for export toast UI
## How it works
1. User selects multiple assets and clicks download
2. Frontend calls `POST /assets/export` with asset/job IDs
3. Backend creates a ZIP task and streams progress via `asset_export`
WebSocket events
4. `AssetExportProgressDialog` shows real-time progress
5. On completion, the ZIP is auto-downloaded via a presigned URL from
`GET /assets/exports/{name}`
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8712-feat-bulk-asset-export-with-ZIP-download-3006d73d365081839ec3dd3e7b0d3b77)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
121 lines
3.4 KiB
TypeScript
121 lines
3.4 KiB
TypeScript
import type { VueWrapper } from '@vue/test-utils'
|
|
import { mount } from '@vue/test-utils'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { defineComponent, h, nextTick, ref } from 'vue'
|
|
|
|
import HoneyToast from './HoneyToast.vue'
|
|
|
|
describe('HoneyToast', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
function mountComponent(
|
|
props: { visible: boolean; expanded?: boolean } = { visible: true }
|
|
): VueWrapper {
|
|
return mount(HoneyToast, {
|
|
props,
|
|
slots: {
|
|
default: (slotProps: { isExpanded: boolean }) =>
|
|
h(
|
|
'div',
|
|
{ 'data-testid': 'content' },
|
|
slotProps.isExpanded ? 'expanded' : 'collapsed'
|
|
),
|
|
footer: (slotProps: { isExpanded: boolean; toggle: () => void }) =>
|
|
h(
|
|
'button',
|
|
{
|
|
'data-testid': 'toggle-btn',
|
|
onClick: slotProps.toggle
|
|
},
|
|
slotProps.isExpanded ? 'Collapse' : 'Expand'
|
|
)
|
|
},
|
|
attachTo: document.body
|
|
})
|
|
}
|
|
|
|
it('renders when visible is true', async () => {
|
|
const wrapper = mountComponent({ visible: true })
|
|
await nextTick()
|
|
|
|
const toast = document.body.querySelector('[role="status"]')
|
|
expect(toast).toBeTruthy()
|
|
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('does not render when visible is false', async () => {
|
|
const wrapper = mountComponent({ visible: false })
|
|
await nextTick()
|
|
|
|
const toast = document.body.querySelector('[role="status"]')
|
|
expect(toast).toBeFalsy()
|
|
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('passes is-expanded=false to slots by default', async () => {
|
|
const wrapper = mountComponent({ visible: true })
|
|
await nextTick()
|
|
|
|
const content = document.body.querySelector('[data-testid="content"]')
|
|
expect(content?.textContent).toBe('collapsed')
|
|
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('has aria-live="polite" for accessibility', async () => {
|
|
const wrapper = mountComponent({ visible: true })
|
|
await nextTick()
|
|
|
|
const toast = document.body.querySelector('[role="status"]')
|
|
expect(toast?.getAttribute('aria-live')).toBe('polite')
|
|
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('supports v-model:expanded with reactive parent state', async () => {
|
|
const TestWrapper = defineComponent({
|
|
components: { HoneyToast },
|
|
setup() {
|
|
const expanded = ref(false)
|
|
return { expanded }
|
|
},
|
|
template: `
|
|
<HoneyToast :visible="true" v-model:expanded="expanded">
|
|
<template #default="slotProps">
|
|
<div data-testid="content">{{ slotProps.isExpanded ? 'expanded' : 'collapsed' }}</div>
|
|
</template>
|
|
<template #footer="slotProps">
|
|
<button data-testid="toggle-btn" @click="slotProps.toggle">
|
|
{{ slotProps.isExpanded ? 'Collapse' : 'Expand' }}
|
|
</button>
|
|
</template>
|
|
</HoneyToast>
|
|
`
|
|
})
|
|
|
|
const wrapper = mount(TestWrapper, { attachTo: document.body })
|
|
await nextTick()
|
|
|
|
const content = document.body.querySelector('[data-testid="content"]')
|
|
expect(content?.textContent).toBe('collapsed')
|
|
|
|
const toggleBtn = document.body.querySelector(
|
|
'[data-testid="toggle-btn"]'
|
|
) as HTMLButtonElement
|
|
expect(toggleBtn?.textContent?.trim()).toBe('Expand')
|
|
|
|
toggleBtn?.click()
|
|
await nextTick()
|
|
|
|
expect(content?.textContent).toBe('expanded')
|
|
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
|
|
|
|
wrapper.unmount()
|
|
})
|
|
})
|