Skip to content

Commit 56f272a

Browse files
authored
Feat/agentflow Port Markdown Editor & Edit/Source Toggle from legacy UI to agentflow (#6125)
* Initial flow * Fix markdown import shape * Add support to check for HTML or markdown * Fix values not being persisted * Make implementation similar to v2 * Add vairable syntax support to expandDialog * Add variable syntax support to messageInput * Test cases added * Remove eslint-disable * Add getHtml as a fallback * Fixed gemini comments * Fix test cases and lint warnings * Fixed editorUtils.test to not have nested structure * Fix review comments
1 parent fedc6eb commit 56f272a

16 files changed

+713
-141
lines changed

packages/agentflow/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"@codemirror/lang-python": "^6.1.0",
7474
"@tabler/icons-react": "^3.7.0",
7575
"@tiptap/core": "^3.20.4",
76+
"@tiptap/markdown": "^3.20.4",
7677
"@tiptap/extension-code-block-lowlight": "^3.20.4",
7778
"@tiptap/extension-mention": "^3.20.4",
7879
"@tiptap/extension-placeholder": "^3.20.4",

packages/agentflow/src/__mocks__/@tiptap/react.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
11
import { createElement, forwardRef } from 'react'
22

3-
export const useEditor = (config?: Record<string, unknown>) => ({
4-
getHTML: () => (config?.content as string) ?? '<p></p>',
5-
getMarkdown: () => (config?.content as string) ?? '',
6-
setEditable: jest.fn(),
7-
commands: { focus: jest.fn(), setContent: jest.fn() },
8-
_onUpdate: config?.onUpdate
9-
})
3+
export const useEditor = (config?: Record<string, unknown>) => {
4+
// Track the current content so getMarkdown/getHTML reflect setContent() calls,
5+
// mirroring real TipTap behaviour where setContent updates the editor state.
6+
let currentContent: string = (config?.content as string) ?? ''
7+
return {
8+
getHTML: () => currentContent || '<p></p>',
9+
getMarkdown: () => currentContent,
10+
isEmpty: !currentContent,
11+
setEditable: jest.fn(),
12+
commands: {
13+
focus: jest.fn(),
14+
// Capture the first argument (the content string) so getMarkdown/getHTML
15+
// return the value that was last loaded into the editor.
16+
setContent: jest.fn((content: string) => {
17+
currentContent = content
18+
})
19+
},
20+
_onUpdate: config?.onUpdate
21+
}
22+
}
1023

1124
export const EditorContent = forwardRef<HTMLDivElement, { editor?: unknown; [k: string]: unknown }>(({ editor, ...rest }, ref) =>
1225
createElement('div', { ref, 'data-testid': 'tiptap-editor-content', 'data-has-editor': !!editor, ...rest })

packages/agentflow/src/atoms/ExpandTextDialog.test.tsx

Lines changed: 206 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -175,36 +175,40 @@ describe('ExpandTextDialog', () => {
175175
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
176176
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
177177
})
178+
179+
it('should not show Edit/Source toggle in code mode', () => {
180+
render(<ExpandTextDialog open={true} value='' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
181+
182+
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
183+
expect(screen.queryByRole('button', { name: 'Source' })).not.toBeInTheDocument()
184+
})
178185
})
179186

180187
// --- Rich text mode ---
181188

182189
describe('inputType="string" (richtext)', () => {
183-
it('should render the TipTap editor instead of a TextField', async () => {
184-
render(
185-
<ExpandTextDialog open={true} value='<p>Hello</p>' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
186-
)
190+
it('should render the TipTap editor instead of a TextField in Edit mode', async () => {
191+
render(<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
187192

188193
// RichTextEditor renders data-testid='rich-text-editor' which wraps tiptap
189194
expect(await screen.findByTestId('rich-text-editor')).toBeInTheDocument()
190195
expect(screen.getByTestId('tiptap-editor-content')).toBeInTheDocument()
191196

192197
// Plain TextField should NOT be present
193198
expect(screen.queryByTestId('expand-content-input')).not.toBeInTheDocument()
199+
expect(screen.queryByTestId('source-input')).not.toBeInTheDocument()
194200
})
195201

196202
it('should render plain TextField for non-string, non-code input types', () => {
197-
render(<ExpandTextDialog open={true} value='Hello' inputType='number' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
203+
render(<ExpandTextDialog open={true} value='42' inputType='number' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
198204

199205
expect(screen.getByTestId('expand-content-input')).toBeInTheDocument()
200206
expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument()
201207
expect(screen.queryByTestId('code-input')).not.toBeInTheDocument()
202208
})
203209

204210
it('should still show Save and Cancel buttons in richtext mode', () => {
205-
render(
206-
<ExpandTextDialog open={true} value='<p>Hello</p>' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
207-
)
211+
render(<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
208212

209213
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
210214
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
@@ -214,7 +218,7 @@ describe('ExpandTextDialog', () => {
214218
render(
215219
<ExpandTextDialog
216220
open={true}
217-
value='<p>Hello</p>'
221+
value='Hello'
218222
inputType='string'
219223
disabled={true}
220224
onConfirm={mockOnConfirm}
@@ -241,14 +245,204 @@ describe('ExpandTextDialog', () => {
241245
})
242246

243247
it('should call onCancel when Cancel is clicked in richtext mode', () => {
244-
render(
245-
<ExpandTextDialog open={true} value='<p>Hello</p>' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
246-
)
248+
render(<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
247249

248250
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
249251

250252
expect(mockOnCancel).toHaveBeenCalled()
251253
expect(mockOnConfirm).not.toHaveBeenCalled()
252254
})
255+
256+
// --- Edit/Source toggle ---
257+
258+
it('should show Edit and Source toggle buttons in richtext mode', () => {
259+
render(<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
260+
261+
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
262+
expect(screen.getByRole('button', { name: 'Source' })).toBeInTheDocument()
263+
})
264+
265+
it('should not show Edit/Source toggle for non-string input types', () => {
266+
render(<ExpandTextDialog open={true} value='42' inputType='number' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
267+
268+
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
269+
expect(screen.queryByRole('button', { name: 'Source' })).not.toBeInTheDocument()
270+
})
271+
272+
it('should not change mode when the active toggle button is clicked again', () => {
273+
render(
274+
<ExpandTextDialog open={true} value='## My heading' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
275+
)
276+
277+
// Already in Edit mode — clicking Edit again should keep the rich-text editor visible
278+
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
279+
expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument()
280+
expect(screen.queryByTestId('source-input')).not.toBeInTheDocument()
281+
})
282+
283+
it('should switch to Source mode showing a raw text field when Source is clicked', () => {
284+
render(
285+
<ExpandTextDialog open={true} value='## My heading' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
286+
)
287+
288+
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
289+
290+
// Source mode renders a monospace TextField with data-testid='source-input'
291+
expect(screen.getByTestId('source-input')).toBeInTheDocument()
292+
293+
// TipTap editor should be unmounted
294+
expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument()
295+
})
296+
297+
it('should show the current markdown value in Source mode', () => {
298+
render(
299+
<ExpandTextDialog open={true} value='## My heading' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
300+
)
301+
302+
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
303+
304+
const sourceTextarea = screen.getByTestId('source-input').querySelector('textarea')!
305+
expect(sourceTextarea).toHaveValue('## My heading')
306+
})
307+
308+
it('should switch back to Edit mode when Edit is clicked from Source', () => {
309+
render(<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
310+
311+
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
312+
expect(screen.getByTestId('source-input')).toBeInTheDocument()
313+
314+
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
315+
expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument()
316+
expect(screen.queryByTestId('source-input')).not.toBeInTheDocument()
317+
})
318+
319+
it('should save edits made in Source mode when Save is clicked', () => {
320+
render(
321+
<ExpandTextDialog open={true} value='## My heading' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
322+
)
323+
324+
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
325+
326+
const sourceTextarea = screen.getByTestId('source-input').querySelector('textarea')!
327+
fireEvent.change(sourceTextarea, { target: { value: '## Updated heading' } })
328+
329+
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
330+
331+
expect(mockOnConfirm).toHaveBeenCalledWith('## Updated heading')
332+
})
333+
334+
it('should disable the toggle buttons when disabled', () => {
335+
render(
336+
<ExpandTextDialog
337+
open={true}
338+
value='Hello'
339+
inputType='string'
340+
disabled={true}
341+
onConfirm={mockOnConfirm}
342+
onCancel={mockOnCancel}
343+
/>
344+
)
345+
346+
expect(screen.getByRole('button', { name: 'Edit' })).toBeDisabled()
347+
expect(screen.getByRole('button', { name: 'Source' })).toBeDisabled()
348+
})
349+
350+
it('should reset to Edit mode when the dialog is reopened', () => {
351+
const { rerender } = render(
352+
<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
353+
)
354+
355+
// Switch to Source mode
356+
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
357+
expect(screen.getByTestId('source-input')).toBeInTheDocument()
358+
359+
// Close dialog
360+
rerender(<ExpandTextDialog open={false} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
361+
362+
// Reopen — should be back in Edit mode
363+
rerender(<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
364+
365+
expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument()
366+
expect(screen.queryByTestId('source-input')).not.toBeInTheDocument()
367+
})
368+
})
369+
370+
// --- With suggestionItems (VariableInput mode) ---
371+
372+
describe('inputType="string" with suggestionItems', () => {
373+
const mockSuggestionItems = [{ id: 'question', label: 'question', description: "User's question", category: 'Chat Context' }]
374+
375+
it('should render VariableInput instead of RichTextEditor in Edit mode when suggestionItems provided', () => {
376+
render(
377+
<ExpandTextDialog
378+
open={true}
379+
value='Hello'
380+
inputType='string'
381+
suggestionItems={mockSuggestionItems}
382+
onConfirm={mockOnConfirm}
383+
onCancel={mockOnCancel}
384+
/>
385+
)
386+
387+
expect(screen.getByTestId('variable-input')).toBeInTheDocument()
388+
expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument()
389+
})
390+
391+
it('should switch to Source mode and back to VariableInput when suggestionItems provided', () => {
392+
render(
393+
<ExpandTextDialog
394+
open={true}
395+
value='Hello {{question}}'
396+
inputType='string'
397+
suggestionItems={mockSuggestionItems}
398+
onConfirm={mockOnConfirm}
399+
onCancel={mockOnCancel}
400+
/>
401+
)
402+
403+
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
404+
expect(screen.getByTestId('source-input')).toBeInTheDocument()
405+
expect(screen.queryByTestId('variable-input')).not.toBeInTheDocument()
406+
407+
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
408+
expect(screen.getByTestId('variable-input')).toBeInTheDocument()
409+
expect(screen.queryByTestId('source-input')).not.toBeInTheDocument()
410+
})
411+
412+
it('should still show Edit/Source toggle when suggestionItems provided', () => {
413+
render(
414+
<ExpandTextDialog
415+
open={true}
416+
value=''
417+
inputType='string'
418+
suggestionItems={mockSuggestionItems}
419+
onConfirm={mockOnConfirm}
420+
onCancel={mockOnCancel}
421+
/>
422+
)
423+
424+
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
425+
expect(screen.getByRole('button', { name: 'Source' })).toBeInTheDocument()
426+
})
427+
428+
it('should save edits made in Source mode when suggestionItems provided', () => {
429+
render(
430+
<ExpandTextDialog
431+
open={true}
432+
value='Hello {{question}}'
433+
inputType='string'
434+
suggestionItems={mockSuggestionItems}
435+
onConfirm={mockOnConfirm}
436+
onCancel={mockOnCancel}
437+
/>
438+
)
439+
440+
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
441+
const sourceTextarea = screen.getByTestId('source-input').querySelector('textarea')!
442+
fireEvent.change(sourceTextarea, { target: { value: 'Updated {{question}}' } })
443+
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
444+
445+
expect(mockOnConfirm).toHaveBeenCalledWith('Updated {{question}}')
446+
})
253447
})
254448
})

0 commit comments

Comments
 (0)