Compare commits
18 Commits
image-outp
...
austin/rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
574a3c21c9 | ||
|
|
87c3ba636f | ||
|
|
48962a4970 | ||
|
|
c4efedfa1d | ||
|
|
1c75a49978 | ||
|
|
090ee7a981 | ||
|
|
a07097e25d | ||
|
|
948320137e | ||
|
|
a50eb8dd57 | ||
|
|
47650bebf8 | ||
|
|
3eece91eb6 | ||
|
|
5187a77234 | ||
|
|
b22ba97a13 | ||
|
|
aab9a30511 | ||
|
|
b0f5a9ffe2 | ||
|
|
3f777fb54f | ||
|
|
6a73288ce3 | ||
|
|
d21ea0f65b |
124
.github/workflows/release-version-bump.yaml
vendored
@@ -20,6 +20,13 @@ on:
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
schedule:
|
||||
# 00:00 UTC ≈ 4:00 PM PST / 5:00 PM PDT on the previous calendar day
|
||||
- cron: '0 0 * * *'
|
||||
|
||||
concurrency:
|
||||
group: release-version-bump
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
@@ -29,15 +36,99 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Prepare inputs
|
||||
id: prepared-inputs
|
||||
shell: bash
|
||||
env:
|
||||
RAW_VERSION_TYPE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version_type || '' }}
|
||||
RAW_PRE_RELEASE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.pre_release || '' }}
|
||||
RAW_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION_TYPE="$RAW_VERSION_TYPE"
|
||||
PRE_RELEASE="$RAW_PRE_RELEASE"
|
||||
TARGET_BRANCH="$RAW_BRANCH"
|
||||
|
||||
if [[ -z "$VERSION_TYPE" ]]; then
|
||||
VERSION_TYPE='patch'
|
||||
fi
|
||||
|
||||
if [[ -z "$TARGET_BRANCH" ]]; then
|
||||
TARGET_BRANCH='main'
|
||||
fi
|
||||
|
||||
{
|
||||
echo "version_type=$VERSION_TYPE"
|
||||
echo "pre_release=$PRE_RELEASE"
|
||||
echo "branch=$TARGET_BRANCH"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Close stale nightly version bump PRs
|
||||
if: github.event_name == 'schedule'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const prefix = 'version-bump-'
|
||||
const closed = []
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
per_page: 100
|
||||
})
|
||||
|
||||
for (const pr of prs) {
|
||||
if (!pr.head?.ref?.startsWith(prefix)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (pr.user?.login !== 'github-actions[bot]') {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only clean up stale nightly PRs targeting main.
|
||||
// Adjust here if other target branches should be cleaned.
|
||||
if (pr.base?.ref !== 'main') {
|
||||
continue
|
||||
}
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed'
|
||||
})
|
||||
|
||||
try {
|
||||
await github.rest.git.deleteRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: `heads/${pr.head.ref}`
|
||||
})
|
||||
} catch (error) {
|
||||
if (![404, 422].includes(error.status)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
closed.push(pr.number)
|
||||
}
|
||||
|
||||
core.info(`Closed ${closed.length} stale PR(s).`)
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
ref: ${{ steps.prepared-inputs.outputs.branch }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate branch exists
|
||||
env:
|
||||
TARGET_BRANCH: ${{ steps.prepared-inputs.outputs.branch }}
|
||||
run: |
|
||||
BRANCH="${{ github.event.inputs.branch }}"
|
||||
BRANCH="$TARGET_BRANCH"
|
||||
if ! git show-ref --verify --quiet "refs/heads/$BRANCH" && ! git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
|
||||
echo "❌ Branch '$BRANCH' does not exist"
|
||||
echo ""
|
||||
@@ -51,7 +142,7 @@ jobs:
|
||||
echo "✅ Branch '$BRANCH' exists"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
|
||||
with:
|
||||
version: 10
|
||||
|
||||
@@ -62,16 +153,31 @@ jobs:
|
||||
|
||||
- name: Bump version
|
||||
id: bump-version
|
||||
env:
|
||||
VERSION_TYPE: ${{ steps.prepared-inputs.outputs.version_type }}
|
||||
PRE_RELEASE: ${{ steps.prepared-inputs.outputs.pre_release }}
|
||||
run: |
|
||||
pnpm version ${{ github.event.inputs.version_type }} --preid ${{ github.event.inputs.pre_release }} --no-git-tag-version
|
||||
set -euo pipefail
|
||||
if [[ -n "$PRE_RELEASE" && ! "$VERSION_TYPE" =~ ^pre(major|minor|patch)$ && "$VERSION_TYPE" != "prerelease" ]]; then
|
||||
echo "❌ pre_release was provided but version_type='$VERSION_TYPE' does not support --preid"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$PRE_RELEASE" ]]; then
|
||||
pnpm version "$VERSION_TYPE" --preid "$PRE_RELEASE" --no-git-tag-version
|
||||
else
|
||||
pnpm version "$VERSION_TYPE" --no-git-tag-version
|
||||
fi
|
||||
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Format PR string
|
||||
id: capitalised
|
||||
env:
|
||||
VERSION_TYPE: ${{ steps.prepared-inputs.outputs.version_type }}
|
||||
run: |
|
||||
CAPITALISED_TYPE=${{ github.event.inputs.version_type }}
|
||||
echo "capitalised=${CAPITALISED_TYPE@u}" >> $GITHUB_OUTPUT
|
||||
CAPITALISED_TYPE="$VERSION_TYPE"
|
||||
echo "capitalised=${CAPITALISED_TYPE@u}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
@@ -82,8 +188,8 @@ jobs:
|
||||
body: |
|
||||
${{ steps.capitalised.outputs.capitalised }} version increment to ${{ steps.bump-version.outputs.NEW_VERSION }}
|
||||
|
||||
**Base branch:** `${{ github.event.inputs.branch }}`
|
||||
**Base branch:** `${{ steps.prepared-inputs.outputs.branch }}`
|
||||
branch: version-bump-${{ steps.bump-version.outputs.NEW_VERSION }}
|
||||
base: ${{ github.event.inputs.branch }}
|
||||
base: ${{ steps.prepared-inputs.outputs.branch }}
|
||||
labels: |
|
||||
Release
|
||||
|
||||
@@ -51,8 +51,6 @@ defineProps<{
|
||||
canProceed: boolean
|
||||
/** Whether the location step should be disabled */
|
||||
disableLocationStep: boolean
|
||||
/** Whether the migration step should be disabled */
|
||||
disableMigrationStep: boolean
|
||||
/** Whether the settings step should be disabled */
|
||||
disableSettingsStep: boolean
|
||||
}>()
|
||||
|
||||
|
After Width: | Height: | Size: 422 B |
@@ -160,7 +160,7 @@ export class VueNodeHelpers {
|
||||
return {
|
||||
input: widget.locator('input'),
|
||||
incrementButton: widget.locator('button').first(),
|
||||
decrementButton: widget.locator('button').last()
|
||||
decrementButton: widget.locator('button').nth(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ test.describe('Load Workflow in Media', () => {
|
||||
'edited_workflow.webp',
|
||||
'no_workflow.webp',
|
||||
'large_workflow.webp',
|
||||
'workflow_prompt_parameters.png',
|
||||
'workflow.webm',
|
||||
// Skipped due to 3d widget unstable visual result.
|
||||
// 3d widget shows grid after fully loaded.
|
||||
|
||||
|
After Width: | Height: | Size: 44 KiB |
29
browser_tests/tests/mobileBaseline.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
test.describe('Mobile Baseline Snapshots', () => {
|
||||
test('@mobile empty canvas', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.ConfirmClear', false)
|
||||
await comfyPage.executeCommand('Comfy.ClearWorkflow')
|
||||
await expect(async () => {
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(0)
|
||||
}).toPass({ timeout: 256 })
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
|
||||
})
|
||||
|
||||
test('@mobile default workflow', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('default')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'mobile-default-workflow.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('@mobile settings dialog', async ({ comfyPage }) => {
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
|
||||
'mobile-settings-dialog.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 112 KiB |
@@ -15,7 +15,9 @@ test.describe('Vue Integer Widget', () => {
|
||||
await comfyPage.loadWorkflow('vueNodes/linked-int-widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const seedWidget = comfyPage.vueNodes.getWidgetByName('KSampler', 'seed')
|
||||
const seedWidget = comfyPage.vueNodes
|
||||
.getWidgetByName('KSampler', 'seed')
|
||||
.first()
|
||||
const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget)
|
||||
const initialValue = Number(await controls.input.inputValue())
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.35.4",
|
||||
"version": "1.35.6",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -264,7 +264,7 @@ if (!releaseInfo) {
|
||||
}
|
||||
|
||||
// Output as JSON for GitHub Actions
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.log(JSON.stringify(releaseInfo, null, 2))
|
||||
|
||||
export { resolveRelease }
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { reactiveComputed } from '@vueuse/core'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
import { reactive, ref, shallowReactive, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type {
|
||||
@@ -20,7 +21,8 @@ import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { WidgetValue } from '@/types/simplifiedWidget'
|
||||
import type { WidgetValue, ControlOptions } from '@/types/simplifiedWidget'
|
||||
import { normalizeControlOption } from '@/types/simplifiedWidget'
|
||||
|
||||
import type {
|
||||
LGraph,
|
||||
@@ -40,14 +42,15 @@ export interface WidgetSlotMetadata {
|
||||
export interface SafeWidgetData {
|
||||
name: string
|
||||
type: string
|
||||
value: WidgetValue
|
||||
value: () => Ref<WidgetValue>
|
||||
borderStyle?: string
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
controlWidget?: () => Ref<ControlOptions>
|
||||
isDOMWidget?: boolean
|
||||
label?: string
|
||||
options?: IWidgetOptions<unknown>
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
spec?: InputSpec
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
isDOMWidget?: boolean
|
||||
borderStyle?: string
|
||||
spec?: InputSpec
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
@@ -84,6 +87,49 @@ export interface GraphNodeManager {
|
||||
cleanup(): void
|
||||
}
|
||||
|
||||
function normalizeWidgetValue(value: unknown): WidgetValue {
|
||||
if (value === null || value === undefined || value === void 0) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
// Check if it's a File array
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((item): item is File => item instanceof File)
|
||||
) {
|
||||
return value
|
||||
}
|
||||
// Otherwise it's a generic object
|
||||
return value
|
||||
}
|
||||
// If none of the above, return undefined
|
||||
console.warn(`Invalid widget value type: ${typeof value}`, value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getControlWidget(
|
||||
widget: IBaseWidget
|
||||
): (() => Ref<ControlOptions>) | undefined {
|
||||
const cagWidget = widget.linkedWidgets?.find(
|
||||
(w) => w.name == 'control_after_generate'
|
||||
)
|
||||
if (!cagWidget) return
|
||||
const cagRef = ref<ControlOptions>(normalizeControlOption(cagWidget.value))
|
||||
watch(cagRef, (value) => {
|
||||
cagWidget.value = normalizeControlOption(value)
|
||||
cagWidget.callback?.(cagWidget.value)
|
||||
})
|
||||
return () => cagRef
|
||||
}
|
||||
|
||||
export function safeWidgetMapper(
|
||||
node: LGraphNode,
|
||||
slotMetadata: Map<string, WidgetSlotMetadata>
|
||||
@@ -91,18 +137,27 @@ export function safeWidgetMapper(
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
return function (widget) {
|
||||
try {
|
||||
// TODO: Use widget.getReactiveData() once TypeScript types are updated
|
||||
let value = widget.value
|
||||
|
||||
// For combo widgets, if value is undefined, use the first option as default
|
||||
if (
|
||||
value === undefined &&
|
||||
widget.value === undefined &&
|
||||
widget.type === 'combo' &&
|
||||
widget.options?.values &&
|
||||
Array.isArray(widget.options.values) &&
|
||||
widget.options.values.length > 0
|
||||
) {
|
||||
value = widget.options.values[0]
|
||||
widget.value = widget.options.values[0]
|
||||
}
|
||||
if (!widget.valueRef) {
|
||||
const valueRef = ref(widget.value)
|
||||
watch(valueRef, (newValue) => {
|
||||
widget.value = newValue
|
||||
widget.callback?.(newValue)
|
||||
})
|
||||
widget.callback = useChainCallback(widget.callback, () => {
|
||||
if (valueRef.value !== widget.value)
|
||||
valueRef.value = normalizeWidgetValue(widget.value) ?? undefined
|
||||
})
|
||||
widget.valueRef = () => valueRef
|
||||
}
|
||||
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
@@ -115,20 +170,20 @@ export function safeWidgetMapper(
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: value,
|
||||
value: widget.valueRef,
|
||||
borderStyle,
|
||||
callback: widget.callback,
|
||||
isDOMWidget: isDOMWidget(widget),
|
||||
label: widget.label,
|
||||
options: widget.options,
|
||||
spec,
|
||||
slotMetadata: slotInfo
|
||||
slotMetadata: slotInfo,
|
||||
controlWidget: getControlWidget(widget)
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
name: widget.name || 'unknown',
|
||||
type: widget.type || 'text',
|
||||
value: undefined
|
||||
value: () => ref()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,6 +259,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
|
||||
}
|
||||
})
|
||||
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
|
||||
Object.defineProperty(node, 'inputs', {
|
||||
get() {
|
||||
return reactiveInputs
|
||||
},
|
||||
set(v) {
|
||||
reactiveInputs.splice(0, reactiveInputs.length, ...v)
|
||||
}
|
||||
})
|
||||
|
||||
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
|
||||
node.inputs?.forEach((input, index) => {
|
||||
@@ -238,7 +302,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
badges,
|
||||
hasErrors: !!node.has_errors,
|
||||
widgets: safeWidgets,
|
||||
inputs: node.inputs ? [...node.inputs] : undefined,
|
||||
inputs: reactiveInputs,
|
||||
outputs: node.outputs ? [...node.outputs] : undefined,
|
||||
flags: node.flags ? { ...node.flags } : undefined,
|
||||
color: node.color || undefined,
|
||||
@@ -252,128 +316,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
return nodeRefs.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a value is a valid WidgetValue type
|
||||
*/
|
||||
const validateWidgetValue = (value: unknown): WidgetValue => {
|
||||
if (value === null || value === undefined || value === void 0) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
// Check if it's a File array
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((item): item is File => item instanceof File)
|
||||
) {
|
||||
return value
|
||||
}
|
||||
// Otherwise it's a generic object
|
||||
return value
|
||||
}
|
||||
// If none of the above, return undefined
|
||||
console.warn(`Invalid widget value type: ${typeof value}`, value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates Vue state when widget values change
|
||||
*/
|
||||
const updateVueWidgetState = (
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
value: unknown
|
||||
): void => {
|
||||
try {
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
if (!currentData?.widgets) return
|
||||
|
||||
const updatedWidgets = currentData.widgets.map((w) =>
|
||||
w.name === widgetName ? { ...w, value: validateWidgetValue(value) } : w
|
||||
)
|
||||
// Create a completely new object to ensure Vue reactivity triggers
|
||||
const updatedData = {
|
||||
...currentData,
|
||||
widgets: updatedWidgets
|
||||
}
|
||||
|
||||
vueNodeData.set(nodeId, updatedData)
|
||||
} catch (error) {
|
||||
// Ignore widget update errors to prevent cascade failures
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync
|
||||
*/
|
||||
const createWrappedWidgetCallback = (
|
||||
widget: { value?: unknown; name: string }, // LiteGraph widget with minimal typing
|
||||
originalCallback: ((value: unknown) => void) | undefined,
|
||||
nodeId: string
|
||||
) => {
|
||||
let updateInProgress = false
|
||||
|
||||
return (value: unknown) => {
|
||||
if (updateInProgress) return
|
||||
updateInProgress = true
|
||||
|
||||
try {
|
||||
// 1. Update the widget value in LiteGraph (critical for LiteGraph state)
|
||||
// Validate that the value is of an acceptable type
|
||||
if (
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
typeof value !== 'string' &&
|
||||
typeof value !== 'number' &&
|
||||
typeof value !== 'boolean' &&
|
||||
typeof value !== 'object'
|
||||
) {
|
||||
console.warn(`Invalid widget value type: ${typeof value}`)
|
||||
updateInProgress = false
|
||||
return
|
||||
}
|
||||
|
||||
// Always update widget.value to ensure sync
|
||||
widget.value = value
|
||||
|
||||
// 2. Call the original callback if it exists
|
||||
if (originalCallback) {
|
||||
originalCallback.call(widget, value)
|
||||
}
|
||||
|
||||
// 3. Update Vue state to maintain synchronization
|
||||
updateVueWidgetState(nodeId, widget.name, value)
|
||||
} finally {
|
||||
updateInProgress = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up widget callbacks for a node
|
||||
*/
|
||||
const setupNodeWidgetCallbacks = (node: LGraphNode) => {
|
||||
if (!node.widgets) return
|
||||
|
||||
const nodeId = String(node.id)
|
||||
|
||||
node.widgets.forEach((widget) => {
|
||||
const originalCallback = widget.callback
|
||||
widget.callback = createWrappedWidgetCallback(
|
||||
widget,
|
||||
originalCallback,
|
||||
nodeId
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const syncWithGraph = () => {
|
||||
if (!graph?._nodes) return
|
||||
|
||||
@@ -394,9 +336,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Store non-reactive reference
|
||||
nodeRefs.set(id, node)
|
||||
|
||||
// Set up widget callbacks BEFORE extracting data (critical order)
|
||||
setupNodeWidgetCallbacks(node)
|
||||
|
||||
// Extract and store safe data for Vue
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
})
|
||||
@@ -415,9 +354,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Store non-reactive reference to original node
|
||||
nodeRefs.set(id, node)
|
||||
|
||||
// Set up widget callbacks BEFORE extracting data (critical order)
|
||||
setupNodeWidgetCallbacks(node)
|
||||
|
||||
// Extract initial data for Vue (may be incomplete during graph configure)
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
|
||||
|
||||
@@ -231,6 +231,36 @@ const ltxvPricingCalculator = (node: LGraphNode): string => {
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
|
||||
const klingVideoWithAudioPricingCalculator: PricingFunction = (
|
||||
node: LGraphNode
|
||||
): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const generateAudioWidget = node.widgets?.find(
|
||||
(w) => w.name === 'generate_audio'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget || !generateAudioWidget) {
|
||||
return '$0.35-1.40/Run (varies with duration & audio)'
|
||||
}
|
||||
|
||||
const duration = String(durationWidget.value)
|
||||
const generateAudio =
|
||||
String(generateAudioWidget.value).toLowerCase() === 'true'
|
||||
|
||||
if (duration === '5') {
|
||||
return generateAudio ? '$0.70/Run' : '$0.35/Run'
|
||||
}
|
||||
|
||||
if (duration === '10') {
|
||||
return generateAudio ? '$1.40/Run' : '$0.70/Run'
|
||||
}
|
||||
|
||||
// Fallback for unexpected duration values
|
||||
return '$0.35-1.40/Run (varies with duration & audio)'
|
||||
}
|
||||
|
||||
// ---- constants ----
|
||||
const SORA_SIZES = {
|
||||
BASIC: new Set(['720x1280', '1280x720']),
|
||||
@@ -744,6 +774,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
KlingOmniProImageNode: {
|
||||
displayPrice: '$0.028/Run'
|
||||
},
|
||||
KlingTextToVideoWithAudio: {
|
||||
displayPrice: klingVideoWithAudioPricingCalculator
|
||||
},
|
||||
KlingImageToVideoWithAudio: {
|
||||
displayPrice: klingVideoWithAudioPricingCalculator
|
||||
},
|
||||
LumaImageToVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
// Same pricing as LumaVideoNode per CSV
|
||||
@@ -1931,6 +1967,8 @@ export const useNodePricing = () => {
|
||||
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
|
||||
KlingSingleImageVideoEffectNode: ['effect_scene'],
|
||||
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
|
||||
KlingTextToVideoWithAudio: ['duration', 'generate_audio'],
|
||||
KlingImageToVideoWithAudio: ['duration', 'generate_audio'],
|
||||
KlingOmniProTextToVideoNode: ['duration'],
|
||||
KlingOmniProFirstLastFrameNode: ['duration'],
|
||||
KlingOmniProImageToVideoNode: ['duration'],
|
||||
|
||||
@@ -257,6 +257,7 @@ export class PrimitiveNode extends LGraphNode {
|
||||
undefined,
|
||||
inputData
|
||||
)
|
||||
if (this.widgets?.[1]) widget.linkedWidgets = [this.widgets[1]]
|
||||
let filter = this.widgets_values?.[2]
|
||||
if (filter && this.widgets && this.widgets.length === 3) {
|
||||
this.widgets[2].value = filter
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { CanvasColour, Point, RequiredProps, Size } from '../interfaces'
|
||||
import type { CanvasPointer, LGraphCanvas, LGraphNode } from '../litegraph'
|
||||
import type { CanvasPointerEvent } from './events'
|
||||
@@ -284,6 +286,7 @@ export interface IBaseWidget<
|
||||
/** Widget type (see {@link TWidgetType}) */
|
||||
type: TType
|
||||
value?: TValue
|
||||
valueRef?: () => Ref<boolean | number | string | object | undefined>
|
||||
|
||||
/**
|
||||
* Whether the widget value should be serialized on node serialization.
|
||||
|
||||
@@ -2052,6 +2052,24 @@
|
||||
"placeholderVideo": "Select video...",
|
||||
"placeholderModel": "Select model...",
|
||||
"placeholderUnknown": "Select media..."
|
||||
},
|
||||
"numberControl": {
|
||||
"header": {
|
||||
"prefix": "Automatically update the value",
|
||||
"after": "AFTER",
|
||||
"before": "BEFORE",
|
||||
"postfix": "running the workflow:"
|
||||
},
|
||||
"linkToGlobal": "Link to",
|
||||
"linkToGlobalSeed": "Global Value",
|
||||
"linkToGlobalDesc": "Unique value linked to the Global Value's control setting",
|
||||
"randomize": "Randomize Value",
|
||||
"randomizeDesc": "Shuffles the value randomly after each generation",
|
||||
"increment": "Increment Value",
|
||||
"incrementDesc": "Adds 1 to the value number",
|
||||
"decrement": "Decrement Value",
|
||||
"decrementDesc": "Subtracts 1 from the value number",
|
||||
"editSettings": "Edit control settings"
|
||||
}
|
||||
},
|
||||
"widgetFileUpload": {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import type {
|
||||
@@ -51,14 +51,16 @@ const nodeData = computed<VueNodeData>(() => {
|
||||
.map(([name, input]) => ({
|
||||
name,
|
||||
type: input.widgetType || input.type,
|
||||
value:
|
||||
input.default !== undefined
|
||||
? input.default
|
||||
: input.type === 'COMBO' &&
|
||||
Array.isArray(input.options) &&
|
||||
input.options.length > 0
|
||||
? input.options[0]
|
||||
: '',
|
||||
value: () =>
|
||||
ref(
|
||||
input.default !== undefined
|
||||
? input.default
|
||||
: input.type === 'COMBO' &&
|
||||
Array.isArray(input.options) &&
|
||||
input.options.length > 0
|
||||
? input.options[0]
|
||||
: ''
|
||||
),
|
||||
options: {
|
||||
hidden: input.hidden,
|
||||
advanced: input.advanced,
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
|
||||
:node-type="nodeType"
|
||||
class="col-span-2"
|
||||
@update:model-value="widget.updateHandler"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -69,7 +68,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipOptions } from 'primevue'
|
||||
import { computed, onErrorCaptured, ref, toValue } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import type { Component, Ref } from 'vue'
|
||||
|
||||
import type {
|
||||
VueNodeData,
|
||||
@@ -136,8 +135,7 @@ interface ProcessedWidget {
|
||||
type: string
|
||||
vueComponent: Component
|
||||
simplified: SimplifiedWidget
|
||||
value: WidgetValue
|
||||
updateHandler: (value: WidgetValue) => void
|
||||
value: () => Ref<WidgetValue>
|
||||
tooltipConfig: TooltipOptions
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
}
|
||||
@@ -170,20 +168,9 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
value: widget.value,
|
||||
label: widget.label,
|
||||
options: widgetOptions,
|
||||
callback: widget.callback,
|
||||
spec: widget.spec,
|
||||
borderStyle: widget.borderStyle
|
||||
}
|
||||
|
||||
function updateHandler(value: WidgetValue) {
|
||||
// Update the widget value directly
|
||||
widget.value = value
|
||||
|
||||
// Skip callback for asset widgets - their callback opens the modal,
|
||||
// but Vue asset mode handles selection through the dropdown
|
||||
if (widget.type !== 'asset') {
|
||||
widget.callback?.(value)
|
||||
}
|
||||
borderStyle: widget.borderStyle,
|
||||
controlWidget: widget.controlWidget
|
||||
}
|
||||
|
||||
const tooltipText = getWidgetTooltip(widget)
|
||||
@@ -195,7 +182,6 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
vueComponent,
|
||||
simplified,
|
||||
value: widget.value,
|
||||
updateHandler,
|
||||
tooltipConfig,
|
||||
slotMetadata
|
||||
})
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Popover from 'primevue/popover'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import type { ControlOptions } from '@/types/simplifiedWidget'
|
||||
|
||||
type ControlOption = {
|
||||
description: string
|
||||
mode: ControlOptions
|
||||
icon?: string
|
||||
text?: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const popover = ref()
|
||||
const settingStore = useSettingStore()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
const toggle = (event: Event) => {
|
||||
popover.value.toggle(event)
|
||||
}
|
||||
defineExpose({ toggle })
|
||||
|
||||
const controlOptions: ControlOption[] = [
|
||||
{
|
||||
mode: 'randomize',
|
||||
icon: 'icon-[lucide--shuffle]',
|
||||
title: 'randomize',
|
||||
description: 'randomizeDesc'
|
||||
},
|
||||
{
|
||||
mode: 'increment',
|
||||
text: '+1',
|
||||
title: 'increment',
|
||||
description: 'incrementDesc'
|
||||
},
|
||||
{
|
||||
mode: 'decrement',
|
||||
text: '-1',
|
||||
title: 'decrement',
|
||||
description: 'decrementDesc'
|
||||
}
|
||||
]
|
||||
|
||||
const widgetControlMode = computed(() =>
|
||||
settingStore.get('Comfy.WidgetControlMode')
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
controlWidget: () => Ref<ControlOptions>
|
||||
}>()
|
||||
|
||||
const handleToggle = (mode: ControlOptions) => {
|
||||
if (props.controlWidget().value === mode) return
|
||||
props.controlWidget().value = mode
|
||||
}
|
||||
|
||||
const isActive = (mode: ControlOptions) => {
|
||||
return props.controlWidget().value === mode
|
||||
}
|
||||
|
||||
const handleEditSettings = () => {
|
||||
popover.value.hide()
|
||||
dialogService.showSettingsDialog()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover
|
||||
ref="popover"
|
||||
class="bg-interface-panel-surface border border-interface-stroke rounded-lg"
|
||||
>
|
||||
<div class="w-113 max-w-md p-4 space-y-4">
|
||||
<div class="text-sm text-muted-foreground leading-tight">
|
||||
{{ $t('widgets.numberControl.header.prefix') }}
|
||||
<span class="text-base-foreground font-medium">
|
||||
{{
|
||||
widgetControlMode === 'before'
|
||||
? $t('widgets.numberControl.header.before')
|
||||
: $t('widgets.numberControl.header.after')
|
||||
}}
|
||||
</span>
|
||||
{{ $t('widgets.numberControl.header.postfix') }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="option in controlOptions"
|
||||
:key="option.mode"
|
||||
class="flex items-center justify-between py-2 gap-7"
|
||||
>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0 bg-secondary-background border border-border-subtle"
|
||||
>
|
||||
<i
|
||||
v-if="option.icon"
|
||||
:class="option.icon"
|
||||
class="text-base text-base-foreground"
|
||||
/>
|
||||
<span
|
||||
v-if="option.text"
|
||||
class="text-xs font-normal text-base-foreground"
|
||||
>
|
||||
{{ option.text }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<div
|
||||
class="text-sm font-normal text-base-foreground leading-tight"
|
||||
>
|
||||
<span>
|
||||
{{ $t(`widgets.numberControl.${option.title}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-normal text-muted-foreground leading-tight"
|
||||
>
|
||||
{{ $t(`widgets.numberControl.${option.description}`) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToggleSwitch
|
||||
:model-value="isActive(option.mode)"
|
||||
class="flex-shrink-0"
|
||||
@update:model-value="
|
||||
(v) => (v ? handleToggle(option.mode) : handleToggle('fixed'))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-border-subtle"></div>
|
||||
<Button
|
||||
class="w-full bg-secondary-background hover:bg-secondary-background-hover border-0 rounded-lg p-2 text-sm"
|
||||
@click="handleEditSettings"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<i class="pi pi-cog text-xs text-muted-foreground" />
|
||||
<span class="font-normal text-base-foreground">{{
|
||||
$t('widgets.numberControl.editSettings')
|
||||
}}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -31,11 +31,7 @@ const props = defineProps<{
|
||||
nodeId: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>('modelValue')
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
const modelValue = props.widget.value()
|
||||
|
||||
// Get litegraph node
|
||||
const litegraphNode = computed(() => {
|
||||
@@ -50,7 +46,7 @@ const isOutputNodeRef = computed(() => {
|
||||
return isOutputNode(node)
|
||||
})
|
||||
|
||||
const audioFilePath = computed(() => props.widget.value as string)
|
||||
const audioFilePath = props.widget.value()
|
||||
|
||||
// Computed audio URL from widget value (for input files)
|
||||
const audioUrlFromWidget = computed(() => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import Button from 'primevue/button'
|
||||
import type { ButtonProps } from 'primevue/button'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import WidgetButton from '@/renderer/extensions/vueNodes/widgets/components/WidgetButton.vue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
@@ -12,13 +13,16 @@ describe('WidgetButton Interactions', () => {
|
||||
options: Partial<ButtonProps> = {},
|
||||
callback?: () => void,
|
||||
name: string = 'test_button'
|
||||
): SimplifiedWidget<void> => ({
|
||||
name,
|
||||
type: 'button',
|
||||
value: undefined,
|
||||
options,
|
||||
callback
|
||||
})
|
||||
): SimplifiedWidget<void> => {
|
||||
const valueRef = ref()
|
||||
if (callback) watch(valueRef, callback)
|
||||
return {
|
||||
name,
|
||||
type: 'button',
|
||||
value: () => valueRef,
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
const mountComponent = (widget: SimplifiedWidget<void>, readonly = false) => {
|
||||
return mount(WidgetButton, {
|
||||
@@ -195,11 +199,7 @@ describe('WidgetButton Interactions', () => {
|
||||
const widget = createMockWidget({}, mockCallback)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
// Simulate rapid clicks
|
||||
const clickPromises = Array.from({ length: 16 }, () =>
|
||||
clickButton(wrapper)
|
||||
)
|
||||
await Promise.all(clickPromises)
|
||||
for (let i = 0; i < 16; i++) await clickButton(wrapper)
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(16)
|
||||
})
|
||||
|
||||
@@ -37,8 +37,8 @@ const filteredProps = computed(() =>
|
||||
)
|
||||
|
||||
const handleClick = () => {
|
||||
if (props.widget.callback) {
|
||||
props.widget.callback()
|
||||
}
|
||||
const ref = props.widget.value()
|
||||
//@ts-expect-error - need to actually assign value, can't use triggerRef :(
|
||||
ref.value = !ref.value
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -21,12 +21,12 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
type ChartWidgetOptions = NonNullable<ChartInputSpec['options']>
|
||||
|
||||
const value = defineModel<ChartData>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<ChartData, ChartWidgetOptions>
|
||||
}>()
|
||||
|
||||
const value = props.widget.value()
|
||||
|
||||
const chartType = computed(() => props.widget.options?.type ?? 'line')
|
||||
|
||||
const chartData = computed(() => value.value || { labels: [], datasets: [] })
|
||||
|
||||
@@ -3,6 +3,7 @@ import ColorPicker from 'primevue/colorpicker'
|
||||
import type { ColorPickerProps } from 'primevue/colorpicker'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -14,13 +15,16 @@ describe('WidgetColorPicker Value Binding', () => {
|
||||
value: string = '#000000',
|
||||
options: Partial<ColorPickerProps> = {},
|
||||
callback?: (value: string) => void
|
||||
): SimplifiedWidget<string> => ({
|
||||
name: 'test_color_picker',
|
||||
type: 'color',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
})
|
||||
): SimplifiedWidget<string> => {
|
||||
const valueRef = ref(value)
|
||||
if (callback) watch(valueRef, (v) => callback(v))
|
||||
return {
|
||||
name: 'test_color_picker',
|
||||
type: 'color',
|
||||
value: () => valueRef,
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string>,
|
||||
@@ -49,80 +53,61 @@ describe('WidgetColorPicker Value Binding', () => {
|
||||
) => {
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
await colorPicker.setValue(value)
|
||||
return wrapper.emitted('update:modelValue')
|
||||
}
|
||||
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when color changes', async () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
describe('Value Binding', () => {
|
||||
it('triggers callback when color changes', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('#ff0000', {}, callback)
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '#00ff00')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#00ff00')
|
||||
await setColorPickerValue(wrapper, '#00ff00')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('#00ff00')
|
||||
})
|
||||
|
||||
it('handles different color formats', async () => {
|
||||
const widget = createMockWidget('#ffffff')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('#ffffff', {}, callback)
|
||||
const wrapper = mountComponent(widget, '#ffffff')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '#123abc')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#123abc')
|
||||
await setColorPickerValue(wrapper, '#123abc')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('#123abc')
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createMockWidget('#000000', {}, undefined)
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '#ff00ff')
|
||||
|
||||
// Should still emit Vue event
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#ff00ff')
|
||||
})
|
||||
|
||||
it('normalizes bare hex without # to #hex on emit', async () => {
|
||||
const widget = createMockWidget('ff0000')
|
||||
it('normalizes bare hex without # to #hex', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('ff0000', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'ff0000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '00ff00')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#00ff00')
|
||||
await setColorPickerValue(wrapper, '00ff00')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('#00ff00')
|
||||
})
|
||||
|
||||
it('normalizes rgb() strings to #hex on emit', async (context) => {
|
||||
context.skip('needs diagnosis')
|
||||
const widget = createMockWidget('#000000')
|
||||
it('normalizes rgb() strings to #hex', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('#000000', { format: 'rgb' }, callback)
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, 'rgb(255, 0, 0)')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#ff0000')
|
||||
await setColorPickerValue(wrapper, 'rgb(255, 0, 0)')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('#ff0000')
|
||||
})
|
||||
|
||||
it('normalizes hsb() strings to #hex on emit', async () => {
|
||||
const widget = createMockWidget('#000000', { format: 'hsb' })
|
||||
it('normalizes hsb() strings to #hex', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('#000000', { format: 'hsb' }, callback)
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, 'hsb(120, 100, 100)')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#00ff00')
|
||||
await setColorPickerValue(wrapper, 'hsb(120, 100, 100)')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('#00ff00')
|
||||
})
|
||||
|
||||
it('normalizes HSB object values to #hex on emit', async () => {
|
||||
const widget = createMockWidget('#000000', { format: 'hsb' })
|
||||
it('normalizes HSB object values to #hex', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('#000000', { format: 'hsb' }, callback)
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, {
|
||||
h: 240,
|
||||
s: 100,
|
||||
b: 100
|
||||
})
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#0000ff')
|
||||
await setColorPickerValue(wrapper, { h: 240, s: 100, b: 100 })
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('#0000ff')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -265,15 +250,15 @@ describe('WidgetColorPicker Value Binding', () => {
|
||||
})
|
||||
|
||||
it('handles invalid color formats gracefully', async () => {
|
||||
const widget = createMockWidget('invalid-color')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('invalid-color', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'invalid-color')
|
||||
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect(colorText.text()).toBe('#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, 'invalid-color')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#000000')
|
||||
await setColorPickerValue(wrapper, 'invalid-color')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('#000000')
|
||||
})
|
||||
|
||||
it('handles widget with no options', () => {
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
:pt="{
|
||||
preview: '!w-full !h-full !border-none'
|
||||
}"
|
||||
@update:model-value="onPickerUpdate"
|
||||
/>
|
||||
<span
|
||||
class="text-xs truncate min-w-[4ch]"
|
||||
@@ -27,11 +26,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { isColorFormat, toHexFromFormat } from '@/utils/colorUtil'
|
||||
import type { ColorFormat, HSB } from '@/utils/colorUtil'
|
||||
import type { ColorFormat } from '@/utils/colorUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
@@ -45,39 +44,23 @@ type WidgetOptions = { format?: ColorFormat } & Record<string, unknown>
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string, WidgetOptions>
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
const modelValue = props.widget.value()
|
||||
|
||||
const format = computed<ColorFormat>(() => {
|
||||
const optionFormat = props.widget.options?.format
|
||||
return isColorFormat(optionFormat) ? optionFormat : 'hex'
|
||||
})
|
||||
|
||||
type PickerValue = string | HSB
|
||||
const localValue = ref<PickerValue>(
|
||||
toHexFromFormat(
|
||||
props.modelValue || '#000000',
|
||||
isColorFormat(props.widget.options?.format)
|
||||
? props.widget.options.format
|
||||
: 'hex'
|
||||
)
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
localValue.value = toHexFromFormat(newVal || '#000000', format.value)
|
||||
const localValue = computed({
|
||||
get() {
|
||||
return toHexFromFormat(modelValue.value || '#000000', format.value)
|
||||
},
|
||||
set(v) {
|
||||
modelValue.value = toHexFromFormat(v, format.value)
|
||||
}
|
||||
)
|
||||
|
||||
function onPickerUpdate(val: unknown) {
|
||||
localValue.value = val as PickerValue
|
||||
emit('update:modelValue', toHexFromFormat(val, format.value))
|
||||
}
|
||||
})
|
||||
|
||||
// ColorPicker specific excluded props include panel/overlay classes
|
||||
const COLOR_PICKER_EXCLUDED_PROPS = [...PANEL_EXCLUDED_PROPS] as const
|
||||
|
||||
@@ -4,6 +4,7 @@ import Galleria from 'primevue/galleria'
|
||||
import type { GalleriaProps } from 'primevue/galleria'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -52,7 +53,7 @@ function createMockWidget(
|
||||
return {
|
||||
name: 'test_galleria',
|
||||
type: 'array',
|
||||
value,
|
||||
value: () => ref(value),
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,12 +68,12 @@ export interface GalleryImage {
|
||||
|
||||
export type GalleryValue = string[] | GalleryImage[]
|
||||
|
||||
const value = defineModel<GalleryValue>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<GalleryValue>
|
||||
}>()
|
||||
|
||||
const value = props.widget.value()
|
||||
|
||||
const activeIndex = ref(0)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ImageCompare from 'primevue/imagecompare'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -15,7 +16,7 @@ describe('WidgetImageCompare Display', () => {
|
||||
): SimplifiedWidget<ImageCompareValue | string> => ({
|
||||
name: 'test_imagecompare',
|
||||
type: 'object',
|
||||
value,
|
||||
value: () => ref(value),
|
||||
options
|
||||
})
|
||||
|
||||
|
||||
@@ -44,24 +44,24 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const beforeImage = computed(() => {
|
||||
const value = props.widget.value
|
||||
const value = props.widget.value().value
|
||||
return typeof value === 'string' ? value : value?.before || ''
|
||||
})
|
||||
|
||||
const afterImage = computed(() => {
|
||||
const value = props.widget.value
|
||||
const value = props.widget.value().value
|
||||
return typeof value === 'string' ? '' : value?.after || ''
|
||||
})
|
||||
|
||||
const beforeAlt = computed(() => {
|
||||
const value = props.widget.value
|
||||
const value = props.widget.value().value
|
||||
return typeof value === 'object' && value?.beforeAlt
|
||||
? value.beforeAlt
|
||||
: 'Before image'
|
||||
})
|
||||
|
||||
const afterAlt = computed(() => {
|
||||
const value = props.widget.value
|
||||
const value = props.widget.value().value
|
||||
return typeof value === 'object' && value?.afterAlt
|
||||
? value.afterAlt
|
||||
: 'After image'
|
||||
|
||||
@@ -1,23 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type {
|
||||
SimplifiedControlWidget,
|
||||
SimplifiedWidget
|
||||
} from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
|
||||
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
|
||||
import WidgetWithControl from './WidgetWithControl.vue'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ default: 0 })
|
||||
const modelValue = props.widget.value()
|
||||
|
||||
const hasControlAfterGenerate = computed(() => {
|
||||
return !!props.widget.controlWidget
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WidgetWithControl
|
||||
v-if="hasControlAfterGenerate"
|
||||
:widget="widget as SimplifiedControlWidget<number>"
|
||||
:comp="
|
||||
widget.type === 'slider'
|
||||
? WidgetInputNumberSlider
|
||||
: WidgetInputNumberInput
|
||||
"
|
||||
/>
|
||||
<component
|
||||
:is="
|
||||
widget.type === 'slider'
|
||||
? WidgetInputNumberSlider
|
||||
: WidgetInputNumberInput
|
||||
"
|
||||
v-else
|
||||
v-model="modelValue"
|
||||
:widget="widget"
|
||||
v-bind="$attrs"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -13,12 +14,13 @@ function createMockWidget(
|
||||
options: SimplifiedWidget['options'] = {},
|
||||
callback?: (value: number) => void
|
||||
): SimplifiedWidget<number> {
|
||||
const valueRef = ref(value)
|
||||
if (callback) watch(valueRef, (v) => callback(v))
|
||||
return {
|
||||
name: 'test_input_number',
|
||||
type,
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
value: () => valueRef,
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,16 +51,15 @@ describe('WidgetInputNumberInput Value Binding', () => {
|
||||
expect(input.value).toBe('42')
|
||||
})
|
||||
|
||||
it('emits update:modelValue when value changes', async () => {
|
||||
const widget = createMockWidget(10, 'int')
|
||||
it('triggers callback when value changes', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget(10, 'int', {}, callback)
|
||||
const wrapper = mountComponent(widget, 10)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
await inputNumber.vm.$emit('update:modelValue', 20)
|
||||
await inputNumber.setValue(20)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain(20)
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(20)
|
||||
})
|
||||
|
||||
it('handles negative values', () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ const props = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ default: 0 })
|
||||
const modelValue = props.widget.value()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
|
||||
@@ -89,8 +89,11 @@ const buttonTooltip = computed(() => {
|
||||
:show-buttons="!buttonsDisabled"
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'[&>input]:bg-transparent [&>input]:border-0 [&>input]:truncate [&>input]:min-w-[4ch]'
|
||||
class: cn(
|
||||
'[&>input]:bg-transparent [&>input]:border-0',
|
||||
'[&>input]:truncate [&>input]:min-w-[4ch]',
|
||||
$slots.default && '[&>input]:pr-7'
|
||||
)
|
||||
},
|
||||
decrementButton: {
|
||||
class: 'w-8 border-0'
|
||||
@@ -107,6 +110,9 @@ const buttonTooltip = computed(() => {
|
||||
<span class="pi pi-minus text-sm" />
|
||||
</template>
|
||||
</InputNumber>
|
||||
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5">
|
||||
<slot />
|
||||
</div>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
@@ -13,12 +14,13 @@ function createMockWidget(
|
||||
options: SimplifiedWidget['options'] = {},
|
||||
callback?: (value: number) => void
|
||||
): SimplifiedWidget<number> {
|
||||
const valueRef = ref(value)
|
||||
if (callback) watch(valueRef, callback)
|
||||
return {
|
||||
name: 'test_slider',
|
||||
type: 'float',
|
||||
value,
|
||||
options: { min: 0, max: 100, step: 1, precision: 0, ...options },
|
||||
callback
|
||||
value: () => valueRef,
|
||||
options: { min: 0, max: 100, step: 1, precision: 0, ...options }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import { useNumberStepCalculation } from '../composables/useNumberStepCalculation'
|
||||
import { useNumberWidgetButtonPt } from '../composables/useNumberWidgetButtonPt'
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
@@ -47,7 +48,7 @@ const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ default: 0 })
|
||||
const modelValue = widget.value()
|
||||
|
||||
const timesEmptied = ref(0)
|
||||
|
||||
@@ -56,7 +57,7 @@ const updateLocalValue = (newValue: number[] | undefined): void => {
|
||||
}
|
||||
|
||||
const handleNumberInputUpdate = (newValue: number | undefined) => {
|
||||
if (newValue) {
|
||||
if (newValue !== undefined) {
|
||||
updateLocalValue([newValue])
|
||||
return
|
||||
}
|
||||
@@ -67,33 +68,11 @@ const filteredProps = computed(() =>
|
||||
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
// Get the precision value for proper number formatting
|
||||
const precision = computed(() => {
|
||||
const p = widget.options?.precision
|
||||
// Treat negative or non-numeric precision as undefined
|
||||
return typeof p === 'number' && p >= 0 ? p : undefined
|
||||
})
|
||||
const p = widget.options?.precision
|
||||
const precision = typeof p === 'number' && p >= 0 ? p : undefined
|
||||
|
||||
// Calculate the step value based on precision or widget options
|
||||
const stepValue = computed(() => {
|
||||
// Use step2 (correct input spec value) instead of step (legacy 10x value)
|
||||
if (widget.options?.step2 !== undefined) {
|
||||
return widget.options.step2
|
||||
}
|
||||
|
||||
// Otherwise, derive from precision
|
||||
if (precision.value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (precision.value === 0) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// For precision > 0, step = 1 / (10^precision)
|
||||
// precision 1 → 0.1, precision 2 → 0.01, etc.
|
||||
return 1 / Math.pow(10, precision.value)
|
||||
})
|
||||
const stepValue = useNumberStepCalculation(widget.options, precision, true)
|
||||
|
||||
const sliderNumberPt = useNumberWidgetButtonPt({
|
||||
roundedLeft: true,
|
||||
|
||||
@@ -4,6 +4,7 @@ import InputText from 'primevue/inputtext'
|
||||
import type { InputTextProps } from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -14,13 +15,16 @@ describe('WidgetInputText Value Binding', () => {
|
||||
value: string = 'default',
|
||||
options: Partial<InputTextProps> = {},
|
||||
callback?: (value: string) => void
|
||||
): SimplifiedWidget<string> => ({
|
||||
name: 'test_input',
|
||||
type: 'string',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
})
|
||||
): SimplifiedWidget<string> => {
|
||||
const valueRef = ref(value)
|
||||
if (callback) watch(valueRef, (v) => callback(v))
|
||||
return {
|
||||
name: 'test_input',
|
||||
type: 'string',
|
||||
value: () => valueRef,
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string>,
|
||||
@@ -54,86 +58,26 @@ describe('WidgetInputText Value Binding', () => {
|
||||
return input
|
||||
}
|
||||
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when input value changes on blur', async () => {
|
||||
const widget = createMockWidget('hello')
|
||||
const wrapper = mountComponent(widget, 'hello')
|
||||
|
||||
await setInputValueAndTrigger(wrapper, 'world', 'blur')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('world')
|
||||
})
|
||||
|
||||
it('emits Vue event when enter key is pressed', async () => {
|
||||
const widget = createMockWidget('initial')
|
||||
const wrapper = mountComponent(widget, 'initial')
|
||||
|
||||
await setInputValueAndTrigger(wrapper, 'new value', 'keydown.enter')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('new value')
|
||||
})
|
||||
|
||||
describe('Widget Value Callbacks', () => {
|
||||
it('handles empty string values', async () => {
|
||||
const widget = createMockWidget('something')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('something', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'something')
|
||||
|
||||
await setInputValueAndTrigger(wrapper, '')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('')
|
||||
})
|
||||
|
||||
it('handles special characters correctly', async () => {
|
||||
const widget = createMockWidget('normal')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('normal', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'normal')
|
||||
|
||||
const specialText = 'special @#$%^&*()[]{}|\\:";\'<>?,./'
|
||||
await setInputValueAndTrigger(wrapper, specialText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain(specialText)
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createMockWidget('test', {}, undefined)
|
||||
const wrapper = mountComponent(widget, 'test')
|
||||
|
||||
await setInputValueAndTrigger(wrapper, 'new value')
|
||||
|
||||
// Should still emit Vue event
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('new value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('emits update:modelValue on blur', async () => {
|
||||
const widget = createMockWidget('original')
|
||||
const wrapper = mountComponent(widget, 'original')
|
||||
|
||||
await setInputValueAndTrigger(wrapper, 'updated')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('updated')
|
||||
})
|
||||
|
||||
it('emits update:modelValue on enter key', async () => {
|
||||
const widget = createMockWidget('start')
|
||||
const wrapper = mountComponent(widget, 'start')
|
||||
|
||||
await setInputValueAndTrigger(wrapper, 'finish', 'keydown.enter')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('finish')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(specialText)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -154,27 +98,25 @@ describe('WidgetInputText Value Binding', () => {
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles very long strings', async () => {
|
||||
const widget = createMockWidget('short')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('short', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'short')
|
||||
|
||||
const longString = 'a'.repeat(10000)
|
||||
await setInputValueAndTrigger(wrapper, longString)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain(longString)
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(longString)
|
||||
})
|
||||
|
||||
it('handles unicode characters', async () => {
|
||||
const widget = createMockWidget('ascii')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('ascii', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'ascii')
|
||||
|
||||
const unicodeText = '🎨 Unicode: αβγ 中文 العربية 🚀'
|
||||
await setInputValueAndTrigger(wrapper, unicodeText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain(unicodeText)
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(unicodeText)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ const props = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '' })
|
||||
const modelValue = props.widget.value()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
@@ -28,13 +28,16 @@ describe('WidgetMarkdown Dual Mode Display', () => {
|
||||
value: string = '# Default Heading\nSome **bold** text.',
|
||||
options: Record<string, unknown> = {},
|
||||
callback?: (value: string) => void
|
||||
): SimplifiedWidget<string> => ({
|
||||
name: 'test_markdown',
|
||||
type: 'string',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
})
|
||||
): SimplifiedWidget<string> => {
|
||||
const valueRef = ref(value)
|
||||
if (callback) watch(valueRef, (v) => callback(v))
|
||||
return {
|
||||
name: 'test_markdown',
|
||||
type: 'string',
|
||||
value: () => valueRef,
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string>,
|
||||
@@ -209,8 +212,9 @@ describe('WidgetMarkdown Dual Mode Display', () => {
|
||||
})
|
||||
|
||||
describe('Value Updates', () => {
|
||||
it('emits update:modelValue when textarea content changes', async () => {
|
||||
const widget = createMockWidget('# Original')
|
||||
it('triggers callback when textarea content changes', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('# Original', {}, callback)
|
||||
const wrapper = mountComponent(widget, '# Original')
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
@@ -219,9 +223,7 @@ describe('WidgetMarkdown Dual Mode Display', () => {
|
||||
await textarea.setValue('# Updated Content')
|
||||
await textarea.trigger('input')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![emitted!.length - 1]).toEqual(['# Updated Content'])
|
||||
expect(callback).toHaveBeenLastCalledWith('# Updated Content')
|
||||
})
|
||||
|
||||
it('renders updated HTML after value change and blur', async () => {
|
||||
@@ -239,38 +241,6 @@ describe('WidgetMarkdown Dual Mode Display', () => {
|
||||
expect(displayDiv.html()).toContain('<h2>New Heading</h2>')
|
||||
expect(displayDiv.html()).toContain('<strong>bold</strong>')
|
||||
})
|
||||
|
||||
it('emits update:modelValue for callback handling at parent level', async () => {
|
||||
const widget = createMockWidget('# Test', {})
|
||||
const wrapper = mountComponent(widget, '# Test')
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('# Changed')
|
||||
await textarea.trigger('input')
|
||||
|
||||
// The widget should emit the change for parent (NodeWidgets) to handle callbacks
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![emitted!.length - 1]).toEqual(['# Changed'])
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createMockWidget('# Test', {}, undefined)
|
||||
const wrapper = mountComponent(widget, '# Test')
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('# Changed')
|
||||
|
||||
// Should not throw error and should still emit Vue event
|
||||
await expect(textarea.trigger('input')).resolves.not.toThrow()
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Complex Markdown Rendering', () => {
|
||||
@@ -337,8 +307,9 @@ Another line with more content.`
|
||||
})
|
||||
|
||||
it('handles unicode characters', async () => {
|
||||
const callback = vi.fn()
|
||||
const unicode = '# Unicode: 🎨 αβγ 中文 العربية 🚀'
|
||||
const widget = createMockWidget(unicode)
|
||||
const widget = createMockWidget(unicode, {}, callback)
|
||||
const wrapper = mountComponent(widget, unicode)
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
@@ -348,9 +319,7 @@ Another line with more content.`
|
||||
await textarea.setValue(unicode + ' more unicode')
|
||||
await textarea.trigger('input')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![emitted!.length - 1]).toEqual([unicode + ' more unicode'])
|
||||
expect(callback).toHaveBeenLastCalledWith(unicode + ' more unicode')
|
||||
})
|
||||
|
||||
it('handles rapid edit mode toggling', async () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '' })
|
||||
const modelValue = widget.value()
|
||||
|
||||
// State
|
||||
const isEditing = ref(false)
|
||||
|
||||
@@ -92,6 +92,7 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import { useAudioPlayback } from '../composables/audio/useAudioPlayback'
|
||||
import { useAudioRecorder } from '../composables/audio/useAudioRecorder'
|
||||
@@ -99,8 +100,9 @@ import { useAudioWaveform } from '../composables/audio/useAudioWaveform'
|
||||
import { formatTime } from '../utils/audioUtils'
|
||||
|
||||
const props = defineProps<{
|
||||
readonly?: boolean
|
||||
nodeId: string
|
||||
widget: SimplifiedWidget<string>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
// Audio element ref
|
||||
@@ -152,7 +154,7 @@ const { isPlaying, audioElementKey } = playback
|
||||
// Computed for waveform animation
|
||||
const isWaveformActive = computed(() => isRecording.value || isPlaying.value)
|
||||
|
||||
const modelValue = defineModel<string>({ default: '' })
|
||||
const modelValue = props.widget.value()
|
||||
|
||||
const litegraphNode = computed(() => {
|
||||
if (!props.nodeId || !app.canvas.graph) return null
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
:is-asset-mode="isAssetMode"
|
||||
:default-layout-mode="defaultLayoutMode"
|
||||
/>
|
||||
<WidgetWithControl
|
||||
v-else-if="widget.controlWidget"
|
||||
:comp="WidgetSelectDefault"
|
||||
:widget="widget as SimplifiedControlWidget<string>"
|
||||
/>
|
||||
<WidgetSelectDefault v-else v-model="modelValue" :widget />
|
||||
</template>
|
||||
|
||||
@@ -20,11 +25,15 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
|
||||
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
||||
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
|
||||
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type {
|
||||
SimplifiedControlWidget,
|
||||
SimplifiedWidget
|
||||
} from '@/types/simplifiedWidget'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -32,7 +41,7 @@ const props = defineProps<{
|
||||
nodeType?: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string | undefined>()
|
||||
const modelValue = props.widget.value()
|
||||
|
||||
const comboSpec = computed<ComboInputSpec | undefined>(() => {
|
||||
if (props.widget.spec && isComboInputSpec(props.widget.spec)) {
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget>
|
||||
<Select
|
||||
v-model="modelValue"
|
||||
:invalid
|
||||
:filter="selectOptions.length > 4"
|
||||
:auto-filter-focus="selectOptions.length > 4"
|
||||
:options="selectOptions"
|
||||
v-bind="combinedProps"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
|
||||
:aria-label="widget.name"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs',
|
||||
dropdown: 'w-8',
|
||||
label: 'truncate min-w-[4ch]',
|
||||
overlay: 'w-fit min-w-full'
|
||||
}"
|
||||
data-capture-wheel="true"
|
||||
/>
|
||||
<div class="relative">
|
||||
<Select
|
||||
v-model="modelValue"
|
||||
:invalid
|
||||
:filter="selectOptions.length > 4"
|
||||
:auto-filter-focus="selectOptions.length > 4"
|
||||
:options="selectOptions"
|
||||
v-bind="combinedProps"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
|
||||
:aria-label="widget.name"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs',
|
||||
dropdown: 'w-8',
|
||||
label: cn('truncate min-w-[4ch]', slots.default && 'mr-5'),
|
||||
overlay: 'w-fit min-w-full'
|
||||
}"
|
||||
data-capture-wheel="true"
|
||||
/>
|
||||
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
@@ -36,17 +41,15 @@ import {
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
interface Props {
|
||||
widget: SimplifiedWidget<string | undefined>
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const modelValue = defineModel<string | undefined>({
|
||||
default(props: Props) {
|
||||
return props.widget.options?.values?.[0] || ''
|
||||
}
|
||||
})
|
||||
const modelValue = props.widget.value()
|
||||
|
||||
// Transform compatibility props for overlay positioning
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
@@ -43,11 +43,7 @@ provide(
|
||||
computed(() => props.assetKind)
|
||||
)
|
||||
|
||||
const modelValue = defineModel<string | undefined>({
|
||||
default(props: Props) {
|
||||
return props.widget.options?.values?.[0] || ''
|
||||
}
|
||||
})
|
||||
const modelValue = props.widget.value()
|
||||
|
||||
const toastStore = useToastStore()
|
||||
const queueStore = useQueueStore()
|
||||
@@ -313,11 +309,6 @@ async function handleFilesUpdate(files: File[]) {
|
||||
|
||||
// 3. Update widget value to the first uploaded file
|
||||
modelValue.value = uploadedPaths[0]
|
||||
|
||||
// 4. Trigger callback to notify underlying LiteGraph widget
|
||||
if (props.widget.callback) {
|
||||
props.widget.callback(uploadedPaths[0])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
toastStore.addAlert(`Upload failed: ${error}`)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -12,12 +13,13 @@ function createMockWidget(
|
||||
options: SimplifiedWidget['options'] = {},
|
||||
callback?: (value: string) => void
|
||||
): SimplifiedWidget<string> {
|
||||
const valueRef = ref(value)
|
||||
if (callback) watch(valueRef, (v) => callback(v))
|
||||
return {
|
||||
name: 'test_textarea',
|
||||
type: 'string',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
value: () => valueRef,
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,98 +60,47 @@ async function setTextareaValueAndTrigger(
|
||||
}
|
||||
|
||||
describe('WidgetTextarea Value Binding', () => {
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when textarea value changes on blur', async () => {
|
||||
const widget = createMockWidget('hello')
|
||||
const wrapper = mountComponent(widget, 'hello')
|
||||
|
||||
await setTextareaValueAndTrigger(wrapper, 'world', 'blur')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain('world')
|
||||
})
|
||||
|
||||
it('emits Vue event when textarea value changes on input', async () => {
|
||||
const widget = createMockWidget('initial')
|
||||
describe('Widget Value Callbacks', () => {
|
||||
it('triggers callback when textarea value changes on input', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('initial', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'initial')
|
||||
|
||||
await setTextareaValueAndTrigger(wrapper, 'new content', 'input')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain('new content')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('new content')
|
||||
})
|
||||
|
||||
it('handles empty string values', async () => {
|
||||
const widget = createMockWidget('something')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('something', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'something')
|
||||
|
||||
await setTextareaValueAndTrigger(wrapper, '')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain('')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('')
|
||||
})
|
||||
|
||||
it('handles multiline text correctly', async () => {
|
||||
const widget = createMockWidget('single line')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('single line', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'single line')
|
||||
|
||||
const multilineText = 'Line 1\nLine 2\nLine 3'
|
||||
await setTextareaValueAndTrigger(wrapper, multilineText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain(multilineText)
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(multilineText)
|
||||
})
|
||||
|
||||
it('handles special characters correctly', async () => {
|
||||
const widget = createMockWidget('normal')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('normal', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'normal')
|
||||
|
||||
const specialText = 'special @#$%^&*()[]{}|\\:";\'<>?,./'
|
||||
await setTextareaValueAndTrigger(wrapper, specialText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain(specialText)
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createMockWidget('test', {}, undefined)
|
||||
const wrapper = mountComponent(widget, 'test')
|
||||
|
||||
await setTextareaValueAndTrigger(wrapper, 'new value')
|
||||
|
||||
// Should still emit Vue event
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain('new value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('emits update:modelValue on blur', async () => {
|
||||
const widget = createMockWidget('original')
|
||||
const wrapper = mountComponent(widget, 'original')
|
||||
|
||||
await setTextareaValueAndTrigger(wrapper, 'updated')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain('updated')
|
||||
})
|
||||
|
||||
it('emits update:modelValue on input', async () => {
|
||||
const widget = createMockWidget('start')
|
||||
const wrapper = mountComponent(widget, 'start')
|
||||
|
||||
await setTextareaValueAndTrigger(wrapper, 'finish', 'input')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain('finish')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(specialText)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -199,39 +150,36 @@ describe('WidgetTextarea Value Binding', () => {
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles very long text', async () => {
|
||||
const widget = createMockWidget('short')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('short', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'short')
|
||||
|
||||
const longText = 'a'.repeat(10000)
|
||||
await setTextareaValueAndTrigger(wrapper, longText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain(longText)
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(longText)
|
||||
})
|
||||
|
||||
it('handles unicode characters', async () => {
|
||||
const widget = createMockWidget('ascii')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('ascii', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'ascii')
|
||||
|
||||
const unicodeText = '🎨 Unicode: αβγ 中文 العربية 🚀'
|
||||
await setTextareaValueAndTrigger(wrapper, unicodeText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain(unicodeText)
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(unicodeText)
|
||||
})
|
||||
|
||||
it('handles text with tabs and spaces', async () => {
|
||||
const widget = createMockWidget('normal')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('normal', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'normal')
|
||||
|
||||
const formattedText = '\tIndented line\n Spaced line\n\t\tDouble indent'
|
||||
await setTextareaValueAndTrigger(wrapper, formattedText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain(formattedText)
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(formattedText)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@ const { widget, placeholder = '' } = defineProps<{
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '' })
|
||||
const modelValue = widget.value()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(widget.options, INPUT_EXCLUDED_PROPS)
|
||||
|
||||
@@ -3,6 +3,7 @@ import PrimeVue from 'primevue/config'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import type { ToggleSwitchProps } from 'primevue/toggleswitch'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -13,13 +14,16 @@ describe('WidgetToggleSwitch Value Binding', () => {
|
||||
value: boolean = false,
|
||||
options: Partial<ToggleSwitchProps> = {},
|
||||
callback?: (value: boolean) => void
|
||||
): SimplifiedWidget<boolean> => ({
|
||||
name: 'test_toggle',
|
||||
type: 'boolean',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
})
|
||||
): SimplifiedWidget<boolean> => {
|
||||
const valueRef = ref(value)
|
||||
if (callback) watch(valueRef, (v) => callback(v))
|
||||
return {
|
||||
name: 'test_toggle',
|
||||
type: 'boolean',
|
||||
value: () => valueRef,
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<boolean>,
|
||||
@@ -39,48 +43,6 @@ describe('WidgetToggleSwitch Value Binding', () => {
|
||||
})
|
||||
}
|
||||
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when toggled from false to true', async () => {
|
||||
const widget = createMockWidget(false)
|
||||
const wrapper = mountComponent(widget, false)
|
||||
|
||||
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
|
||||
await toggle.setValue(true)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain(true)
|
||||
})
|
||||
|
||||
it('emits Vue event when toggled from true to false', async () => {
|
||||
const widget = createMockWidget(true)
|
||||
const wrapper = mountComponent(widget, true)
|
||||
|
||||
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
|
||||
await toggle.setValue(false)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain(false)
|
||||
})
|
||||
|
||||
it('handles value changes gracefully', async () => {
|
||||
const widget = createMockWidget(false)
|
||||
const wrapper = mountComponent(widget, false)
|
||||
|
||||
// Should not throw when changing values
|
||||
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
|
||||
await toggle.setValue(true)
|
||||
await toggle.setValue(false)
|
||||
|
||||
// Should emit events for all changes
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toHaveLength(2)
|
||||
expect(emitted![0]).toContain(true)
|
||||
expect(emitted![1]).toContain(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('renders toggle switch component', () => {
|
||||
const widget = createMockWidget(false)
|
||||
@@ -109,27 +71,9 @@ describe('WidgetToggleSwitch Value Binding', () => {
|
||||
})
|
||||
|
||||
describe('Multiple Value Changes', () => {
|
||||
it('handles rapid toggling correctly', async () => {
|
||||
const widget = createMockWidget(false)
|
||||
const wrapper = mountComponent(widget, false)
|
||||
|
||||
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
|
||||
|
||||
// Rapid toggle sequence
|
||||
await toggle.setValue(true)
|
||||
await toggle.setValue(false)
|
||||
await toggle.setValue(true)
|
||||
|
||||
// Should have emitted 3 Vue events with correct values
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toHaveLength(3)
|
||||
expect(emitted![0]).toContain(true)
|
||||
expect(emitted![1]).toContain(false)
|
||||
expect(emitted![2]).toContain(true)
|
||||
})
|
||||
|
||||
it('maintains state consistency during multiple changes', async () => {
|
||||
const widget = createMockWidget(false)
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget(false, {}, callback)
|
||||
const wrapper = mountComponent(widget, false)
|
||||
|
||||
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
|
||||
@@ -140,13 +84,12 @@ describe('WidgetToggleSwitch Value Binding', () => {
|
||||
await toggle.setValue(true)
|
||||
await toggle.setValue(false)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toHaveLength(4)
|
||||
expect(callback).toHaveBeenCalledTimes(4)
|
||||
// Verify alternating pattern
|
||||
expect(emitted![0]).toContain(true)
|
||||
expect(emitted![1]).toContain(false)
|
||||
expect(emitted![2]).toContain(true)
|
||||
expect(emitted![3]).toContain(false)
|
||||
expect(callback).toHaveBeenNthCalledWith(1, true)
|
||||
expect(callback).toHaveBeenNthCalledWith(2, false)
|
||||
expect(callback).toHaveBeenNthCalledWith(3, true)
|
||||
expect(callback).toHaveBeenNthCalledWith(4, false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<boolean>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<boolean>()
|
||||
const modelValue = widget.value()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts" generic="T extends WidgetValue">
|
||||
import Button from 'primevue/button'
|
||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type {
|
||||
ControlOptions,
|
||||
SimplifiedControlWidget,
|
||||
WidgetValue
|
||||
} from '@/types/simplifiedWidget'
|
||||
|
||||
const NumberControlPopover = defineAsyncComponent(
|
||||
() => import('./NumberControlPopover.vue')
|
||||
)
|
||||
|
||||
function useControlButtonIcon(controlMode: Ref<ControlOptions>) {
|
||||
return computed(() => {
|
||||
switch (controlMode.value) {
|
||||
case 'increment':
|
||||
return 'pi pi-plus'
|
||||
case 'decrement':
|
||||
return 'pi pi-minus'
|
||||
case 'fixed':
|
||||
return 'icon-[lucide--pencil-off]'
|
||||
default:
|
||||
return 'icon-[lucide--shuffle]'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedControlWidget<T>
|
||||
comp: unknown
|
||||
}>()
|
||||
|
||||
const popover = ref()
|
||||
|
||||
const controlButtonIcon = useControlButtonIcon(props.widget.controlWidget())
|
||||
|
||||
const togglePopover = (event: Event) => {
|
||||
popover.value.toggle(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="comp" v-bind="$attrs" :widget>
|
||||
<Button
|
||||
variant="link"
|
||||
size="small"
|
||||
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
|
||||
@pointerdown.stop.prevent="togglePopover"
|
||||
>
|
||||
<i :class="`${controlButtonIcon} text-blue-100 text-xs size-3.5`" />
|
||||
</Button>
|
||||
</component>
|
||||
<NumberControlPopover ref="popover" :control-widget="widget.controlWidget" />
|
||||
</template>
|
||||
@@ -28,7 +28,7 @@ const hideLayoutField = inject<boolean>('hideLayoutField', false)
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'cursor-default min-w-0 rounded-lg space-y-1 focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
|
||||
'cursor-default min-w-0 rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
|
||||
widget.borderStyle
|
||||
)
|
||||
"
|
||||
|
||||
@@ -13,16 +13,16 @@ import {
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type {
|
||||
ComboInputSpec,
|
||||
InputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||
import { addValueControlWidgets } from '@/scripts/widgets'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { addValueControlWidgets } from '@/scripts/widgets'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
@@ -69,6 +69,16 @@ const addMultiSelectWidget = (
|
||||
addWidget(node, widget as BaseDOMWidget<object | string>)
|
||||
// TODO: Add remote support to multi-select widget
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003
|
||||
if (inputSpec.control_after_generate) {
|
||||
widget.linkedWidgets = addValueControlWidgets(
|
||||
node,
|
||||
widget,
|
||||
'fixed',
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
)
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { isFloatInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { addValueControlWidget } from '@/scripts/widgets'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
|
||||
function onFloatValueChange(this: INumericWidget, v: number) {
|
||||
const round = this.options.round
|
||||
@@ -55,7 +57,7 @@ export const useFloatWidget = () => {
|
||||
|
||||
/** Assertion {@link inputSpec.default} */
|
||||
const defaultValue = (inputSpec.default as number | undefined) ?? 0
|
||||
return node.addWidget(
|
||||
const widget = node.addWidget(
|
||||
widgetType,
|
||||
inputSpec.name,
|
||||
defaultValue,
|
||||
@@ -73,6 +75,20 @@ export const useFloatWidget = () => {
|
||||
precision
|
||||
}
|
||||
)
|
||||
|
||||
if (inputSpec.control_after_generate) {
|
||||
const controlWidget = addValueControlWidget(
|
||||
node,
|
||||
widget,
|
||||
'fixed',
|
||||
undefined,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
)
|
||||
widget.linkedWidgets = [controlWidget]
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import { isIntInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { addValueControlWidget } from '@/scripts/widgets'
|
||||
import { isIntInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { addValueControlWidget } from '@/scripts/widgets'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
|
||||
function onValueChange(this: INumericWidget, v: number) {
|
||||
// For integers, always round to the nearest step
|
||||
@@ -69,14 +69,10 @@ export const useIntWidget = () => {
|
||||
|
||||
const controlAfterGenerate =
|
||||
inputSpec.control_after_generate ??
|
||||
/**
|
||||
* Compatibility with legacy node convention. Int input with name
|
||||
* 'seed' or 'noise_seed' get automatically added a control widget.
|
||||
*/
|
||||
['seed', 'noise_seed'].includes(inputSpec.name)
|
||||
|
||||
if (controlAfterGenerate) {
|
||||
const seedControl = addValueControlWidget(
|
||||
const controlWidget = addValueControlWidget(
|
||||
node,
|
||||
widget,
|
||||
'randomize',
|
||||
@@ -84,7 +80,7 @@ export const useIntWidget = () => {
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
)
|
||||
widget.linkedWidgets = [seedControl]
|
||||
widget.linkedWidgets = [controlWidget]
|
||||
}
|
||||
|
||||
return widget
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
interface NumberWidgetOptions {
|
||||
step2?: number
|
||||
precision?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared composable for calculating step values in number input widgets
|
||||
* Handles both explicit step2 values and precision-derived steps
|
||||
*/
|
||||
export function useNumberStepCalculation(
|
||||
options: NumberWidgetOptions | undefined,
|
||||
precisionArg: MaybeRefOrGetter<number | undefined>,
|
||||
returnUndefinedForDefault = false
|
||||
) {
|
||||
return computed(() => {
|
||||
const precision = toValue(precisionArg)
|
||||
// Use step2 (correct input spec value) instead of step (legacy 10x value)
|
||||
if (options?.step2 !== undefined) {
|
||||
return Number(options.step2)
|
||||
}
|
||||
|
||||
if (precision === undefined) {
|
||||
return returnUndefinedForDefault ? undefined : 0
|
||||
}
|
||||
|
||||
if (precision === 0) return 1
|
||||
|
||||
// For precision > 0, step = 1 / (10^precision)
|
||||
const step = 1 / Math.pow(10, precision)
|
||||
return returnUndefinedForDefault ? step : Number(step.toFixed(precision))
|
||||
})
|
||||
}
|
||||
@@ -1468,7 +1468,21 @@ export class ComfyApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Use parameters as fallback when no workflow exists
|
||||
if (prompt) {
|
||||
try {
|
||||
const promptObj =
|
||||
typeof prompt === 'string' ? JSON.parse(prompt) : prompt
|
||||
if (this.isApiJson(promptObj)) {
|
||||
this.loadApiJson(promptObj, fileName)
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse prompt:', err)
|
||||
}
|
||||
// Fall through to parameters as a last resort
|
||||
}
|
||||
|
||||
// Use parameters strictly as the final fallback
|
||||
if (parameters) {
|
||||
// Note: Not putting this in `importA1111` as it is mostly not used
|
||||
// by external callers, and `importA1111` has no access to `app`.
|
||||
@@ -1481,18 +1495,25 @@ export class ComfyApp {
|
||||
return
|
||||
}
|
||||
|
||||
if (prompt) {
|
||||
const promptObj = typeof prompt === 'string' ? JSON.parse(prompt) : prompt
|
||||
this.loadApiJson(promptObj, fileName)
|
||||
return
|
||||
}
|
||||
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
|
||||
// @deprecated
|
||||
isApiJson(data: unknown) {
|
||||
return _.isObject(data) && Object.values(data).every((v) => v.class_type)
|
||||
isApiJson(data: unknown): data is ComfyApiWorkflow {
|
||||
if (!_.isObject(data) || Array.isArray(data)) {
|
||||
return false
|
||||
}
|
||||
if (Object.keys(data).length === 0) return false
|
||||
|
||||
return Object.values(data).every((node) => {
|
||||
if (!node || typeof node !== 'object' || Array.isArray(node)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { class_type: classType, inputs } = node as Record<string, unknown>
|
||||
const inputsIsRecord = _.isObject(inputs) && !Array.isArray(inputs)
|
||||
return typeof classType === 'string' && inputsIsRecord
|
||||
})
|
||||
}
|
||||
|
||||
loadApiJson(apiData: ComfyApiWorkflow, fileName: string) {
|
||||
|
||||
@@ -17,7 +17,6 @@ export function clone<T>(obj: T): T {
|
||||
}
|
||||
|
||||
/**
|
||||
* @knipIgnoreUnusedButUsedByCustomNodes
|
||||
* @deprecated Use `applyTextReplacements` from `@/utils/searchAndReplace` instead
|
||||
* There are external callers to this function, so we need to keep it for now
|
||||
*/
|
||||
@@ -25,7 +24,6 @@ export function applyTextReplacements(app: ComfyApp, value: string): string {
|
||||
return _applyTextReplacements(app.rootGraph, value)
|
||||
}
|
||||
|
||||
/** @knipIgnoreUnusedButUsedByCustomNodes */
|
||||
export async function addStylesheet(
|
||||
urlOrFile: string,
|
||||
relativeTo?: string
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Simplified widget interface for Vue-based node rendering
|
||||
* Removes all DOM manipulation and positioning concerns
|
||||
*/
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
/** Valid types for widget values */
|
||||
@@ -15,6 +17,23 @@ export type WidgetValue =
|
||||
| void
|
||||
| File[]
|
||||
|
||||
const CONTROL_OPTIONS = [
|
||||
'fixed',
|
||||
'increment',
|
||||
'decrement',
|
||||
'randomize'
|
||||
] as const
|
||||
export type ControlOptions = (typeof CONTROL_OPTIONS)[number]
|
||||
|
||||
function isControlOption(val: WidgetValue): val is ControlOptions {
|
||||
return CONTROL_OPTIONS.includes(val as ControlOptions)
|
||||
}
|
||||
|
||||
export function normalizeControlOption(val: WidgetValue): ControlOptions {
|
||||
if (isControlOption(val)) return val
|
||||
return 'randomize'
|
||||
}
|
||||
|
||||
export interface SimplifiedWidget<
|
||||
T extends WidgetValue = WidgetValue,
|
||||
O = Record<string, any>
|
||||
@@ -26,7 +45,7 @@ export interface SimplifiedWidget<
|
||||
type: string
|
||||
|
||||
/** Current value of the widget */
|
||||
value: T
|
||||
value: () => Ref<T>
|
||||
|
||||
borderStyle?: string
|
||||
|
||||
@@ -47,4 +66,8 @@ export interface SimplifiedWidget<
|
||||
|
||||
/** Optional input specification backing this widget */
|
||||
spec?: InputSpecV2
|
||||
|
||||
controlWidget?: () => Ref<ControlOptions>
|
||||
}
|
||||
export type SimplifiedControlWidget<T extends WidgetValue = WidgetValue> =
|
||||
SimplifiedWidget<T> & Required<Pick<SimplifiedWidget<T>, 'controlWidget'>>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memoize } from 'es-toolkit/compat'
|
||||
|
||||
type RGB = { r: number; g: number; b: number }
|
||||
export interface HSB {
|
||||
interface HSB {
|
||||
h: number
|
||||
s: number
|
||||
b: number
|
||||
|
||||
@@ -3,7 +3,7 @@ import { storeToRefs } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import Splitter from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
@@ -64,7 +64,7 @@ const isDesktop = isElectron()
|
||||
|
||||
const batchCountWidget = {
|
||||
options: { step2: 1, precision: 1, min: 1, max: 100 },
|
||||
value: 1,
|
||||
value: () => ref(1),
|
||||
name: t('Number of generations'),
|
||||
type: 'number'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type {
|
||||
SafeWidgetData,
|
||||
@@ -15,11 +16,10 @@ describe('NodeWidgets', () => {
|
||||
): SafeWidgetData => ({
|
||||
name: 'test_widget',
|
||||
type: 'combo',
|
||||
value: 'test_value',
|
||||
value: () => ref('test_value'),
|
||||
options: {
|
||||
values: ['option1', 'option2']
|
||||
},
|
||||
callback: undefined,
|
||||
spec: undefined,
|
||||
label: undefined,
|
||||
isDOMWidget: false,
|
||||
|
||||
@@ -4,6 +4,7 @@ import PrimeVue from 'primevue/config'
|
||||
import Select from 'primevue/select'
|
||||
import type { SelectProps } from 'primevue/select'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
@@ -51,17 +52,20 @@ describe('WidgetSelect Value Binding', () => {
|
||||
> = {},
|
||||
callback?: (value: string | undefined) => void,
|
||||
spec?: ComboInputSpec
|
||||
): SimplifiedWidget<string | undefined> => ({
|
||||
name: 'test_select',
|
||||
type: 'combo',
|
||||
value,
|
||||
options: {
|
||||
values: ['option1', 'option2', 'option3'],
|
||||
...options
|
||||
},
|
||||
callback,
|
||||
spec
|
||||
})
|
||||
): SimplifiedWidget<string | undefined> => {
|
||||
const valueRef = ref(value)
|
||||
if (callback) watch(valueRef, (v) => callback(v))
|
||||
return {
|
||||
name: 'test_select',
|
||||
type: 'combo',
|
||||
value: () => valueRef,
|
||||
options: {
|
||||
values: ['option1', 'option2', 'option3'],
|
||||
...options
|
||||
},
|
||||
spec
|
||||
}
|
||||
}
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
@@ -81,67 +85,57 @@ describe('WidgetSelect Value Binding', () => {
|
||||
})
|
||||
}
|
||||
|
||||
const setSelectValueAndEmit = async (
|
||||
const setSelectValue = async (
|
||||
wrapper: ReturnType<typeof mount>,
|
||||
value: string
|
||||
) => {
|
||||
const select = wrapper.findComponent({ name: 'Select' })
|
||||
await select.setValue(value)
|
||||
return wrapper.emitted('update:modelValue')
|
||||
}
|
||||
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when selection changes', async () => {
|
||||
const widget = createMockWidget('option1')
|
||||
describe('Widget Value Callbacks', () => {
|
||||
it('triggers callback when selection changes', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('option1', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, 'option2')
|
||||
await setSelectValue(wrapper, 'option2')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('option2')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('option2')
|
||||
})
|
||||
|
||||
it('emits string value for different options', async () => {
|
||||
const widget = createMockWidget('option1')
|
||||
it('handles string value for different options', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('option1', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, 'option3')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
// Should emit the string value
|
||||
expect(emitted![0]).toContain('option3')
|
||||
await setSelectValue(wrapper, 'option3')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('option3')
|
||||
})
|
||||
|
||||
it('handles custom option values', async () => {
|
||||
const customOptions = ['custom_a', 'custom_b', 'custom_c']
|
||||
const widget = createMockWidget('custom_a', { values: customOptions })
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget(
|
||||
'custom_a',
|
||||
{ values: customOptions },
|
||||
callback
|
||||
)
|
||||
const wrapper = mountComponent(widget, 'custom_a')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, 'custom_b')
|
||||
await setSelectValue(wrapper, 'custom_b')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('custom_b')
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createMockWidget('option1', {}, undefined)
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, 'option2')
|
||||
|
||||
// Should emit Vue event
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('option2')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('custom_b')
|
||||
})
|
||||
|
||||
it('handles value changes gracefully', async () => {
|
||||
const widget = createMockWidget('option1')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('option1', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, 'option2')
|
||||
await setSelectValue(wrapper, 'option2')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('option2')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('option2')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -172,43 +166,43 @@ describe('WidgetSelect Value Binding', () => {
|
||||
'option@#$%',
|
||||
'option/with\\slashes'
|
||||
]
|
||||
const widget = createMockWidget(specialOptions[0], {
|
||||
values: specialOptions
|
||||
})
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget(
|
||||
specialOptions[0],
|
||||
{
|
||||
values: specialOptions
|
||||
},
|
||||
callback
|
||||
)
|
||||
const wrapper = mountComponent(widget, specialOptions[0])
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, specialOptions[1])
|
||||
await setSelectValue(wrapper, specialOptions[1])
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain(specialOptions[1])
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(specialOptions[1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles selection of non-existent option gracefully', async () => {
|
||||
const widget = createMockWidget('option1')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('option1', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(
|
||||
wrapper,
|
||||
'non_existent_option'
|
||||
)
|
||||
await setSelectValue(wrapper, 'non_existent_option')
|
||||
|
||||
// Should still emit Vue event with the value
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('non_existent_option')
|
||||
// Should still trigger callback with the value
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('non_existent_option')
|
||||
})
|
||||
|
||||
it('handles numeric string options correctly', async () => {
|
||||
const callback = vi.fn()
|
||||
const numericOptions = ['1', '2', '10', '100']
|
||||
const widget = createMockWidget('1', { values: numericOptions })
|
||||
const widget = createMockWidget('1', { values: numericOptions }, callback)
|
||||
const wrapper = mountComponent(widget, '1')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, '100')
|
||||
await setSelectValue(wrapper, '100')
|
||||
|
||||
// Should maintain string type in emitted event
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('100')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('100')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -34,7 +35,7 @@ describe('WidgetSelect asset mode', () => {
|
||||
const createWidget = (): SimplifiedWidget<string | undefined> => ({
|
||||
name: 'ckpt_name',
|
||||
type: 'combo',
|
||||
value: undefined,
|
||||
value: () => ref(),
|
||||
options: {
|
||||
values: []
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
@@ -24,17 +25,22 @@ describe('WidgetSelectDropdown custom label mapping', () => {
|
||||
values?: string[]
|
||||
getOptionLabel?: (value: string | null) => string
|
||||
} = {},
|
||||
spec?: ComboInputSpec
|
||||
): SimplifiedWidget<string | undefined> => ({
|
||||
name: 'test_image_select',
|
||||
type: 'combo',
|
||||
value,
|
||||
options: {
|
||||
values: ['img_001.png', 'photo_abc.jpg', 'hash789.png'],
|
||||
...options
|
||||
},
|
||||
spec
|
||||
})
|
||||
spec?: ComboInputSpec,
|
||||
callback?: (value: string | undefined) => void
|
||||
): SimplifiedWidget<string | undefined> => {
|
||||
const valueRef = ref(value)
|
||||
if (callback) watch(valueRef, (v) => callback(v))
|
||||
return {
|
||||
name: 'test_image_select',
|
||||
type: 'combo',
|
||||
value: () => valueRef,
|
||||
options: {
|
||||
values: ['img_001.png', 'photo_abc.jpg', 'hash789.png'],
|
||||
...options
|
||||
},
|
||||
spec
|
||||
}
|
||||
}
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
@@ -102,26 +108,29 @@ describe('WidgetSelectDropdown custom label mapping', () => {
|
||||
expect(getOptionLabel).toHaveBeenCalledWith('hash789.png')
|
||||
})
|
||||
|
||||
it('emits original values when items with custom labels are selected', async () => {
|
||||
it('triggers callback with original values when items with custom labels are selected', async () => {
|
||||
const getOptionLabel = vi.fn((value: string | null) => {
|
||||
if (!value) return 'No file'
|
||||
return `Custom: ${value}`
|
||||
})
|
||||
|
||||
const widget = createMockWidget('img_001.png', {
|
||||
getOptionLabel
|
||||
})
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget(
|
||||
'img_001.png',
|
||||
{
|
||||
getOptionLabel
|
||||
},
|
||||
undefined,
|
||||
callback
|
||||
)
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
// Simulate selecting an item
|
||||
const selectedSet = new Set(['input-1']) // index 1 = photo_abc.jpg
|
||||
wrapper.vm.updateSelectedItems(selectedSet)
|
||||
|
||||
// Should emit the original value, not the custom label
|
||||
expect(wrapper.emitted('update:modelValue')).toBeDefined()
|
||||
expect(wrapper.emitted('update:modelValue')![0]).toEqual([
|
||||
'photo_abc.jpg'
|
||||
])
|
||||
await nextTick()
|
||||
expect(callback).toHaveBeenCalledWith('photo_abc.jpg')
|
||||
})
|
||||
|
||||
it('falls back to original value when label mapping fails', () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
getComponent,
|
||||
@@ -26,7 +28,18 @@ vi.mock('@/stores/queueStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the settings store for components that might use it
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn(() => 'before')
|
||||
})
|
||||
}))
|
||||
|
||||
describe('widgetRegistry', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
describe('getComponent', () => {
|
||||
// Test number type mappings
|
||||
describe('number types', () => {
|
||||
|
||||