mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 22:58:08 +00:00
fix: avoid false missing media errors after importing shared workflow assets (#12333)
## Summary Import published media assets for shared workflows before loading the graph so the first missing-media scan sees the user's newly imported references instead of surfacing a false missing asset error. cc FE-773 ## Changes - **What**: Moves the shared workflow import step ahead of `loadGraphData` for the copy-and-open flow, while still allowing the workflow to open with a warning path if asset import fails. - **What**: Clears the shared workflow URL intent consistently on failure paths, including graph load failure after an import attempt, so reloads do not repeatedly replay the same shared workflow side effects. - **What**: Invalidates the input asset cache after published asset import so graph loading and missing-media resolution can observe the refreshed media state. - **What**: Adds a global loading spinner while shared workflow asset import and graph load are in progress, with `role="status"`, `aria-live`, reduced-motion-safe animation, and body teleporting so it stays visible above blocking UI. - **What**: Adds stable TestIds for the shared workflow dialog and updates existing shared workflow E2E selectors away from copy-dependent role text. - **What**: Adds a cloud E2E regression fixture and spec covering the critical flow: shared URL opens the dialog, the user confirms asset import, published media is imported before the public-inclusive input asset scan, the workflow loads, the share query is removed, and missing media UI is not surfaced. - **Breaking**: None. - **Dependencies**: None. ## Root Cause Shared workflow graph loading triggered the missing-media pipeline before the user-selected published media import had completed. Because `include_public=true` does not include published assets, the pre-import scan could classify shared media as missing even when the user was about to import those assets into their own library. ## Review Focus - The ordering in `useSharedWorkflowUrlLoader`: import published assets first, then load the graph, while keeping import failure non-fatal for workflow opening. - The failure cleanup behavior: the shared URL/preserved query intent is now cleared for graph load failures too, avoiding repeated reload-triggered imports. - The spinner behavior in `App.vue`: it uses the existing `workspaceStore.spinner` boolean and intentionally keeps broader ref-counted spinner ownership as follow-up work. - The E2E sentinel in `sharedWorkflowMissingMedia.spec.ts`: it asserts no public-inclusive input asset scan occurs before `/api/assets/import`, then waits for a settling window to ensure the missing-media overlay does not appear. ## Validation - `pnpm format` - `pnpm lint` (passed with existing unrelated warnings only) - `pnpm typecheck` - `pnpm test:unit` - Commit hook: lint-staged formatting/linting, `pnpm typecheck`, `pnpm typecheck:browser` - Push hook: `pnpm knip --cache` (passed with existing tag hint only) ## Follow-Up - Consider a ref-counted or scoped global spinner API so long-running flows do not directly toggle `workspaceStore.spinner`. - Consider separating shared workflow load status into orthogonal result fields instead of encoding partial success in a single string union. - Consider moving published asset import/cache invalidation behind an asset-service-owned API boundary. - Backend follow-up remains needed for `include_public=true` not including published assets; this PR only removes the frontend false positive when the user explicitly imports the shared media. ## Screenshots Before https://github.com/user-attachments/assets/dc790046-237c-4dd8-b773-2507f9a66650 After https://github.com/user-attachments/assets/6517cd38-2c3d-4bfe-a990-35892b7e50ae https://github.com/user-attachments/assets/d89dc3d3-75d9-4251-998b-0c354414e25b ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-12333-fix-avoid-false-missing-media-errors-after-importing-shared-workflow-assets-3656d73d365081b38634dcb7625cfc32) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -76,7 +76,15 @@ export const TestIds = {
|
||||
publishTabPanel: 'publish-tab-panel',
|
||||
apiSignin: 'api-signin-dialog',
|
||||
updatePassword: 'update-password-dialog',
|
||||
cloudNotification: 'cloud-notification-dialog'
|
||||
cloudNotification: 'cloud-notification-dialog',
|
||||
openSharedWorkflow: 'open-shared-workflow-dialog',
|
||||
openSharedWorkflowTitle: 'open-shared-workflow-title',
|
||||
openSharedWorkflowClose: 'open-shared-workflow-close',
|
||||
openSharedWorkflowErrorClose: 'open-shared-workflow-error-close',
|
||||
openSharedWorkflowCancel: 'open-shared-workflow-cancel',
|
||||
openSharedWorkflowOpenWithoutImporting:
|
||||
'open-shared-workflow-open-without-importing',
|
||||
openSharedWorkflowConfirm: 'open-shared-workflow-confirm'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
|
||||
250
browser_tests/fixtures/sharedWorkflowImportFixture.ts
Normal file
250
browser_tests/fixtures/sharedWorkflowImportFixture.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import type {
|
||||
Asset,
|
||||
ImportPublishedAssetsRequest,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||
|
||||
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
|
||||
|
||||
export const sharedWorkflowImportScenario = {
|
||||
shareId: 'shared-missing-media-e2e',
|
||||
workflowId: 'shared-missing-media-workflow',
|
||||
publishedAssetId: 'published-input-asset-1',
|
||||
inputFileName: 'shared_imported_image.png'
|
||||
} as const
|
||||
|
||||
export type SharedWorkflowRequestEvent =
|
||||
| 'import'
|
||||
| 'input-assets-including-public-before-import'
|
||||
| 'input-assets-including-public-after-import'
|
||||
|
||||
export interface SharedWorkflowImportMocks {
|
||||
resetAndStartRecording: () => void
|
||||
getImportBody: () => ImportPublishedAssetsRequest | undefined
|
||||
getRequestEvents: () => SharedWorkflowRequestEvent[]
|
||||
waitForPublicInclusiveInputAssetResponseAfterImport: () => Promise<void>
|
||||
}
|
||||
|
||||
const defaultInputFileName = '00000000000000000000000Aexample.png'
|
||||
|
||||
const sharedWorkflowAsset: AssetInfo = {
|
||||
id: sharedWorkflowImportScenario.publishedAssetId,
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
|
||||
const defaultInputAsset: Asset = {
|
||||
id: 'default-input-asset',
|
||||
name: defaultInputFileName,
|
||||
asset_hash: defaultInputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const importedInputAsset: Asset = {
|
||||
id: 'imported-input-asset',
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
asset_hash: sharedWorkflowImportScenario.inputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const sharedWorkflowResponse: SharedWorkflowResponse = {
|
||||
share_id: sharedWorkflowImportScenario.shareId,
|
||||
workflow_id: sharedWorkflowImportScenario.workflowId,
|
||||
name: 'Shared Missing Media Workflow',
|
||||
listed: true,
|
||||
publish_time: '2026-05-01T00:00:00Z',
|
||||
workflow_json: {
|
||||
version: 0.4,
|
||||
last_node_id: 10,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 10,
|
||||
type: 'LoadImage',
|
||||
pos: [50, 200],
|
||||
size: [315, 314],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{
|
||||
name: 'IMAGE',
|
||||
type: 'IMAGE',
|
||||
links: null
|
||||
},
|
||||
{
|
||||
name: 'MASK',
|
||||
type: 'MASK',
|
||||
links: null
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'LoadImage'
|
||||
},
|
||||
widgets_values: [sharedWorkflowImportScenario.inputFileName, 'image']
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {
|
||||
ds: {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
assets: [sharedWorkflowAsset]
|
||||
}
|
||||
|
||||
export const sharedWorkflowImportFixture = base.extend<{
|
||||
sharedWorkflowImportMocks: SharedWorkflowImportMocks
|
||||
}>({
|
||||
sharedWorkflowImportMocks: async ({ page }, use) => {
|
||||
const mocks = await mockSharedWorkflowImportFlow(page)
|
||||
await use(mocks)
|
||||
}
|
||||
})
|
||||
|
||||
async function mockSharedWorkflowImportFlow(
|
||||
page: Page
|
||||
): Promise<SharedWorkflowImportMocks> {
|
||||
let isRecording = false
|
||||
let importEndpointCalled = false
|
||||
let importBody: ImportPublishedAssetsRequest | undefined
|
||||
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void = () => {}
|
||||
let publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
|
||||
(resolve) => {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
|
||||
}
|
||||
)
|
||||
const requestEvents: SharedWorkflowRequestEvent[] = []
|
||||
|
||||
function resetPublicInclusiveInputAssetResponseWaiter() {
|
||||
publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
|
||||
(resolve) => {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function recordRequestEvent(event: SharedWorkflowRequestEvent) {
|
||||
if (isRecording) requestEvents.push(event)
|
||||
}
|
||||
|
||||
await page.route(
|
||||
`**/workflows/published/${sharedWorkflowImportScenario.shareId}`,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(sharedWorkflowResponse)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await page.route('**/api/assets/import', async (route) => {
|
||||
recordRequestEvent('import')
|
||||
importBody = route.request().postDataJSON() as ImportPublishedAssetsRequest
|
||||
importEndpointCalled = true
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
})
|
||||
|
||||
// Excludes `/api/assets/import` so the specific route above
|
||||
// remains isolated from the general asset listing mock.
|
||||
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const includeTags = getTagParam(url, 'include_tags')
|
||||
const isInputAssetRequest = includeTags.includes('input')
|
||||
const includesPublicAssets =
|
||||
url.searchParams.get('include_public') === 'true'
|
||||
const isPublicInclusiveInputAssetRequest =
|
||||
isInputAssetRequest && includesPublicAssets
|
||||
const isAfterImportPublicInclusiveInputAssetRequest =
|
||||
isPublicInclusiveInputAssetRequest && importEndpointCalled
|
||||
|
||||
if (isPublicInclusiveInputAssetRequest) {
|
||||
recordRequestEvent(
|
||||
importEndpointCalled
|
||||
? 'input-assets-including-public-after-import'
|
||||
: 'input-assets-including-public-before-import'
|
||||
)
|
||||
}
|
||||
|
||||
const allAssets = [
|
||||
defaultInputAsset,
|
||||
...(importEndpointCalled ? [importedInputAsset] : [])
|
||||
]
|
||||
const assets = includeTags.length
|
||||
? allAssets.filter((asset) =>
|
||||
includeTags.every((tag) => asset.tags?.includes(tag))
|
||||
)
|
||||
: allAssets
|
||||
|
||||
const response: ListAssetsResponse = {
|
||||
assets,
|
||||
total: assets.length,
|
||||
has_more: false
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
|
||||
if (isAfterImportPublicInclusiveInputAssetRequest) {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
resetAndStartRecording: () => {
|
||||
isRecording = true
|
||||
importEndpointCalled = false
|
||||
importBody = undefined
|
||||
requestEvents.length = 0
|
||||
resetPublicInclusiveInputAssetResponseWaiter()
|
||||
},
|
||||
getImportBody: () => importBody,
|
||||
getRequestEvents: () => [...requestEvents],
|
||||
waitForPublicInclusiveInputAssetResponseAfterImport: () =>
|
||||
publicInclusiveInputAssetResponseAfterImport
|
||||
}
|
||||
}
|
||||
|
||||
function getTagParam(url: URL, key: string): string[] {
|
||||
return (
|
||||
url.searchParams
|
||||
.get(key)
|
||||
?.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
147
browser_tests/tests/sharedWorkflowMissingMedia.spec.ts
Normal file
147
browser_tests/tests/sharedWorkflowMissingMedia.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
sharedWorkflowImportFixture,
|
||||
sharedWorkflowImportScenario
|
||||
} from '@e2e/fixtures/sharedWorkflowImportFixture'
|
||||
import type { SharedWorkflowImportMocks } from '@e2e/fixtures/sharedWorkflowImportFixture'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
|
||||
const IMPORT_ORDER_TIMEOUT_MS = 5_000
|
||||
|
||||
async function expectImportPrecedesPublicInclusiveInputAssetScan(
|
||||
mocks: SharedWorkflowImportMocks
|
||||
): Promise<void> {
|
||||
await expect(async () => {
|
||||
const events = mocks.getRequestEvents()
|
||||
const importIndex = events.indexOf('import')
|
||||
const afterImportIndex = events.indexOf(
|
||||
'input-assets-including-public-after-import'
|
||||
)
|
||||
|
||||
expect(
|
||||
events,
|
||||
'public-inclusive input assets must not be scanned before import'
|
||||
).not.toContain('input-assets-including-public-before-import')
|
||||
expect(importIndex, `events: ${events.join(',')}`).toBeGreaterThanOrEqual(0)
|
||||
expect(afterImportIndex, `events: ${events.join(',')}`).toBeGreaterThan(
|
||||
importIndex
|
||||
)
|
||||
}).toPass({ timeout: IMPORT_ORDER_TIMEOUT_MS })
|
||||
}
|
||||
|
||||
async function getCachedMissingMediaWarningNames(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<string[] | null> {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow
|
||||
if (!workflow) return null
|
||||
|
||||
return (
|
||||
workflow.pendingWarnings?.missingMediaCandidates?.map(
|
||||
(candidate) => candidate.name
|
||||
) ?? []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function expectNoMissingMediaAfterPublicInclusiveAssetScan(
|
||||
comfyPage: ComfyPage,
|
||||
mocks: SharedWorkflowImportMocks
|
||||
): Promise<void> {
|
||||
await mocks.waitForPublicInclusiveInputAssetResponseAfterImport()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeHidden()
|
||||
await expect
|
||||
.poll(() => getCachedMissingMediaWarningNames(comfyPage))
|
||||
.toEqual([])
|
||||
}
|
||||
|
||||
async function openPanelAndExpectNoMissingMedia(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<void> {
|
||||
const page = comfyPage.page
|
||||
const errorOverlay = page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
const panel = new PropertiesPanelHelper(page)
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
await expect(
|
||||
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
await expect(page.getByTestId(TestIds.dialogs.missingMediaGroup)).toHaveCount(
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
const test = mergeTests(comfyPageFixture, sharedWorkflowImportFixture)
|
||||
|
||||
test.describe('Shared workflow missing media', { tag: '@cloud' }, () => {
|
||||
// Missing media only surfaces the overlay when the Errors tab is enabled
|
||||
// (src/stores/executionErrorStore.ts).
|
||||
test.use({
|
||||
initialSettings: {
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': true
|
||||
}
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => {
|
||||
sharedWorkflowImportMocks.resetAndStartRecording()
|
||||
await comfyPage.setup({
|
||||
clearStorage: false,
|
||||
url: `/?share=${sharedWorkflowImportScenario.shareId}`
|
||||
})
|
||||
})
|
||||
|
||||
test('imports shared media before loading workflow so missing media is not surfaced', async ({
|
||||
comfyPage,
|
||||
sharedWorkflowImportMocks
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
|
||||
await expect(
|
||||
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
|
||||
).toBeVisible()
|
||||
|
||||
await dialog.getByTestId(TestIds.dialogs.openSharedWorkflowConfirm).click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
window.app!.graph.nodes.map((node) => ({
|
||||
type: node.type,
|
||||
value: node.widgets?.[0]?.value
|
||||
}))
|
||||
)
|
||||
)
|
||||
.toEqual([
|
||||
{
|
||||
type: 'LoadImage',
|
||||
value: sharedWorkflowImportScenario.inputFileName
|
||||
}
|
||||
])
|
||||
await expectImportPrecedesPublicInclusiveInputAssetScan(
|
||||
sharedWorkflowImportMocks
|
||||
)
|
||||
await expectNoMissingMediaAfterPublicInclusiveAssetScan(
|
||||
comfyPage,
|
||||
sharedWorkflowImportMocks
|
||||
)
|
||||
|
||||
expect(sharedWorkflowImportMocks.getImportBody()).toEqual({
|
||||
published_asset_ids: [sharedWorkflowImportScenario.publishedAssetId],
|
||||
share_id: sharedWorkflowImportScenario.shareId
|
||||
})
|
||||
expect(new URL(page.url()).searchParams.has('share')).toBe(false)
|
||||
await openPanelAndExpectNoMissingMedia(comfyPage)
|
||||
})
|
||||
})
|
||||
@@ -143,7 +143,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByRole('heading', { name: 'Open shared workflow' })
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
|
||||
).toBeVisible()
|
||||
|
||||
await expect(comfyPage.templates.content).toBeHidden()
|
||||
|
||||
@@ -3225,6 +3225,7 @@
|
||||
"copyAssetsAndOpen": "Import assets & open workflow",
|
||||
"openWorkflow": "Open workflow",
|
||||
"openWithoutImporting": "Open without importing",
|
||||
"opening": "Opening shared workflow...",
|
||||
"importFailed": "Failed to import workflow assets",
|
||||
"loadError": "Could not load this shared workflow. Please try again later."
|
||||
},
|
||||
|
||||
@@ -34,6 +34,7 @@ const i18n = createI18n({
|
||||
copyAssetsAndOpen: 'Copy assets & open workflow',
|
||||
openWorkflow: 'Open workflow',
|
||||
openWithoutImporting: 'Open without importing',
|
||||
opening: 'Opening shared workflow...',
|
||||
loadError:
|
||||
'Could not load this shared workflow. Please try again later.'
|
||||
},
|
||||
@@ -292,6 +293,25 @@ describe('OpenSharedWorkflowDialogContent', () => {
|
||||
expect(onConfirm).toHaveBeenCalledWith(assetsPayload)
|
||||
})
|
||||
|
||||
it('shows opening status and disables actions while opening', async () => {
|
||||
mockGetSharedWorkflow.mockResolvedValue(assetsPayload)
|
||||
const { container } = renderComponent({ openingAction: 'copy-and-open' })
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.getByRole('status').textContent).toContain(
|
||||
'Opening shared workflow...'
|
||||
)
|
||||
expect(container.textContent).not.toContain(
|
||||
'Opening the workflow will create a new copy in your workspace'
|
||||
)
|
||||
expect(screen.getByTestId('open-shared-workflow-close')).toBeEnabled()
|
||||
expect(screen.getByTestId('open-shared-workflow-cancel')).toBeDisabled()
|
||||
expect(
|
||||
screen.getByTestId('open-shared-workflow-open-without-importing')
|
||||
).toBeDisabled()
|
||||
expect(screen.getByTestId('open-shared-workflow-confirm')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('filters out assets already in library', async () => {
|
||||
const mixedPayload = makePayload({
|
||||
assets: [
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col">
|
||||
<div
|
||||
data-testid="open-shared-workflow-dialog"
|
||||
class="flex w-full flex-col"
|
||||
:aria-busy="isOpening"
|
||||
>
|
||||
<header
|
||||
class="flex h-12 items-center justify-between gap-2 border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="text-sm text-base-foreground">
|
||||
<h2
|
||||
data-testid="open-shared-workflow-title"
|
||||
class="text-sm text-base-foreground"
|
||||
>
|
||||
{{ $t('openSharedWorkflow.dialogTitle') }}
|
||||
</h2>
|
||||
<Button size="icon" :aria-label="$t('g.close')" @click="onCancel">
|
||||
<Button
|
||||
data-testid="open-shared-workflow-close"
|
||||
size="icon"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</header>
|
||||
@@ -43,7 +55,12 @@
|
||||
<footer
|
||||
class="flex items-center justify-end gap-2.5 border-t border-border-default px-8 py-4"
|
||||
>
|
||||
<Button variant="secondary" size="lg" @click="onCancel">
|
||||
<Button
|
||||
data-testid="open-shared-workflow-error-close"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ $t('g.close') }}
|
||||
</Button>
|
||||
</footer>
|
||||
@@ -55,8 +72,23 @@
|
||||
<h2 class="m-0 text-2xl font-semibold text-base-foreground">
|
||||
{{ workflowName }}
|
||||
</h2>
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('openSharedWorkflow.copyDescription') }}
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="m-0 flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<i
|
||||
v-if="isOpening"
|
||||
class="icon-[lucide--loader-circle] size-4 motion-safe:animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{{
|
||||
isOpening
|
||||
? $t('openSharedWorkflow.opening')
|
||||
: $t('openSharedWorkflow.copyDescription')
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -102,18 +134,34 @@
|
||||
<footer
|
||||
class="flex items-center justify-end gap-2.5 border-t border-border-default px-8 py-4"
|
||||
>
|
||||
<Button variant="secondary" size="lg" @click="onCancel">
|
||||
<Button
|
||||
data-testid="open-shared-workflow-cancel"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="isOpening"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasAssets"
|
||||
data-testid="open-shared-workflow-open-without-importing"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:loading="openingAction === 'open-only'"
|
||||
:disabled="isOpening"
|
||||
@click="onOpenWithoutImporting(sharedWorkflow)"
|
||||
>
|
||||
{{ $t('openSharedWorkflow.openWithoutImporting') }}
|
||||
</Button>
|
||||
<Button variant="primary" size="lg" @click="onConfirm(sharedWorkflow)">
|
||||
<Button
|
||||
data-testid="open-shared-workflow-confirm"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading="openingAction === 'copy-and-open'"
|
||||
:disabled="isOpening"
|
||||
@click="onConfirm(sharedWorkflow)"
|
||||
>
|
||||
{{
|
||||
hasAssets
|
||||
? $t('openSharedWorkflow.copyAssetsAndOpen')
|
||||
@@ -141,8 +189,17 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { shareId, onConfirm, onOpenWithoutImporting, onCancel } = defineProps<{
|
||||
type OpeningAction = 'copy-and-open' | 'open-only'
|
||||
|
||||
const {
|
||||
shareId,
|
||||
openingAction = null,
|
||||
onConfirm,
|
||||
onOpenWithoutImporting,
|
||||
onCancel
|
||||
} = defineProps<{
|
||||
shareId: string
|
||||
openingAction?: OpeningAction | null
|
||||
onConfirm: (payload: SharedWorkflowPayload) => void
|
||||
onOpenWithoutImporting: (payload: SharedWorkflowPayload) => void
|
||||
onCancel: () => void
|
||||
@@ -162,6 +219,7 @@ const nonOwnedAssets = computed(
|
||||
)
|
||||
|
||||
const hasAssets = computed(() => nonOwnedAssets.value.length > 0)
|
||||
const isOpening = computed(() => openingAction !== null)
|
||||
|
||||
const workflowName = computed(() => {
|
||||
if (!sharedWorkflow.value) return ''
|
||||
|
||||
@@ -80,6 +80,15 @@ vi.mock('vue-i18n', () => ({
|
||||
const mockShowLayoutDialog = vi.hoisted(() => vi.fn())
|
||||
const mockCloseDialog = vi.hoisted(() => vi.fn())
|
||||
const mockHideTemplateSelector = vi.hoisted(() => vi.fn())
|
||||
const mockDialogStack = vi.hoisted(
|
||||
() =>
|
||||
[] as Array<{
|
||||
key: string
|
||||
contentProps: Record<string, unknown>
|
||||
dialogComponentProps: Record<string, unknown>
|
||||
}>
|
||||
)
|
||||
const mockUpdateDialog = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
@@ -89,7 +98,9 @@ vi.mock('@/services/dialogService', () => ({
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
closeDialog: mockCloseDialog
|
||||
dialogStack: mockDialogStack,
|
||||
closeDialog: mockCloseDialog,
|
||||
updateDialog: mockUpdateDialog
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -117,17 +128,11 @@ function makePayload(
|
||||
}
|
||||
|
||||
function resolveDialogWithConfirm(payload: SharedWorkflowPayload) {
|
||||
const call = mockShowLayoutDialog.mock.calls.at(-1)
|
||||
if (!call) throw new Error('showLayoutDialog was not called')
|
||||
const options = call[0]
|
||||
options.props.onConfirm(payload)
|
||||
getLastDialogOptions().props.onConfirm(payload)
|
||||
}
|
||||
|
||||
function resolveDialogWithOpenOnly(payload: SharedWorkflowPayload) {
|
||||
const call = mockShowLayoutDialog.mock.calls.at(-1)
|
||||
if (!call) throw new Error('showLayoutDialog was not called')
|
||||
const options = call[0]
|
||||
options.props.onOpenWithoutImporting(payload)
|
||||
getLastDialogOptions().props.onOpenWithoutImporting(payload)
|
||||
}
|
||||
|
||||
function resolveDialogWithCancel() {
|
||||
@@ -137,10 +142,66 @@ function resolveDialogWithCancel() {
|
||||
options.props.onCancel()
|
||||
}
|
||||
|
||||
function getLastDialogOptions() {
|
||||
const call = mockShowLayoutDialog.mock.calls.at(-1)
|
||||
if (!call) throw new Error('showLayoutDialog was not called')
|
||||
return call[0]
|
||||
}
|
||||
|
||||
function createDialogInstance(options: {
|
||||
key: string
|
||||
props: Record<string, unknown>
|
||||
dialogComponentProps?: Record<string, unknown>
|
||||
}) {
|
||||
const dialog = {
|
||||
key: options.key,
|
||||
contentProps: { ...options.props },
|
||||
dialogComponentProps: { ...options.dialogComponentProps }
|
||||
}
|
||||
mockDialogStack.push(dialog)
|
||||
return dialog
|
||||
}
|
||||
|
||||
function createDeferred() {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('useSharedWorkflowUrlLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetAllMocks()
|
||||
mockQueryParams = {}
|
||||
mockDialogStack.length = 0
|
||||
mockShowLayoutDialog.mockImplementation(createDialogInstance)
|
||||
mockUpdateDialog.mockImplementation(
|
||||
(options: {
|
||||
key: string
|
||||
contentProps?: Record<string, unknown>
|
||||
dialogComponentProps?: Record<string, unknown>
|
||||
}) => {
|
||||
const dialog = mockDialogStack.find((item) => item.key === options.key)
|
||||
if (!dialog) return false
|
||||
|
||||
if (options.contentProps) {
|
||||
dialog.contentProps = {
|
||||
...dialog.contentProps,
|
||||
...options.contentProps
|
||||
}
|
||||
}
|
||||
|
||||
if (options.dialogComponentProps) {
|
||||
dialog.dialogComponentProps = {
|
||||
...dialog.dialogComponentProps,
|
||||
...options.dialogComponentProps
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null)
|
||||
})
|
||||
|
||||
@@ -193,6 +254,38 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
expect(mockHideTemplateSelector).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps dialog open with opening state while shared workflow loads', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
const graphLoad = createDeferred()
|
||||
mockLoadGraphData.mockReturnValue(graphLoad.promise)
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
const loadPromise = loadSharedWorkflowFromUrl()
|
||||
await Promise.resolve()
|
||||
const dialogOptions = getLastDialogOptions()
|
||||
const dialogInstance = mockShowLayoutDialog.mock.results[0].value
|
||||
|
||||
dialogOptions.props.onConfirm(makePayload())
|
||||
await Promise.resolve()
|
||||
|
||||
expect(dialogInstance.contentProps.openingAction).toBe('copy-and-open')
|
||||
expect(mockUpdateDialog).toHaveBeenCalledWith({
|
||||
key: 'open-shared-workflow',
|
||||
contentProps: { openingAction: 'copy-and-open' }
|
||||
})
|
||||
expect(dialogInstance.dialogComponentProps.closable).toBeUndefined()
|
||||
expect(dialogInstance.dialogComponentProps.closeOnEscape).toBeUndefined()
|
||||
expect(dialogInstance.dialogComponentProps.dismissableMask).toBeUndefined()
|
||||
expect(mockCloseDialog).not.toHaveBeenCalled()
|
||||
|
||||
graphLoad.resolve()
|
||||
await loadPromise
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenLastCalledWith({
|
||||
key: 'open-shared-workflow'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not load graph when user cancels dialog', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
@@ -222,7 +315,7 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
expect(mockHideTemplateSelector).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls import when non-owned assets exist and user confirms', async () => {
|
||||
it('imports non-owned assets before loading graph when user confirms', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
const payload = makePayload({
|
||||
assets: [
|
||||
@@ -242,9 +335,13 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
})
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
await loadSharedWorkflowFromUrl()
|
||||
const loaded = await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(loaded).toBe('loaded')
|
||||
expect(mockImportPublishedAssets).toHaveBeenCalledWith(['a1'], 'share-id-1')
|
||||
expect(mockImportPublishedAssets.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockLoadGraphData.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('does not call import when user chooses open-only', async () => {
|
||||
@@ -309,6 +406,13 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
const loaded = await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(loaded).toBe('loaded-without-assets')
|
||||
expect(mockLoadGraphData).toHaveBeenCalledWith(
|
||||
{ nodes: [] },
|
||||
true,
|
||||
true,
|
||||
'Test Workflow',
|
||||
{ openSource: 'shared_url' }
|
||||
)
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
@@ -317,6 +421,37 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('clears share intent when graph load fails after importing assets', async () => {
|
||||
mockQueryParams = { share: 'share-id-1', tab: 'assets' }
|
||||
const payload = makePayload({
|
||||
assets: [
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'img.png',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
]
|
||||
})
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
resolveDialogWithConfirm(payload)
|
||||
})
|
||||
mockLoadGraphData.mockRejectedValue(new Error('Graph load failed'))
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
const loaded = await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(loaded).toBe('failed')
|
||||
expect(mockImportPublishedAssets).toHaveBeenCalledWith(['a1'], 'share-id-1')
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: { tab: 'assets' } })
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'share'
|
||||
)
|
||||
})
|
||||
|
||||
it('filters out in_library assets before importing', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
const payload = makePayload({
|
||||
|
||||
@@ -28,6 +28,10 @@ type DialogResult =
|
||||
| { action: 'open-only'; payload: SharedWorkflowPayload }
|
||||
| { action: 'cancel' }
|
||||
|
||||
type OpeningAction = Exclude<DialogResult['action'], 'cancel'>
|
||||
|
||||
const OPEN_SHARED_WORKFLOW_DIALOG_KEY = 'open-shared-workflow'
|
||||
|
||||
export function useSharedWorkflowUrlLoader() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -63,28 +67,39 @@ export function useSharedWorkflowUrlLoader() {
|
||||
void router.replace({ query: newQuery })
|
||||
}
|
||||
|
||||
function clearShareIntent() {
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(SHARE_NAMESPACE)
|
||||
}
|
||||
|
||||
function showOpenSharedWorkflowDialog(
|
||||
shareId: string
|
||||
): Promise<DialogResult> {
|
||||
const dialogKey = 'open-shared-workflow'
|
||||
function setOpeningAction(openingAction: OpeningAction) {
|
||||
dialogStore.updateDialog({
|
||||
key: OPEN_SHARED_WORKFLOW_DIALOG_KEY,
|
||||
contentProps: { openingAction }
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise<DialogResult>((resolve) => {
|
||||
dialogService.showLayoutDialog({
|
||||
key: dialogKey,
|
||||
key: OPEN_SHARED_WORKFLOW_DIALOG_KEY,
|
||||
component: OpenSharedWorkflowDialogContent,
|
||||
props: {
|
||||
shareId,
|
||||
openingAction: null,
|
||||
onConfirm: (payload: SharedWorkflowPayload) => {
|
||||
setOpeningAction('copy-and-open')
|
||||
resolve({ action: 'copy-and-open', payload })
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
},
|
||||
onOpenWithoutImporting: (payload: SharedWorkflowPayload) => {
|
||||
setOpeningAction('open-only')
|
||||
resolve({ action: 'open-only', payload })
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
},
|
||||
onCancel: () => {
|
||||
resolve({ action: 'cancel' })
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
dialogStore.closeDialog({ key: OPEN_SHARED_WORKFLOW_DIALOG_KEY })
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
@@ -108,8 +123,7 @@ export function useSharedWorkflowUrlLoader() {
|
||||
}
|
||||
|
||||
if (typeof shareParam !== 'string') {
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(SHARE_NAMESPACE)
|
||||
clearShareIntent()
|
||||
return 'not-present'
|
||||
}
|
||||
|
||||
@@ -122,67 +136,74 @@ export function useSharedWorkflowUrlLoader() {
|
||||
summary: t('g.error'),
|
||||
detail: t('shareWorkflow.loadFailed')
|
||||
})
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(SHARE_NAMESPACE)
|
||||
clearShareIntent()
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
const result = await showOpenSharedWorkflowDialog(shareParam)
|
||||
|
||||
if (result.action === 'cancel') {
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(SHARE_NAMESPACE)
|
||||
clearShareIntent()
|
||||
return 'cancelled'
|
||||
}
|
||||
|
||||
templateSelectorDialog.hide()
|
||||
|
||||
const { payload } = result
|
||||
const workflowName = payload.name || t('openSharedWorkflow.dialogTitle')
|
||||
const nonOwnedAssets = payload.assets.filter((a) => !a.in_library)
|
||||
|
||||
try {
|
||||
await app.loadGraphData(payload.workflowJson, true, true, workflowName, {
|
||||
openSource: 'shared_url'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[useSharedWorkflowUrlLoader] Failed to load workflow graph:',
|
||||
error
|
||||
)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('shareWorkflow.loadFailed')
|
||||
})
|
||||
return 'failed'
|
||||
}
|
||||
const { payload } = result
|
||||
const workflowName = payload.name || t('openSharedWorkflow.dialogTitle')
|
||||
const nonOwnedAssets = payload.assets.filter((a) => !a.in_library)
|
||||
let importFailed = false
|
||||
|
||||
if (result.action === 'copy-and-open' && nonOwnedAssets.length > 0) {
|
||||
try {
|
||||
await workflowShareService.importPublishedAssets(
|
||||
nonOwnedAssets.map((a) => a.id),
|
||||
payload.shareId
|
||||
)
|
||||
} catch (importError) {
|
||||
importFailed = true
|
||||
console.error(
|
||||
'[useSharedWorkflowUrlLoader] Failed to import assets:',
|
||||
importError
|
||||
)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('openSharedWorkflow.importFailed')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (result.action === 'copy-and-open' && nonOwnedAssets.length > 0) {
|
||||
try {
|
||||
await workflowShareService.importPublishedAssets(
|
||||
nonOwnedAssets.map((a) => a.id),
|
||||
payload.shareId
|
||||
await app.loadGraphData(
|
||||
payload.workflowJson,
|
||||
true,
|
||||
true,
|
||||
workflowName,
|
||||
{
|
||||
openSource: 'shared_url'
|
||||
}
|
||||
)
|
||||
} catch (importError) {
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[useSharedWorkflowUrlLoader] Failed to import assets:',
|
||||
importError
|
||||
'[useSharedWorkflowUrlLoader] Failed to load workflow graph:',
|
||||
error
|
||||
)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('openSharedWorkflow.importFailed')
|
||||
detail: t('shareWorkflow.loadFailed')
|
||||
})
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(SHARE_NAMESPACE)
|
||||
return 'loaded-without-assets'
|
||||
clearShareIntent()
|
||||
return 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(SHARE_NAMESPACE)
|
||||
return 'loaded'
|
||||
clearShareIntent()
|
||||
return importFailed ? 'loaded-without-assets' : 'loaded'
|
||||
} finally {
|
||||
dialogStore.closeDialog({ key: OPEN_SHARED_WORKFLOW_DIALOG_KEY })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('@/scripts/app', () => ({
|
||||
|
||||
const mockGetShareableAssets = vi.fn()
|
||||
const mockFetchApi = vi.fn()
|
||||
const mockInvalidateInputAssetsIncludingPublic = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/validation/schemas/workflowSchema',
|
||||
@@ -32,6 +33,13 @@ vi.mock('@/scripts/api', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
invalidateInputAssetsIncludingPublic:
|
||||
mockInvalidateInputAssetsIncludingPublic
|
||||
}
|
||||
}))
|
||||
|
||||
describe(useWorkflowShareService, () => {
|
||||
const mockShareableAssets: AssetInfo[] = [
|
||||
{
|
||||
@@ -348,6 +356,7 @@ describe(useWorkflowShareService, () => {
|
||||
share_id: 'share-id-1'
|
||||
})
|
||||
})
|
||||
expect(mockInvalidateInputAssetsIncludingPublic).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('omits share_id from the payload when not provided', async () => {
|
||||
@@ -384,6 +393,7 @@ describe(useWorkflowShareService, () => {
|
||||
await expect(
|
||||
service.importPublishedAssets(['bad-id'], 'share-id-1')
|
||||
).rejects.toThrow('Failed to import assets: 400')
|
||||
expect(mockInvalidateInputAssetsIncludingPublic).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when shared workflow payload is invalid', async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
WorkflowPublishResult,
|
||||
WorkflowPublishStatus
|
||||
} from '@/platform/workflow/sharing/types/shareTypes'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import type { ThumbnailType } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
@@ -275,6 +276,8 @@ export function useWorkflowShareService() {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to import assets: ${response.status}`)
|
||||
}
|
||||
|
||||
assetService.invalidateInputAssetsIncludingPublic()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,17 @@ const MockComponent = defineComponent({
|
||||
template: '<div>Mock</div>'
|
||||
})
|
||||
|
||||
const MockContentPropsComponent = defineComponent({
|
||||
name: 'MockContentPropsComponent',
|
||||
props: {
|
||||
openingAction: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
template: '<div>Mock</div>'
|
||||
})
|
||||
|
||||
describe('dialogStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -172,6 +183,31 @@ describe('dialogStore', () => {
|
||||
expect(store.dialogStack[0].key).toBe('reusable-dialog')
|
||||
expect(store.dialogStack[0].title).toBe('Original Title')
|
||||
})
|
||||
|
||||
it('should update existing dialog props by key', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'updatable-dialog',
|
||||
component: MockContentPropsComponent,
|
||||
props: { openingAction: null },
|
||||
dialogComponentProps: { dismissableMask: true }
|
||||
})
|
||||
|
||||
const updated = store.updateDialog({
|
||||
key: 'updatable-dialog',
|
||||
contentProps: { openingAction: 'copy-and-open' },
|
||||
dialogComponentProps: { dismissableMask: false }
|
||||
})
|
||||
|
||||
expect(updated).toBe(true)
|
||||
expect(store.dialogStack[0].contentProps).toMatchObject({
|
||||
openingAction: 'copy-and-open'
|
||||
})
|
||||
expect(store.dialogStack[0].dialogComponentProps.dismissableMask).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ESC key behavior with multiple dialogs', () => {
|
||||
|
||||
@@ -93,6 +93,12 @@ export interface ShowDialogOptions<
|
||||
priority?: number
|
||||
}
|
||||
|
||||
interface UpdateDialogOptions {
|
||||
key: string
|
||||
contentProps?: Partial<DialogInstance['contentProps']>
|
||||
dialogComponentProps?: Partial<DialogComponentProps>
|
||||
}
|
||||
|
||||
export const useDialogStore = defineStore('dialog', () => {
|
||||
const dialogStack = ref<DialogInstance[]>([])
|
||||
|
||||
@@ -269,6 +275,28 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
return dialogStack.value.some((d) => d.key === key)
|
||||
}
|
||||
|
||||
function updateDialog(options: UpdateDialogOptions): boolean {
|
||||
const dialog = dialogStack.value.find((d) => d.key === options.key)
|
||||
if (!dialog) return false
|
||||
|
||||
if (options.contentProps) {
|
||||
dialog.contentProps = {
|
||||
...dialog.contentProps,
|
||||
...options.contentProps
|
||||
}
|
||||
}
|
||||
|
||||
if (options.dialogComponentProps) {
|
||||
dialog.dialogComponentProps = {
|
||||
...dialog.dialogComponentProps,
|
||||
...options.dialogComponentProps
|
||||
}
|
||||
updateCloseOnEscapeStates()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
dialogStack,
|
||||
riseDialog,
|
||||
@@ -276,6 +304,7 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
closeDialog,
|
||||
showExtensionDialog,
|
||||
isDialogOpen,
|
||||
updateDialog,
|
||||
activeKey
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user