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 @@ -19,6 +19,11 @@ function RegisterModK({ handler }: { handler: () => void }) {
return null
}

function RegisterModKOutsideEditable({ handler }: { handler: () => void }) {
useRegisterGlobalCommands([{ id: 'search', shortcut: 'Mod+K', handler, allowInEditable: false }])
return null
}

let container: HTMLDivElement
let root: Root

Expand Down Expand Up @@ -87,3 +92,70 @@ describe('GlobalCommandsProvider owned-shortcut yielding', () => {
expect(handler).toHaveBeenCalledTimes(1)
})
})

describe('GlobalCommandsProvider editable guard', () => {
/**
* jsdom does not implement `isContentEditable`, so stub the browser's computed
* editability on the element the test focuses.
*/
function focusWithEditability(element: HTMLElement, isContentEditable: boolean) {
Object.defineProperty(element, 'isContentEditable', { value: isContentEditable })
element.focus()
}

it('skips a non-editable command when focus is in an input', () => {
const handler = vi.fn()
mount(
<GlobalCommandsProvider>
<RegisterModKOutsideEditable handler={handler} />
<input type='text' />
</GlobalCommandsProvider>
)
;(container.querySelector('input') as HTMLInputElement).focus()
pressModK()
expect(handler).not.toHaveBeenCalled()
})

it('skips a non-editable command when focus is in a contenteditable element', () => {
const handler = vi.fn()
mount(
<GlobalCommandsProvider>
<RegisterModKOutsideEditable handler={handler} />
<div contentEditable />
</GlobalCommandsProvider>
)
focusWithEditability(container.querySelector('[contenteditable]') as HTMLElement, true)
pressModK()
expect(handler).not.toHaveBeenCalled()
})

it('skips a non-editable command when focus is on a descendant of a contenteditable root', () => {
const handler = vi.fn()
mount(
<GlobalCommandsProvider>
<RegisterModKOutsideEditable handler={handler} />
<div contentEditable>
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: focusable stand-in for a node view inside the editor */}
<span tabIndex={0} />
</div>
</GlobalCommandsProvider>
)
focusWithEditability(container.querySelector('span') as HTMLElement, true)
pressModK()
expect(handler).not.toHaveBeenCalled()
})

it('fires a non-editable command when focus is in a contenteditable="false" element', () => {
const handler = vi.fn()
mount(
<GlobalCommandsProvider>
<RegisterModKOutsideEditable handler={handler} />
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: focusable stand-in for a read-only editor */}
<div contentEditable={false} tabIndex={0} />
</GlobalCommandsProvider>
)
focusWithEditability(container.querySelector('[contenteditable]') as HTMLElement, false)
pressModK()
expect(handler).toHaveBeenCalledTimes(1)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,17 @@ function shortcutSignature(parsed: ParsedShortcut, isMac: boolean): string {
return `${parsed.key}|${+ctrl}|${+meta}|${+!!parsed.shift}|${+!!parsed.alt}`
}

/**
* Whether `element` is an editable region for the purposes of the editable guard.
* `isContentEditable` is the browser's computed editability, so focusable descendants of a
* `contenteditable` root count as editable, while `contenteditable="false"` (e.g. a rich-text
* editor in read-only mode) does not — commands with `allowInEditable: false` still fire there.
*/
function isEditableElement(element: Element | null): boolean {
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) return true
return element instanceof HTMLElement && element.isContentEditable
}
Comment thread
waleedlatif1 marked this conversation as resolved.

/**
* Whether the focused element (or an ancestor) declares it owns `parsed` via a comma-separated
* `data-owned-shortcuts` attribute (e.g. a rich-text editor that binds `Mod+K` to links). Such a
Expand Down Expand Up @@ -141,14 +152,7 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
if (e.isComposing) return

for (const [, cmd] of registryRef.current) {
if (!cmd.allowInEditable) {
const ae = document.activeElement
const isEditable =
ae instanceof HTMLInputElement ||
ae instanceof HTMLTextAreaElement ||
ae?.hasAttribute('contenteditable')
if (isEditable) continue
}
if (!cmd.allowInEditable && isEditableElement(document.activeElement)) continue

if (matchesShortcut(e, cmd.parsed)) {
if (focusedElementOwnsShortcut(cmd.parsed, isMac)) continue
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type CommandId =
| 'clear-terminal-console'
| 'focus-toolbar-search'
| 'fit-to-view'
| 'toggle-sidebar'

/**
* Static metadata for a global command.
Expand Down Expand Up @@ -92,6 +93,11 @@ export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
shortcut: 'Mod+Shift+F',
allowInEditable: false,
},
'toggle-sidebar': {
id: 'toggle-sidebar',
shortcut: 'Mod+B',
allowInEditable: false,
},
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,18 +120,20 @@ export function SidebarTooltip({
label,
enabled,
side = 'right',
shortcut,
}: {
children: React.ReactElement
label: string
enabled: boolean
side?: 'right' | 'bottom'
shortcut?: string
}) {
if (!enabled) return children
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
<Tooltip.Content side={side}>
<p>{label}</p>
{shortcut ? <Tooltip.Shortcut keys={shortcut}>{label}</Tooltip.Shortcut> : <p>{label}</p>}
</Tooltip.Content>
</Tooltip.Root>
)
Expand Down Expand Up @@ -1234,6 +1236,12 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) {
handleCreateWorkflow()
},
},
{
id: 'toggle-sidebar',
handler: () => {
toggleCollapsed()
},
},
])
)

Expand Down Expand Up @@ -1284,7 +1292,12 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) {
isCollapsed={isCollapsed}
onExpandSidebar={toggleCollapsed}
/>
<SidebarTooltip label='Collapse sidebar' enabled={!isCollapsed} side='bottom'>
<SidebarTooltip
label='Collapse sidebar'
enabled={!isCollapsed}
side='bottom'
shortcut={isMac ? '⌘B' : 'Ctrl+B'}
>
<button
type='button'
onClick={toggleCollapsed}
Expand Down
96 changes: 96 additions & 0 deletions apps/sim/blocks/blocks/ashby.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { AshbyBlock } from './ashby'

describe('AshbyBlock', () => {
const buildParams = (operation: string, extra: Record<string, unknown>) => ({
operation,
...extra,
})

describe('alternateEmailAddresses parsing (create_candidate)', () => {
it('parses a comma-separated string into an array', () => {
const result = AshbyBlock.tools.config.params!(
buildParams('create_candidate', {
alternateEmailAddresses: 'a@x.com, b@x.com',
})
)
expect(result.alternateEmailAddresses).toEqual(['a@x.com', 'b@x.com'])
})

it('parses a JSON array string into an array', () => {
const result = AshbyBlock.tools.config.params!(
buildParams('create_candidate', {
alternateEmailAddresses: '["a@x.com","b@x.com"]',
})
)
expect(result.alternateEmailAddresses).toEqual(['a@x.com', 'b@x.com'])
})

it('omits the field entirely when empty', () => {
const result = AshbyBlock.tools.config.params!(
buildParams('create_candidate', { alternateEmailAddresses: '' })
)
expect(result.alternateEmailAddresses).toBeUndefined()
})
})

describe('socialLinks parsing (update_candidate)', () => {
it('parses a JSON array of link objects', () => {
const result = AshbyBlock.tools.config.params!(
buildParams('update_candidate', {
socialLinks: '[{"type":"Twitter","url":"https://twitter.com/jane"}]',
})
)
expect(result.socialLinks).toEqual([{ type: 'Twitter', url: 'https://twitter.com/jane' }])
})

it('throws instead of silently dropping the field when the JSON is malformed', () => {
// A silent [] here would let the Ashby update proceed without applying
// the requested links and with no error shown to the workflow author.
expect(() =>
AshbyBlock.tools.config.params!(
buildParams('update_candidate', { socialLinks: 'not json' })
)
).toThrow(/Invalid JSON in Ashby social links/)
})

it('throws when the parsed JSON is not an array', () => {
expect(() =>
AshbyBlock.tools.config.params!(
buildParams('update_candidate', { socialLinks: '{"type":"Twitter"}' })
)
).toThrow(/expected a JSON array/)
})
})

describe('wandConfig on array-shaped fields', () => {
it('does not request object-wrapped output for alternateEmailAddresses or socialLinks', () => {
// generationType 'json-object' makes the wand API append "the response
// must start with { and end with }", which conflicts with these fields'
// array-or-comma-separated parsers (parseStringListInput/parseSocialLinksInput).
const alternateEmailAddresses = AshbyBlock.subBlocks.find(
(s) => s.id === 'alternateEmailAddresses'
)
const socialLinks = AshbyBlock.subBlocks.find((s) => s.id === 'socialLinks')
expect(alternateEmailAddresses?.wandConfig?.generationType).not.toBe('json-object')
expect(socialLinks?.wandConfig?.generationType).not.toBe('json-object')
})
})

describe('list_applications candidateId filter', () => {
it('has no filterCandidateId subBlock or wiring', () => {
// Ashby's application.list endpoint silently ignores a candidateId
// body field (confirmed live: passing a nonexistent candidate UUID
// still returns unfiltered results) — the correct path for a
// candidate's applications is ashby_get_candidate's applicationIds.
expect(AshbyBlock.subBlocks.find((s) => s.id === 'filterCandidateId')).toBeUndefined()
const result = AshbyBlock.tools.config.params!(
buildParams('list_applications', { filterCandidateId: 'some-id' })
)
expect(result.candidateId).toBeUndefined()
})
})
})
Loading
Loading