Skip to content

Commit e01bfb1

Browse files
authored
v0.7.31: sidebar shortcut, env var expansion in lifecycle triggers, ashby fixes
2 parents 207785c + 29cf3e7 commit e01bfb1

12 files changed

Lines changed: 495 additions & 129 deletions

File tree

apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.test.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ function RegisterModK({ handler }: { handler: () => void }) {
1919
return null
2020
}
2121

22+
function RegisterModKOutsideEditable({ handler }: { handler: () => void }) {
23+
useRegisterGlobalCommands([{ id: 'search', shortcut: 'Mod+K', handler, allowInEditable: false }])
24+
return null
25+
}
26+
2227
let container: HTMLDivElement
2328
let root: Root
2429

@@ -87,3 +92,70 @@ describe('GlobalCommandsProvider owned-shortcut yielding', () => {
8792
expect(handler).toHaveBeenCalledTimes(1)
8893
})
8994
})
95+
96+
describe('GlobalCommandsProvider editable guard', () => {
97+
/**
98+
* jsdom does not implement `isContentEditable`, so stub the browser's computed
99+
* editability on the element the test focuses.
100+
*/
101+
function focusWithEditability(element: HTMLElement, isContentEditable: boolean) {
102+
Object.defineProperty(element, 'isContentEditable', { value: isContentEditable })
103+
element.focus()
104+
}
105+
106+
it('skips a non-editable command when focus is in an input', () => {
107+
const handler = vi.fn()
108+
mount(
109+
<GlobalCommandsProvider>
110+
<RegisterModKOutsideEditable handler={handler} />
111+
<input type='text' />
112+
</GlobalCommandsProvider>
113+
)
114+
;(container.querySelector('input') as HTMLInputElement).focus()
115+
pressModK()
116+
expect(handler).not.toHaveBeenCalled()
117+
})
118+
119+
it('skips a non-editable command when focus is in a contenteditable element', () => {
120+
const handler = vi.fn()
121+
mount(
122+
<GlobalCommandsProvider>
123+
<RegisterModKOutsideEditable handler={handler} />
124+
<div contentEditable />
125+
</GlobalCommandsProvider>
126+
)
127+
focusWithEditability(container.querySelector('[contenteditable]') as HTMLElement, true)
128+
pressModK()
129+
expect(handler).not.toHaveBeenCalled()
130+
})
131+
132+
it('skips a non-editable command when focus is on a descendant of a contenteditable root', () => {
133+
const handler = vi.fn()
134+
mount(
135+
<GlobalCommandsProvider>
136+
<RegisterModKOutsideEditable handler={handler} />
137+
<div contentEditable>
138+
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: focusable stand-in for a node view inside the editor */}
139+
<span tabIndex={0} />
140+
</div>
141+
</GlobalCommandsProvider>
142+
)
143+
focusWithEditability(container.querySelector('span') as HTMLElement, true)
144+
pressModK()
145+
expect(handler).not.toHaveBeenCalled()
146+
})
147+
148+
it('fires a non-editable command when focus is in a contenteditable="false" element', () => {
149+
const handler = vi.fn()
150+
mount(
151+
<GlobalCommandsProvider>
152+
<RegisterModKOutsideEditable handler={handler} />
153+
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: focusable stand-in for a read-only editor */}
154+
<div contentEditable={false} tabIndex={0} />
155+
</GlobalCommandsProvider>
156+
)
157+
focusWithEditability(container.querySelector('[contenteditable]') as HTMLElement, false)
158+
pressModK()
159+
expect(handler).toHaveBeenCalledTimes(1)
160+
})
161+
})

apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,17 @@ function shortcutSignature(parsed: ParsedShortcut, isMac: boolean): string {
9393
return `${parsed.key}|${+ctrl}|${+meta}|${+!!parsed.shift}|${+!!parsed.alt}`
9494
}
9595

96+
/**
97+
* Whether `element` is an editable region for the purposes of the editable guard.
98+
* `isContentEditable` is the browser's computed editability, so focusable descendants of a
99+
* `contenteditable` root count as editable, while `contenteditable="false"` (e.g. a rich-text
100+
* editor in read-only mode) does not — commands with `allowInEditable: false` still fire there.
101+
*/
102+
function isEditableElement(element: Element | null): boolean {
103+
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) return true
104+
return element instanceof HTMLElement && element.isContentEditable
105+
}
106+
96107
/**
97108
* Whether the focused element (or an ancestor) declares it owns `parsed` via a comma-separated
98109
* `data-owned-shortcuts` attribute (e.g. a rich-text editor that binds `Mod+K` to links). Such a
@@ -141,14 +152,7 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
141152
if (e.isComposing) return
142153

143154
for (const [, cmd] of registryRef.current) {
144-
if (!cmd.allowInEditable) {
145-
const ae = document.activeElement
146-
const isEditable =
147-
ae instanceof HTMLInputElement ||
148-
ae instanceof HTMLTextAreaElement ||
149-
ae?.hasAttribute('contenteditable')
150-
if (isEditable) continue
151-
}
155+
if (!cmd.allowInEditable && isEditableElement(document.activeElement)) continue
152156

153157
if (matchesShortcut(e, cmd.parsed)) {
154158
if (focusedElementOwnsShortcut(cmd.parsed, isMac)) continue

apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type CommandId =
1717
| 'clear-terminal-console'
1818
| 'focus-toolbar-search'
1919
| 'fit-to-view'
20+
| 'toggle-sidebar'
2021

2122
/**
2223
* Static metadata for a global command.
@@ -92,6 +93,11 @@ export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
9293
shortcut: 'Mod+Shift+F',
9394
allowInEditable: false,
9495
},
96+
'toggle-sidebar': {
97+
id: 'toggle-sidebar',
98+
shortcut: 'Mod+B',
99+
allowInEditable: false,
100+
},
95101
}
96102

97103
/**

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,18 +120,20 @@ export function SidebarTooltip({
120120
label,
121121
enabled,
122122
side = 'right',
123+
shortcut,
123124
}: {
124125
children: React.ReactElement
125126
label: string
126127
enabled: boolean
127128
side?: 'right' | 'bottom'
129+
shortcut?: string
128130
}) {
129131
if (!enabled) return children
130132
return (
131133
<Tooltip.Root>
132134
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
133135
<Tooltip.Content side={side}>
134-
<p>{label}</p>
136+
{shortcut ? <Tooltip.Shortcut keys={shortcut}>{label}</Tooltip.Shortcut> : <p>{label}</p>}
135137
</Tooltip.Content>
136138
</Tooltip.Root>
137139
)
@@ -1234,6 +1236,12 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) {
12341236
handleCreateWorkflow()
12351237
},
12361238
},
1239+
{
1240+
id: 'toggle-sidebar',
1241+
handler: () => {
1242+
toggleCollapsed()
1243+
},
1244+
},
12371245
])
12381246
)
12391247

@@ -1284,7 +1292,12 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) {
12841292
isCollapsed={isCollapsed}
12851293
onExpandSidebar={toggleCollapsed}
12861294
/>
1287-
<SidebarTooltip label='Collapse sidebar' enabled={!isCollapsed} side='bottom'>
1295+
<SidebarTooltip
1296+
label='Collapse sidebar'
1297+
enabled={!isCollapsed}
1298+
side='bottom'
1299+
shortcut={isMac ? '⌘B' : 'Ctrl+B'}
1300+
>
12881301
<button
12891302
type='button'
12901303
onClick={toggleCollapsed}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { AshbyBlock } from './ashby'
6+
7+
describe('AshbyBlock', () => {
8+
const buildParams = (operation: string, extra: Record<string, unknown>) => ({
9+
operation,
10+
...extra,
11+
})
12+
13+
describe('alternateEmailAddresses parsing (create_candidate)', () => {
14+
it('parses a comma-separated string into an array', () => {
15+
const result = AshbyBlock.tools.config.params!(
16+
buildParams('create_candidate', {
17+
alternateEmailAddresses: 'a@x.com, b@x.com',
18+
})
19+
)
20+
expect(result.alternateEmailAddresses).toEqual(['a@x.com', 'b@x.com'])
21+
})
22+
23+
it('parses a JSON array string into an array', () => {
24+
const result = AshbyBlock.tools.config.params!(
25+
buildParams('create_candidate', {
26+
alternateEmailAddresses: '["a@x.com","b@x.com"]',
27+
})
28+
)
29+
expect(result.alternateEmailAddresses).toEqual(['a@x.com', 'b@x.com'])
30+
})
31+
32+
it('omits the field entirely when empty', () => {
33+
const result = AshbyBlock.tools.config.params!(
34+
buildParams('create_candidate', { alternateEmailAddresses: '' })
35+
)
36+
expect(result.alternateEmailAddresses).toBeUndefined()
37+
})
38+
})
39+
40+
describe('socialLinks parsing (update_candidate)', () => {
41+
it('parses a JSON array of link objects', () => {
42+
const result = AshbyBlock.tools.config.params!(
43+
buildParams('update_candidate', {
44+
socialLinks: '[{"type":"Twitter","url":"https://twitter.com/jane"}]',
45+
})
46+
)
47+
expect(result.socialLinks).toEqual([{ type: 'Twitter', url: 'https://twitter.com/jane' }])
48+
})
49+
50+
it('throws instead of silently dropping the field when the JSON is malformed', () => {
51+
// A silent [] here would let the Ashby update proceed without applying
52+
// the requested links and with no error shown to the workflow author.
53+
expect(() =>
54+
AshbyBlock.tools.config.params!(
55+
buildParams('update_candidate', { socialLinks: 'not json' })
56+
)
57+
).toThrow(/Invalid JSON in Ashby social links/)
58+
})
59+
60+
it('throws when the parsed JSON is not an array', () => {
61+
expect(() =>
62+
AshbyBlock.tools.config.params!(
63+
buildParams('update_candidate', { socialLinks: '{"type":"Twitter"}' })
64+
)
65+
).toThrow(/expected a JSON array/)
66+
})
67+
})
68+
69+
describe('wandConfig on array-shaped fields', () => {
70+
it('does not request object-wrapped output for alternateEmailAddresses or socialLinks', () => {
71+
// generationType 'json-object' makes the wand API append "the response
72+
// must start with { and end with }", which conflicts with these fields'
73+
// array-or-comma-separated parsers (parseStringListInput/parseSocialLinksInput).
74+
const alternateEmailAddresses = AshbyBlock.subBlocks.find(
75+
(s) => s.id === 'alternateEmailAddresses'
76+
)
77+
const socialLinks = AshbyBlock.subBlocks.find((s) => s.id === 'socialLinks')
78+
expect(alternateEmailAddresses?.wandConfig?.generationType).not.toBe('json-object')
79+
expect(socialLinks?.wandConfig?.generationType).not.toBe('json-object')
80+
})
81+
})
82+
83+
describe('list_applications candidateId filter', () => {
84+
it('has no filterCandidateId subBlock or wiring', () => {
85+
// Ashby's application.list endpoint silently ignores a candidateId
86+
// body field (confirmed live: passing a nonexistent candidate UUID
87+
// still returns unfiltered results) — the correct path for a
88+
// candidate's applications is ashby_get_candidate's applicationIds.
89+
expect(AshbyBlock.subBlocks.find((s) => s.id === 'filterCandidateId')).toBeUndefined()
90+
const result = AshbyBlock.tools.config.params!(
91+
buildParams('list_applications', { filterCandidateId: 'some-id' })
92+
)
93+
expect(result.candidateId).toBeUndefined()
94+
})
95+
})
96+
})

0 commit comments

Comments
 (0)