Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,37 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock(
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input',
() => ({
ShortInput: ({
config,
value,
onChange,
onBlur,
disabled,
allowReferences,
}: {
config: { id: string }
value: string
onChange: (value: string) => void
onBlur: () => void
disabled: boolean
allowReferences?: boolean
}) => (
<input
id={config.id}
data-allow-references={String(allowReferences ?? true)}
value={value}
onChange={(event) => onChange(event.target.value)}
onBlur={onBlur}
disabled={disabled}
/>
),
})
)

import { RetrySettings } from './retry-settings'

const policy = { enabled: true as const, maxTries: 5, waitBetweenTriesMs: 2000 }
Expand All @@ -25,7 +56,15 @@ afterEach(() => {
function renderSettings(props: Partial<Parameters<typeof RetrySettings>[0]> = {}) {
const onChange = vi.fn()
act(() => {
root.render(<RetrySettings retry={policy} disabled={false} onChange={onChange} {...props} />)
root.render(
<RetrySettings
blockId='block-1'
retry={policy}
disabled={false}
onChange={onChange}
{...props}
/>
)
})
return { onChange }
}
Expand All @@ -46,6 +85,33 @@ describe('RetrySettings', () => {
expect(field('block-retry-max-tries')!.value).toBe('5')
})

it('commits the normalized value when the field loses focus', () => {
const { onChange } = renderSettings()
const maxTries = field('block-retry-max-tries')!
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!

act(() => {
setValue.call(maxTries, '2.7')
maxTries.dispatchEvent(new Event('input', { bubbles: true }))
})
expect(field('block-retry-max-tries')!.value).toBe('2.7')
expect(onChange).not.toHaveBeenCalled()

act(() => {
maxTries.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
})

expect(onChange).toHaveBeenCalledWith({ ...policy, maxTries: 2 })
expect(field('block-retry-max-tries')!.value).toBe('5')
})

it('turns off the reference pickers on the numeric fields', () => {
renderSettings()

expect(field('block-retry-max-tries')!.dataset.allowReferences).toBe('false')
expect(field('block-retry-wait')!.dataset.allowReferences).toBe('false')
})

it('renders only the switch while retry is off', () => {
renderSettings({ retry: { ...policy, enabled: false } })

Expand Down
Original file line number Diff line number Diff line change
@@ -1,45 +1,63 @@
'use client'

import { useState } from 'react'
import { ChipInput, FieldDivider, Label, Switch } from '@sim/emcn'
import { FieldDivider, Label, Switch } from '@sim/emcn'
import {
BLOCK_RETRY_DEFAULT_TRIES,
BLOCK_RETRY_DEFAULT_WAIT_MS,
type BlockRetryConfig,
normalizeBlockRetryTries,
normalizeBlockRetryWaitMs,
} from '@sim/workflow-types/workflow'
import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input'
import type { SubBlockConfig } from '@/blocks/types'

interface RetrySettingsProps {
blockId: string
retry: BlockRetryConfig | undefined
disabled: boolean
onChange: (retry: BlockRetryConfig) => void
}

interface RetryNumberFieldProps {
id: string
title: string
blockId: string
config: SubBlockConfig
value: number
disabled: boolean
normalize: (value: unknown) => number
onCommit: (value: number) => void
}

const MAX_TRIES_CONFIG = {
id: 'block-retry-max-tries',
title: 'Max tries',
type: 'short-input',
connectionDroppable: false,
} as const satisfies SubBlockConfig

const WAIT_CONFIG = {
id: 'block-retry-wait',
title: 'Wait between tries (ms)',
type: 'short-input',
connectionDroppable: false,
} as const satisfies SubBlockConfig

/**
* A bounded number field that commits on blur.
*
* Typed as text with a numeric input mode rather than `type='number'`: the
* native spinner is all that buys, and it does not fit the field chrome the rest
* of the panel uses. Bounds are applied on commit through the same normalizer
* execution uses, so the field cannot clamp differently from the executor.
* Renders the same `ShortInput` every other sub-block text field uses, so it
* carries the panel's field chrome. Retry values are plain numbers that never
* resolve references, so the reference pickers are turned off. Bounds are
* applied on commit through the same normalizer execution uses, so the field
* cannot clamp differently from the executor.
*
* The draft exists only while the field is being edited; clearing it on commit
* lets an external change — a collaborator's edit, or an undo — flow straight
* through on the next render with no resync.
*/
function RetryNumberField({
id,
title,
blockId,
config,
value,
disabled,
normalize,
Expand All @@ -57,15 +75,18 @@ function RetryNumberField({

return (
<div className='subblock-content flex flex-col gap-2.5'>
<Label htmlFor={id}>{title}</Label>
<ChipInput
id={id}
type='text'
inputMode='numeric'
<div className='flex items-center justify-between gap-1.5 pl-0.5'>
<Label className='flex items-baseline gap-1.5 whitespace-nowrap'>{config.title}</Label>
</div>
<ShortInput
blockId={blockId}
subBlockId={config.id}
config={config}
value={draft ?? String(value)}
onChange={(event) => setDraft(event.target.value)}
onChange={setDraft}
onBlur={commit}
disabled={disabled}
allowReferences={false}
/>
</div>
)
Expand All @@ -79,7 +100,7 @@ function RetryNumberField({
* with `enabled: false` when it is switched off, so turning it back on restores
* what was configured rather than snapping to the defaults.
*/
export function RetrySettings({ retry, disabled, onChange }: RetrySettingsProps) {
export function RetrySettings({ blockId, retry, disabled, onChange }: RetrySettingsProps) {
const enabled = retry?.enabled === true
const maxTries = retry?.maxTries ?? BLOCK_RETRY_DEFAULT_TRIES
const waitBetweenTriesMs = retry?.waitBetweenTriesMs ?? BLOCK_RETRY_DEFAULT_WAIT_MS
Expand All @@ -106,8 +127,8 @@ export function RetrySettings({ retry, disabled, onChange }: RetrySettingsProps)
<>
<div className='subblock-row'>
<RetryNumberField
id='block-retry-max-tries'
title='Max tries'
blockId={blockId}
config={MAX_TRIES_CONFIG}
value={maxTries}
disabled={disabled}
normalize={normalizeBlockRetryTries}
Expand All @@ -117,8 +138,8 @@ export function RetrySettings({ retry, disabled, onChange }: RetrySettingsProps)
</div>
<div className='subblock-row'>
<RetryNumberField
id='block-retry-wait'
title='Wait between tries (ms)'
blockId={blockId}
config={WAIT_CONFIG}
value={waitBetweenTriesMs}
disabled={disabled}
normalize={normalizeBlockRetryWaitMs}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ interface ShortInputProps {
/** Whether to hide the internal wand button (controlled by parent) */
hideInternalWand?: boolean
workflowSearchValuePath?: Array<string | number>
/** Whether the env-var and tag reference pickers may open. Defaults to `true`. */
allowReferences?: boolean
/** Called when the input loses focus. */
onBlur?: () => void
}

/**
Expand Down Expand Up @@ -81,6 +85,8 @@ export const ShortInput = memo(function ShortInput({
wandControlRef,
hideInternalWand = false,
workflowSearchValuePath = [],
allowReferences = true,
onBlur,
}: ShortInputProps) {
const activeSearchTarget = useActiveSearchTarget()
const [localContent, setLocalContent] = useState<string>('')
Expand Down Expand Up @@ -284,7 +290,8 @@ export const ShortInput = memo(function ShortInput({

const handleBlur = useCallback(() => {
setIsFocused(false)
}, [])
onBlur?.()
}, [onBlur])

// Expose wand control handlers to parent via ref
useImperativeHandle(
Expand Down Expand Up @@ -325,6 +332,7 @@ export const ShortInput = memo(function ShortInput({
disabled={disabled}
isStreaming={wandHook.isStreaming}
previewValue={previewValue}
allowReferences={allowReferences}
shouldForceEnvDropdown={shouldForceEnvDropdown}
shouldForceTagDropdown={shouldForceTagDropdown}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export interface SubBlockInputControllerProps {
onStreamingEnd?: () => void
/** Optional preview value for read-only preview. */
previewValue?: string | null
/** Whether the env-var and tag reference pickers may open. Defaults to `true`. */
allowReferences?: boolean
/**
* Optional callback to force/show the env var dropdown (e.g., API key fields).
* Return { show: true, searchTerm?: string } to override defaults.
Expand Down Expand Up @@ -82,6 +84,7 @@ export function SubBlockInputController(props: SubBlockInputControllerProps): Re
isStreaming,
onStreamingEnd,
previewValue,
allowReferences,
shouldForceEnvDropdown,
shouldForceTagDropdown,
children,
Expand All @@ -98,6 +101,7 @@ export function SubBlockInputController(props: SubBlockInputControllerProps): Re
isStreaming,
onStreamingEnd,
previewValue,
allowReferences,
shouldForceEnvDropdown,
shouldForceTagDropdown,
})
Expand Down
Loading
Loading