Compare commits

..

2 Commits

Author SHA1 Message Date
Matt Miller
8a92f92624 feat: send credential_type for BYOK service-account (Vertex) secrets
Add the credential_type discriminator to CreateSecretRequest and send it
from the secret form: a json_file credential (a GCP service-account key)
is submitted as credential_type=gcp_service_account so the backend routes
the provider through Vertex AI instead of the default api_key path. text
credentials keep the default (field omitted), so existing providers are
unchanged.
2026-07-15 17:16:21 -07:00
jaeone94
5da3e16f33 fix: preserve single-select values on reselection (#13608)
## Summary

Prevent single-select dropdowns from clearing their value when the
currently selected item is selected again. This fixes Load Image and
Load Video previews switching to a missing-media error after media
reselection.

Linear: FE-1239

## Changes

- **What**: Keep the current single-select value, close the dropdown,
and restore trigger focus when the selected item is chosen again.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

The behavior is implemented in the shared `FormDropdown` selection
handler, so it applies consistently to all single-select dropdown
consumers rather than special-casing media widgets. Multi-select
toggling remains unchanged.

## Red-Green Verification

- `383576446` adds the unit and Playwright regressions without the
production fix. CI Unit run `29196142841` failed on the new
empty-selection assertion, proving red.
- `d4ae9eb2c` adds the production fix after the red result was
confirmed. CI Unit run `29196531028` passed the same Vitest suite green.

## Test Plan

- Unit regression verifies no selection update is emitted and the
dropdown closes with focus restored.
- Playwright regression reselects `example.png` in Load Image and
verifies the menu closes, the value remains selected, and no load error
appears.
- Targeted Playwright regression passed 10 consecutive local runs with
the production fix applied locally.

## Screenshots (if applicable)

Before 


https://github.com/user-attachments/assets/c3724e16-04fc-4b88-bd92-87004db71596

After 


https://github.com/user-attachments/assets/c99924f1-e446-4eca-aef7-cb8df961ab22
2026-07-15 22:52:11 +00:00
10 changed files with 136 additions and 31 deletions

View File

@@ -7,6 +7,45 @@ import {
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Vue Upload Widgets', { tag: '@vue-nodes' }, () => {
test.describe('media selection', { tag: '@widget' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
})
test('keeps a selected image loaded when it is selected again', async ({
comfyPage
}) => {
const loadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
expect(loadImageNodes, 'Workflow has one Load Image node').toHaveLength(1)
const [loadImageNode] = loadImageNodes
const imageWidget = await loadImageNode.getWidgetByName('image')
await expect.poll(() => imageWidget.getValue()).toBe('example.png')
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
const imageLoadError = node.getByTestId(TestIds.errors.imageLoadError)
const selectedImageButton = node.getByRole('button', {
name: 'example.png',
exact: true
})
await expect(selectedImageButton).toBeVisible()
await expect(imageLoadError).toBeHidden()
await selectedImageButton.click()
const menu = comfyPage.page.getByTestId('form-dropdown-menu')
await expect(menu).toBeVisible()
await menu.getByText('example.png', { exact: true }).click()
await expect(menu).toBeHidden()
await expect(selectedImageButton).toBeFocused()
await expect(selectedImageButton).toBeVisible()
await expect.poll(() => imageWidget.getValue()).toBe('example.png')
await expect(imageLoadError).toBeHidden()
})
})
test('should hide canvas-only upload buttons', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/all_load_widgets')

View File

@@ -1404,6 +1404,10 @@ export type CreateSecretRequest = {
* The plaintext secret to encrypt and store
*/
secret_value: string
/**
* How to interpret secret_value. `api_key` (default) is a plain API-key string; `gcp_service_account` is a GCP service-account key JSON, which routes the provider through Vertex AI.
*/
credential_type?: 'api_key' | 'gcp_service_account'
}
/**

View File

@@ -922,7 +922,8 @@ export const zUpdateSecretRequest = z.object({
export const zCreateSecretRequest = z.object({
name: z.string().min(1).max(255),
provider: z.string().max(64).optional(),
secret_value: z.string().min(1)
secret_value: z.string().min(1),
credential_type: z.enum(['api_key', 'gcp_service_account']).optional()
})
/**

View File

@@ -657,7 +657,7 @@ describe('useSecretForm', () => {
expect(fileName.value).toBe('')
})
it('submits valid JSON for a json_file provider', async () => {
it('submits valid JSON with the gcp_service_account credential type', async () => {
const visible = ref(true)
mockCreate.mockResolvedValue({})
const { form, handleSubmit } = useSecretForm({
@@ -677,6 +677,31 @@ describe('useSecretForm', () => {
expect(mockCreate).toHaveBeenCalledWith({
name: 'Vertex SA',
secret_value: '{"type":"service_account"}',
provider: 'gemini',
credential_type: 'gcp_service_account'
})
})
it('omits credential_type for a text provider (defaults to api_key)', async () => {
const visible = ref(true)
mockCreate.mockResolvedValue({})
const { form, handleSubmit } = useSecretForm({
mode: 'create',
existingProviders: () => [],
availableProviders: () => [{ id: 'gemini', input_type: 'text' }],
visible,
onSaved: vi.fn()
})
form.name = 'Gemini Studio'
form.provider = 'gemini'
form.secretValue = 'AIzaStudioKey'
await handleSubmit()
expect(mockCreate).toHaveBeenCalledWith({
name: 'Gemini Studio',
secret_value: 'AIzaStudioKey',
provider: 'gemini'
})
})

View File

@@ -271,7 +271,11 @@ export function useSecretForm(options: UseSecretFormOptions) {
await createSecret({
name: form.name.trim(),
secret_value: form.secretValue,
provider: form.provider!
provider: form.provider!,
// A json_file credential is a service-account key → route via Vertex.
...(selectedInputType.value === 'json_file' && {
credential_type: 'gcp_service_account'
})
})
} else if (secret) {
const updatePayload: { name: string; secret_value?: string } = {

View File

@@ -1,4 +1,5 @@
import type {
CreateSecretRequest,
SecretProvider as SecretProviderSchema,
SecretResponse
} from '@comfyorg/ingest-types'
@@ -38,6 +39,13 @@ export interface SecretCreateRequest {
secret_value: string
/** Provider identifier as returned by `GET /secrets/providers`. */
provider?: string
/**
* How the backend interprets `secret_value`. `api_key` (default) is a plain
* string; `gcp_service_account` is a service-account key JSON that routes the
* provider (Gemini) through Vertex AI. Derived from the provider's
* `input_type`: a `json_file` credential is a `gcp_service_account`.
*/
credential_type?: CreateSecretRequest['credential_type']
}
export interface SecretUpdateRequest {

View File

@@ -47,6 +47,14 @@ const MockFormDropdownMenu = {
'candidateLabel'
],
template: `<div class="mock-menu" data-testid="dropdown-menu" :data-candidate-index="candidateIndex" :data-candidate-label="candidateLabel ?? ''" :data-items="JSON.stringify(items)">
<button
v-for="(item, index) in items"
:key="item.id"
type="button"
@click="$emit('item-click', item, index)"
>
{{ item.label ?? item.name }}
</button>
<button type="button" @click="$emit('search-enter')">Search enter</button>
</div>`
}
@@ -79,6 +87,7 @@ interface MountDropdownOptions {
onCleanup: (cleanupFn: () => void) => void
) => Promise<FormDropdownItem[]>
multiple?: boolean | number
selected?: Set<string>
searchQuery?: string
onUpdateSelected?: (selected: Set<string>) => void
onUpdateIsOpen?: (isOpen: boolean) => void
@@ -97,6 +106,7 @@ function mountDropdown(
props: {
items,
multiple: options.multiple,
selected: options.selected,
searcher: options.searcher,
searchQuery: options.searchQuery,
'onUpdate:selected': options.onUpdateSelected,
@@ -256,6 +266,40 @@ describe('FormDropdown', () => {
expect(screen.getByRole('button', { name: 'Open' })).toHaveFocus()
})
it('keeps the selected item when it is clicked again in single-select mode', async () => {
const onUpdateSelected = vi.fn()
const onUpdateIsOpen = vi.fn()
const item = createItem('image', 'photo.png')
const { user } = mountDropdown([item], {
selected: new Set([item.id]),
onUpdateSelected,
onUpdateIsOpen
})
await openDropdown(user)
await user.click(screen.getByRole('button', { name: item.label }))
expect(onUpdateSelected).not.toHaveBeenCalled()
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(false)
expect(screen.getByRole('button', { name: 'Open' })).toHaveFocus()
})
it('replaces the selected item when a different item is clicked in single-select mode', async () => {
const onUpdateSelected = vi.fn()
const firstItem = createItem('first', 'first.png')
const secondItem = createItem('second', 'second.png')
const { user } = mountDropdown([firstItem, secondItem], {
selected: new Set([firstItem.id]),
onUpdateSelected
})
await openDropdown(user)
await user.click(screen.getByRole('button', { name: secondItem.label }))
expect(onUpdateSelected).toHaveBeenCalledWith(new Set([secondItem.id]))
expect(screen.getByRole('button', { name: 'Open' })).toHaveFocus()
})
it('does not select when Enter is pressed with an empty search query', async () => {
const onUpdateSelected = vi.fn()
const { user } = mountDropdown(

View File

@@ -265,8 +265,14 @@ function handleFileChange(event: Event) {
function handleSelection(item: FormDropdownItem, index: number) {
if (disabled) return
const itemIsSelected = internalIsSelected(item, index)
if (isSingleSelect.value && itemIsSelected) {
closeDropdown({ restoreFocus: true })
return
}
const sel = selected.value
if (internalIsSelected(item, index)) {
if (itemIsSelected) {
sel.delete(item.id)
} else {
if (sel.size < maxSelectable.value) {
@@ -281,7 +287,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
}
selected.value = new Set(sel)
if (maxSelectable.value === 1) {
if (isSingleSelect.value) {
closeDropdown({ restoreFocus: true })
}
}

View File

@@ -42,11 +42,6 @@ export const useExtensionService = () => {
await Promise.all(
extensions
.filter((extension) => !extension.includes('extensions/core'))
.filter(
(extension) =>
__DISTRIBUTION__ !== 'cloud' ||
extension !== '/extensions/cloud/rum.js'
)
.map(async (ext) => {
try {
await import(/* @vite-ignore */ api.fileURL(ext))

View File

@@ -334,27 +334,6 @@ export default defineConfig({
tailwindcss(),
typegpuPlugin({}),
comfyAPIPlugin(IS_DEV),
{
name: 'inject-cloud-rum',
apply: 'build',
transformIndexHtml(html) {
if (DISTRIBUTION !== 'cloud') return html
return {
html,
tags: [
{
tag: 'script',
attrs: {
type: 'module',
src: '/extensions/cloud/rum.js'
},
injectTo: 'head-prepend'
}
]
}
}
},
// Exclude proprietary ABCROM fonts from non-cloud builds
{
name: 'exclude-proprietary-fonts',