Compare commits

...

7 Commits

Author SHA1 Message Date
github-actions
738cad40c9 [automated] Update test expectations 2026-03-24 21:00:45 +00:00
bymyself
e540920fbb fix: restore firebaseAuthStore to main version
Stale version was carried over from pre-rebase branch.
2026-03-24 13:28:50 -07:00
bymyself
82595f5704 fix: correct createMockWidget calls in placeholder tests
The placeholder tests were calling createMockWidget with two positional
args (value, options) but the function accepts a single object arg. The
second argument was silently ignored, causing tests to fail. Also adds
explicit generic type to fix TypeScript narrowing errors.
2026-03-24 13:27:23 -07:00
bymyself
2be4a2effe style: fix formatting in ComboWidget tests
Amp-Thread-ID: https://ampcode.com/threads/T-019c7a94-a709-7773-b282-458d6bcb9e41
2026-03-24 13:27:23 -07:00
bymyself
6dcaed56ec fix: guard against undefined values in _displayValue and inline hasEmptyOptions
Amp-Thread-ID: https://ampcode.com/threads/T-019c7a79-53f0-7329-8935-5b43637194c5
2026-03-24 13:27:23 -07:00
bymyself
78d4a10691 fix: address code review feedback
- Fix widget value set to placeholder instead of empty string
- Eliminate duplicate rawValues resolution
- Fix ellipsis formatting and Vue props destructuring
- Add test for undefined placeholder edge case

Amp-Thread-ID: https://ampcode.com/threads/T-019c2c7e-2ac1-7114-9147-b41e6334faa9
2026-03-24 13:27:23 -07:00
bymyself
80617d33f0 feat: add placeholder support for empty combo widgets
Add placeholder field to combo widget schema and rendering:
- Add placeholder to zComboInputOptions in nodeDefSchema.ts
- Add placeholder to IWidgetOptions in widgets.ts
- Update ComboWidget._displayValue to show placeholder when values empty
- Update useComboWidget.ts to pass placeholder to widget options

When a combo widget has an empty options list and a placeholder is defined,
the placeholder text will be displayed instead of an empty dropdown.

Amp-Thread-ID: https://ampcode.com/threads/T-019c2bd5-472a-73a1-842b-4e05cba5d12c
2026-03-24 12:34:23 -07:00
10 changed files with 222 additions and 21 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

View File

@@ -1057,6 +1057,58 @@ describe('ComboWidget', () => {
expect(widget.canDecrement()).toBe(false)
})
it('should display placeholder text when values list is empty and placeholder is provided', () => {
widget = new ComboWidget(
createMockWidgetConfig({
name: 'ckpt_name',
value: '',
options: {
values: [],
placeholder:
'No models found in ComfyUI/models/checkpoints folder...'
}
}),
node
)
expect(widget._displayValue).toBe(
'No models found in ComfyUI/models/checkpoints folder...'
)
})
it('should display normal value when values list is not empty even with placeholder', () => {
widget = new ComboWidget(
createMockWidgetConfig({
name: 'ckpt_name',
value: 'model.safetensors',
options: {
values: ['model.safetensors', 'other.safetensors'],
placeholder:
'No models found in ComfyUI/models/checkpoints folder...'
}
}),
node
)
expect(widget._displayValue).toBe('model.safetensors')
})
it('should not display placeholder when placeholder is undefined', () => {
widget = new ComboWidget(
createMockWidgetConfig({
name: 'ckpt_name',
value: '',
options: {
values: [],
placeholder: undefined
}
}),
node
)
expect(widget._displayValue).toBe('')
})
it('should throw error when values is null in getValues', () => {
widget = new ComboWidget(
createMockWidgetConfig({

View File

@@ -35,6 +35,17 @@ export class ComboWidget
override get _displayValue() {
if (this.computedDisabled) return ''
const { values: rawValues, placeholder } = this.options
const resolvedValues =
typeof rawValues === 'function' ? rawValues(this, this.node) : rawValues
if (resolvedValues) {
const valuesArray = toArray(resolvedValues)
if (valuesArray.length === 0 && placeholder) {
return placeholder
}
}
const getOptionLabel = this.options.getOptionLabel
if (getOptionLabel) {
try {
@@ -45,13 +56,8 @@ export class ComboWidget
}
}
const { values: rawValues } = this.options
if (rawValues) {
const values = typeof rawValues === 'function' ? rawValues() : rawValues
if (values && !Array.isArray(values)) {
return values[this.value]
}
if (resolvedValues && !Array.isArray(resolvedValues)) {
return resolvedValues[this.value]
}
return typeof this.value === 'number' ? String(this.value) : this.value
}

View File

@@ -348,4 +348,38 @@ describe('WidgetSelect Value Binding', () => {
expect(wrapper.findComponent(WidgetSelectDropdown).exists()).toBe(false)
})
})
describe('Empty options with placeholder', () => {
it('shows placeholder when options are empty', () => {
const widget = createMockWidget<string | undefined>({
value: '',
options: {
values: [],
placeholder: 'No models found in ComfyUI/models/checkpoints folder...'
}
})
const wrapper = mountComponent(widget, '')
const selectDefault = wrapper.findComponent(WidgetSelectDefault)
const select = selectDefault.findComponent(SelectPlus)
expect(select.props('placeholder')).toBe(
'No models found in ComfyUI/models/checkpoints folder...'
)
})
it('does not show placeholder when options exist', () => {
const widget = createMockWidget<string | undefined>({
value: 'option1',
options: {
values: ['option1', 'option2'],
placeholder: 'No models found'
}
})
const wrapper = mountComponent(widget, 'option1')
const selectDefault = wrapper.findComponent(WidgetSelectDefault)
const select = selectDefault.findComponent(SelectPlus)
expect(select.props('placeholder')).toBeFalsy()
})
})
})

View File

@@ -51,7 +51,7 @@ interface Props {
widget: SimplifiedWidget<string | undefined>
}
const props = defineProps<Props>()
const { widget } = defineProps<Props>()
function resolveValues(values: unknown): string[] {
if (typeof values === 'function') return values()
@@ -76,15 +76,30 @@ function refreshOptions() {
}
const selectOptions = computed(() => {
void refreshTrigger.value
return resolveValues(props.widget.options?.values)
return resolveValues(widget.options?.values)
})
const invalid = computed(
() => !!modelValue.value && !selectOptions.value.includes(modelValue.value)
)
const combinedProps = computed(() => ({
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
...transformCompatProps.value,
...(invalid.value ? { placeholder: `${modelValue.value}` } : {})
}))
const combinedProps = computed(() => {
const { placeholder: _, ...filteredOptions } = filterWidgetProps(
widget.options,
PANEL_EXCLUDED_PROPS
)
const baseProps = {
...filteredOptions,
...transformCompatProps.value
}
if (selectOptions.value.length === 0 && widget.options?.placeholder) {
return { ...baseProps, placeholder: widget.options.placeholder }
}
if (invalid.value) {
return { ...baseProps, placeholder: `${modelValue.value}` }
}
return baseProps
})
</script>

View File

@@ -163,10 +163,11 @@ describe('useComboWidget', () => {
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'inputName',
undefined,
'',
expect.any(Function),
expect.objectContaining({
values: []
values: [],
placeholder: undefined
})
)
expect(widget).toBe(mockWidget)

View File

@@ -207,10 +207,11 @@ const addComboWidget = (
const widget = node.addWidget(
'combo',
inputSpec.name,
defaultValue,
defaultValue ?? '',
() => {},
{
values: inputSpec.options ?? []
values: inputSpec.options ?? [],
placeholder: inputSpec.placeholder
}
)

View File

@@ -99,7 +99,9 @@ export const zComboInputOptions = zBaseInputOptions.extend({
options: z.array(zComboOption).optional(),
remote: zRemoteWidgetConfig.optional(),
/** Whether the widget is a multi-select widget. */
multi_select: zMultiSelectOption.optional()
multi_select: zMultiSelectOption.optional(),
/** Placeholder text to display when the options list is empty. */
placeholder: z.string().optional()
})
const zIntInputSpec = z.tuple([z.literal('INT'), zIntInputOptions.optional()])

View File

@@ -85,10 +85,19 @@ vi.mock('firebase/auth', async (importOriginal) => {
addScope = vi.fn()
setCustomParameters = vi.fn()
},
getAdditionalUserInfo: vi.fn(),
setPersistence: vi.fn().mockResolvedValue(undefined)
}
})
// Mock telemetry
const mockTrackAuth = vi.fn()
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackAuth: mockTrackAuth
})
}))
// Mock useToastStore
vi.mock('@/stores/toastStore', () => ({
useToastStore: () => ({
@@ -572,6 +581,82 @@ describe('useFirebaseAuthStore', () => {
expect(store.loading).toBe(false)
})
describe('sign-up telemetry OR logic', () => {
const mockUserCredential = {
user: mockUser
} as Partial<UserCredential> as UserCredential
beforeEach(() => {
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
mockUserCredential
)
})
it.each(['loginWithGoogle', 'loginWithGithub'] as const)(
'%s should track is_new_user=true when Firebase says new user',
async (method) => {
vi.mocked(firebaseAuth.getAdditionalUserInfo).mockReturnValue({
isNewUser: true,
providerId: 'google.com',
profile: null
})
await store[method]()
expect(mockTrackAuth).toHaveBeenCalledWith(
expect.objectContaining({ is_new_user: true })
)
}
)
it.each(['loginWithGoogle', 'loginWithGithub'] as const)(
'%s should track is_new_user=true when UI options say new user',
async (method) => {
vi.mocked(firebaseAuth.getAdditionalUserInfo).mockReturnValue({
isNewUser: false,
providerId: 'google.com',
profile: null
})
await store[method]({ isNewUser: true })
expect(mockTrackAuth).toHaveBeenCalledWith(
expect.objectContaining({ is_new_user: true })
)
}
)
it.each(['loginWithGoogle', 'loginWithGithub'] as const)(
'%s should track is_new_user=false when neither source says new user',
async (method) => {
vi.mocked(firebaseAuth.getAdditionalUserInfo).mockReturnValue({
isNewUser: false,
providerId: 'google.com',
profile: null
})
await store[method]()
expect(mockTrackAuth).toHaveBeenCalledWith(
expect.objectContaining({ is_new_user: false })
)
}
)
it.each(['loginWithGoogle', 'loginWithGithub'] as const)(
'%s should track is_new_user=false when getAdditionalUserInfo returns null',
async (method) => {
vi.mocked(firebaseAuth.getAdditionalUserInfo).mockReturnValue(null)
await store[method]()
expect(mockTrackAuth).toHaveBeenCalledWith(
expect.objectContaining({ is_new_user: false })
)
}
)
})
})
describe('accessBillingPortal', () => {

View File

@@ -5,6 +5,7 @@ import {
GoogleAuthProvider,
browserLocalPersistence,
createUserWithEmailAndPassword,
getAdditionalUserInfo,
onAuthStateChanged,
onIdTokenChanged,
sendPasswordResetEmail,
@@ -386,9 +387,11 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
)
if (isCloud) {
const additionalUserInfo = getAdditionalUserInfo(result)
useTelemetry()?.trackAuth({
method: 'google',
is_new_user: options?.isNewUser ?? false,
is_new_user:
options?.isNewUser || additionalUserInfo?.isNewUser || false,
user_id: result.user.uid
})
}
@@ -405,9 +408,11 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
)
if (isCloud) {
const additionalUserInfo = getAdditionalUserInfo(result)
useTelemetry()?.trackAuth({
method: 'github',
is_new_user: options?.isNewUser ?? false,
is_new_user:
options?.isNewUser || additionalUserInfo?.isNewUser || false,
user_id: result.user.uid
})
}