From c84c4297d6b6d67c252bb21d7f091ed7c7f11c79 Mon Sep 17 00:00:00 2001 From: Lazaros Toumanidis Date: Tue, 10 Dec 2024 22:48:19 +0200 Subject: [PATCH] added optional props: onConvert, onSave --- .vscode/settings.json | 1 + README.md | 64 +++++++++----- eslint.config.mjs | 2 +- src/index.tsx | 27 ++++++ src/waldiez/components/flow/hooks/useKeys.ts | 16 +++- src/waldiez/components/flow/main.tsx | 55 +++++++++--- src/waldiez/components/flow/types.ts | 2 + src/waldiez/store/creator.ts | 10 ++- src/waldiez/store/provider.tsx | 4 +- src/waldiez/store/selector.ts | 2 + src/waldiez/store/types.ts | 2 + src/waldiez/styles/flow.css | 8 ++ src/waldiez/waldiez.tsx | 6 +- tests/components/flow/common.tsx | 5 ++ tests/components/flow/flow.actions.test.tsx | 87 +++++++++++++++++++ tests/components/flow/flow.test.tsx | 6 +- tests/components/flow/flow.undo.redo.test.tsx | 37 -------- 17 files changed, 258 insertions(+), 76 deletions(-) create mode 100644 tests/components/flow/flow.actions.test.tsx delete mode 100644 tests/components/flow/flow.undo.redo.test.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 42525eb..8c4a7c3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -31,6 +31,7 @@ "groq", "groupchat", "handlepos", + "ipynb", "isinstance", "jsdelivr", "JSOM", diff --git a/README.md b/README.md index 4a08ad2..3dfd84b 100644 --- a/README.md +++ b/README.md @@ -50,15 +50,25 @@ const isProd = import.meta.env.PROD; // the actions should be handled by other components // that use `Waldiez` as a child component - /** *OnChange */ const onChange = null; // const onChange = (flowJson: any) => { -// console.info(JSON.stringify(JSON.parse(flowJson), null, 2)); +// console.info(JSON.stringify(JSON.parse(flowJson), null, 2)); // }; +/** + * OnSave + * if enabled, add a listener for the key combination (ctrl+s/mod+s) + * to save the flow + * the flow string is the JSON stringified flow + * the action should be handled by the parent component + */ +const onSaveDev = (flowString: string) => { + console.info('saving', flowString); +}; +const onSave = isProd ? null : onSaveDev; /** * UserInput */ @@ -102,6 +112,20 @@ const onRunDev = (flowString: string) => { }; const onRun = isProd ? null : onRunDev; +/** + * OnConvert + * adds two buttons to the main panel, to convert the flow to python or jupyter notebook + * The action should be handled by the parent component + * the flow string is the JSON stringified flow + * the `to` parameter is either 'py' or 'ipynb' + * the conversion happens in the python part / backend + */ + +const onConvertDev = (_flowString: string, to: 'py' | 'ipynb') => { + console.info('converting to', to); +}; +const onConvert = isProd ? null : onConvertDev; + /** * OnUpload * on RAG user: adds a dropzone to upload files @@ -138,28 +162,26 @@ const onUpload = isProd ? null : onUploadDev; // PROD: // either served and `VITE_VS_PATH` is set to the path, or // use the default cdn (jsdelivr) that monaco loader uses -const vsPath = isProd ? (import.meta.env.VS_PATH ?? null) : 'vs'; +// make sure the csp allows the cdn +let vsPath = !isProd ? 'vs' : (import.meta.env.VITE_VS_PATH ?? null); +if (!vsPath) { + // if set to empty string, make it null + vsPath = null; +} /** * Other props: * we can use: - * `import { importFlow } from '@waldiez/react';` - * const flowJson = JSON.parse(flowJsonString); - * const flow = importFlow(flowJson); - * to import a flow from a waldiez/json file + * `import { importFlow } from '@waldiez';` + * to import an existing flow from a waldiez/json file * then we can pass the additional props: - * - edges: Edge[]; // initial edges to render (flow.edges) - * - nodes: Node[]; // initial nodes to render (flow.nodes) - * - name: string; // flow name (flow.name) - * - description: string; // flow description (flow.description) - * - tags: string[]; // flow tags (flow.tags) - * - requirements: string[]; // flow requirements (flow.requirements) - * - createdAt?: string; // flow createdAt (flow.createdAt) - * - updatedAt?: string; // flow updatedAt (flow.updatedAt) - * - viewport: Viewport; // initial viewport (flow.viewport) - * - flowId: string; // a specific id for the flow - * - storageId: string; // storage id to use for getting/setting - * the theme mode (light/dark) and - * the sidebar state (open/closed) + * - edges: Edge[]; initial edges to render + * - nodes: Node[]; initial nodes to render + * - name: string; + * - description: string; + * - tags: string[]; + * - requirements: string[]; + * - createdAt?: string; + * - updatedAt?: string; */ const startApp = () => { @@ -172,8 +194,10 @@ const startApp = () => { storageId="storage-0" inputPrompt={inputPrompt} onRun={onRun} + onConvert={onConvert} onChange={onChange} onUpload={onUpload} + onSave={onSave} /> ); diff --git a/eslint.config.mjs b/eslint.config.mjs index 423d7e4..7ee03f4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -119,7 +119,7 @@ const defaultConfig = eslintTs.config({ eqeqeq: 'error', 'prefer-arrow-callback': 'error', 'tsdoc/syntax': 'warn', - complexity: ['error', 16], + complexity: ['error', 20], 'max-depth': ['error', 4], 'max-nested-callbacks': ['error', 4], 'max-statements': ['error', 11, { ignoreTopLevelFunctions: true }], diff --git a/src/index.tsx b/src/index.tsx index 16b3cc1..3bf4ba4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -17,6 +17,17 @@ const onChange = null; // console.info(JSON.stringify(JSON.parse(flowJson), null, 2)); // }; +/** + * OnSave + * if enabled, add a listener for the key combination (ctrl+s/mod+s) + * to save the flow + * the flow string is the JSON stringified flow + * the action should be handled by the parent component + */ +const onSaveDev = (flowString: string) => { + console.info('saving', flowString); +}; +const onSave = isProd ? null : onSaveDev; /** * UserInput */ @@ -60,6 +71,20 @@ const onRunDev = (flowString: string) => { }; const onRun = isProd ? null : onRunDev; +/** + * OnConvert + * adds two buttons to the main panel, to convert the flow to python or jupyter notebook + * The action should be handled by the parent component + * the flow string is the JSON stringified flow + * the `to` parameter is either 'py' or 'ipynb' + * the conversion happens in the python part / backend + */ + +const onConvertDev = (_flowString: string, to: 'py' | 'ipynb') => { + console.info('converting to', to); +}; +const onConvert = isProd ? null : onConvertDev; + /** * OnUpload * on RAG user: adds a dropzone to upload files @@ -128,8 +153,10 @@ export const startApp = () => { storageId="storage-0" inputPrompt={inputPrompt} onRun={onRun} + onConvert={onConvert} onChange={onChange} onUpload={onUpload} + onSave={onSave} /> ); diff --git a/src/waldiez/components/flow/hooks/useKeys.ts b/src/waldiez/components/flow/hooks/useKeys.ts index 0a56bcf..258d06e 100644 --- a/src/waldiez/components/flow/hooks/useKeys.ts +++ b/src/waldiez/components/flow/hooks/useKeys.ts @@ -4,12 +4,14 @@ import { useHotkeys } from 'react-hotkeys-hook'; import { useTemporalStore, useWaldiezContext } from '@waldiez/store'; import { getFlowRoot } from '@waldiez/utils'; -export const useKeys = (flowId: string) => { +export const useKeys = (flowId: string, onSave?: ((flow: string) => void) | null) => { const { undo, redo, futureStates, pastStates } = useTemporalStore(state => state); const deleteAgent = useWaldiezContext(selector => selector.deleteAgent); const deleteEdge = useWaldiezContext(selector => selector.deleteEdge); const deleteModel = useWaldiezContext(selector => selector.deleteModel); const deleteSkill = useWaldiezContext(selector => selector.deleteSkill); + const saveFlow = useWaldiezContext(selector => selector.saveFlow); + const listenForSave = typeof onSave === 'function'; const isFlowVisible = () => { // if on jupyter, we might have more than one tabs with a flow // let's check if the current flow is visible (i.e. we are in the right tab) @@ -42,6 +44,18 @@ export const useKeys = (flowId: string) => { }, { scopes: flowId } ); + if (listenForSave) { + useHotkeys( + 'mod+s', + event => { + if (isFlowVisible()) { + event.preventDefault(); + saveFlow(); + } + }, + { scopes: flowId } + ); + } const onDeleteKey = (event: KeyboardEvent) => { const target = event.target; const isNode = target instanceof Element && target.classList.contains('react-flow__node'); diff --git a/src/waldiez/components/flow/main.tsx b/src/waldiez/components/flow/main.tsx index 43c305b..e963e88 100644 --- a/src/waldiez/components/flow/main.tsx +++ b/src/waldiez/components/flow/main.tsx @@ -2,7 +2,9 @@ import { Background, BackgroundVariant, Controls, Panel, ReactFlow } from '@xyfl import { useEffect, useState } from 'react'; import { FaPlusCircle } from 'react-icons/fa'; +import { FaPython } from 'react-icons/fa6'; import { FaCirclePlay } from 'react-icons/fa6'; +import { SiJupyter } from 'react-icons/si'; import { useDnD, useFlowEvents, useKeys, useRun, useTheme } from '@waldiez/components/flow/hooks'; import { UserInputModal } from '@waldiez/components/flow/modals'; @@ -15,13 +17,14 @@ import { selector } from '@waldiez/store/selector'; import { isDarkMode, setDarkMode } from '@waldiez/theme'; export const WaldiezFlow = (props: WaldiezFlowProps) => { - const { flowId, storageId, onRun: runner, inputPrompt, onUserInput } = props; + const { flowId, storageId, onRun: runner, inputPrompt, onUserInput, onConvert, onSave } = props; const store = useWaldiezContext(selector); const showNodes = useWaldiezContext(selector => selector.showNodes); const onFlowChanged = useWaldiezContext(selector => selector.onFlowChanged); const includeRunButton = typeof runner === 'function'; + const includeConvertIcons = typeof onConvert === 'function'; const { isDark, onThemeToggle } = useTheme(flowId, storageId); - const { onKeyDown } = useKeys(flowId); + const { onKeyDown } = useKeys(flowId, onSave); const [selectedNodeType, setSelectedNodeType] = useState('agent'); const { onAddNode, @@ -49,6 +52,14 @@ export const WaldiezFlow = (props: WaldiezFlowProps) => { showNodes('agent'); setDarkMode(flowId, storageId, isDarkMode(flowId, storageId), true); }, []); + const convertToPy = () => { + const flow = onFlowChanged(); + onConvert?.(JSON.stringify(flow), 'py'); + }; + const convertToIpynb = () => { + const flow = onFlowChanged(); + onConvert?.(JSON.stringify(flow), 'ipynb'); + }; const colorMode = isDark ? 'dark' : 'light'; const rfInstance = store.get().rfInstance; const nodes = store.get().nodes; @@ -108,17 +119,39 @@ export const WaldiezFlow = (props: WaldiezFlowProps) => { )} - {includeRunButton && ( + {(includeRunButton || includeConvertIcons) && (
- + {includeConvertIcons && ( + + )} + {includeConvertIcons && ( + + )} + {includeRunButton && ( + + )}
)} diff --git a/src/waldiez/components/flow/types.ts b/src/waldiez/components/flow/types.ts index 21646c1..beea1ff 100644 --- a/src/waldiez/components/flow/types.ts +++ b/src/waldiez/components/flow/types.ts @@ -7,6 +7,8 @@ export type WaldiezFlowProps = { prompt: string; } | null; onRun?: ((flow: string) => void) | null; + onSave?: ((flow: string) => void) | null; + onConvert?: ((flow: string, to: 'py' | 'ipynb') => void) | null; onChange?: ((content: string) => void) | null; onUserInput?: ((userInput: string) => void) | null; onUpload?: ((files: File[]) => Promise) | null; diff --git a/src/waldiez/store/creator.ts b/src/waldiez/store/creator.ts index 6707fba..a1ec420 100644 --- a/src/waldiez/store/creator.ts +++ b/src/waldiez/store/creator.ts @@ -36,7 +36,8 @@ export const createWaldiezStore = (props?: WaldiezStoreProps) => { updatedAt = new Date().toISOString(), viewport = { zoom: 1, x: 50, y: 50 }, onUpload = null, - onChange = null + onChange = null, + onSave = null } = props || {}; let { storageId } = props || {}; if (!storageId) { @@ -72,6 +73,7 @@ export const createWaldiezStore = (props?: WaldiezStoreProps) => { getViewport: () => get().viewport, onUpload, onChange, + onSave, // edges getEdges: () => get().edges, onEdgesChange: (changes: EdgeChange[]) => EdgesStore.onEdgesChange(changes, get, set), @@ -183,6 +185,12 @@ export const createWaldiezStore = (props?: WaldiezStoreProps) => { onChange(JSON.stringify(flow)); } return flow; + }, + saveFlow: () => { + if (typeof onSave === 'function') { + const flow = FlowStore.exportFlow(false, get); + onSave(JSON.stringify(flow)); + } } }), { diff --git a/src/waldiez/store/provider.tsx b/src/waldiez/store/provider.tsx index 9b67690..362e86e 100644 --- a/src/waldiez/store/provider.tsx +++ b/src/waldiez/store/provider.tsx @@ -32,6 +32,7 @@ export function WaldiezProvider({ const storageId = props.storageId; const onUpload = props.onUpload ?? null; const onChange = props.onChange ?? null; + const onSave = props.onSave ?? null; const rfInstance = props.rfInstance; if (!storeRef.current) { storeRef.current = createWaldiezStore({ @@ -47,7 +48,8 @@ export function WaldiezProvider({ edges, rfInstance, onUpload, - onChange + onChange, + onSave }); } return {children}; diff --git a/src/waldiez/store/selector.ts b/src/waldiez/store/selector.ts index 2e0d443..7a35290 100644 --- a/src/waldiez/store/selector.ts +++ b/src/waldiez/store/selector.ts @@ -71,6 +71,7 @@ export const selector = (store: WaldiezState) => ({ importAgent: store.importAgent, exportAgent: store.exportAgent, // flow + saveFlow: store.saveFlow, getFlowInfo: store.getFlowInfo, updateFlow: store.updateFlow, updateFlowOrder: store.updateFlowOrder, @@ -80,5 +81,6 @@ export const selector = (store: WaldiezState) => ({ onViewportChange: store.onViewportChange, onUpload: store.onUpload, onChange: store.onChange, + onSave: store.onSave, onFlowChanged: store.onFlowChanged }); diff --git a/src/waldiez/store/types.ts b/src/waldiez/store/types.ts index 55b0f65..c60b6d4 100644 --- a/src/waldiez/store/types.ts +++ b/src/waldiez/store/types.ts @@ -39,6 +39,7 @@ export type WaldiezStoreProps = { nodes: Node[]; // only react flow related (no data) onUpload?: ((files: File[]) => Promise) | null; // handler for file uploads (send to backend) onChange?: ((content: string) => void) | null; // handler for changes in the flow (send to backend) + onSave?: ((flow: string) => void) | null; // handler for saving the flow (send to backend) }; export type WaldiezFlowInfo = { @@ -152,6 +153,7 @@ export type WaldiezState = WaldiezStoreProps & { ) => WaldiezAgentNode; exportAgent: (agentId: string, skipLinks: boolean) => { [key: string]: unknown } | null; // flow + saveFlow: () => void; updateFlow: (data: { name: string; description: string; tags: string[]; requirements: string[] }) => void; updateFlowOrder: (data: { id: string; order: number }[]) => void; getFlowEdges: () => [WaldiezEdge[], WaldiezEdge[]]; diff --git a/src/waldiez/styles/flow.css b/src/waldiez/styles/flow.css index 5a84e54..d9621d0 100644 --- a/src/waldiez/styles/flow.css +++ b/src/waldiez/styles/flow.css @@ -22,6 +22,14 @@ margin-right: 0.5rem; } +.flow-wrapper .editor-nav-action { + padding: 10px; +} + +.flow-wrapper .editor-nav-action svg { + font-size: 1.3rem; +} + .flow-wrapper .flow-order-item-wrapper { display: flex; align-items: center; diff --git a/src/waldiez/waldiez.tsx b/src/waldiez/waldiez.tsx index 7f3eaa92..c8531c4 100644 --- a/src/waldiez/waldiez.tsx +++ b/src/waldiez/waldiez.tsx @@ -18,7 +18,8 @@ import { getId } from '@waldiez/utils'; export const Waldiez = (props: Partial) => { const { flowId, storageId, createdAt, updatedAt, name, description, tags, requirements, nodes, edges } = getInitialProps(props); - const { viewport, inputPrompt, monacoVsPath, onChange, onRun, onUserInput, onUpload } = props; + const { viewport, inputPrompt, monacoVsPath, onChange, onRun, onUserInput, onUpload, onConvert, onSave } = + props; useEffect(() => { if (monacoVsPath) { loader.config({ paths: { vs: monacoVsPath } }); @@ -42,6 +43,7 @@ export const Waldiez = (props: Partial) => { edges={edges} onUpload={onUpload} onChange={onChange} + onSave={onSave} > ) => { onChange={onChange} onRun={onRun} onUserInput={onUserInput} + onConvert={onConvert} + onSave={onSave} /> diff --git a/tests/components/flow/common.tsx b/tests/components/flow/common.tsx index ba6a4c5..401e25e 100644 --- a/tests/components/flow/common.tsx +++ b/tests/components/flow/common.tsx @@ -11,6 +11,8 @@ import { WaldiezProvider } from '@waldiez/store'; export const onRun = vi.fn(); export const onChange = vi.fn(); export const onUserInput = vi.fn(); +export const onConvert = vi.fn(); +export const onSave = vi.fn(); export const assistantDataTransfer = { setData: vi.fn(), @@ -116,6 +118,7 @@ export const renderFlow = ( createdAt={createdAt} updatedAt={updatedAt} onChange={onChange} + onSave={onSave} > diff --git a/tests/components/flow/flow.actions.test.tsx b/tests/components/flow/flow.actions.test.tsx new file mode 100644 index 0000000..be7c361 --- /dev/null +++ b/tests/components/flow/flow.actions.test.tsx @@ -0,0 +1,87 @@ +import { onConvert, onSave, renderFlow } from './common'; +import { flowId } from './data'; +import { act, fireEvent, screen } from '@testing-library/react'; +import userEvent, { UserEvent } from '@testing-library/user-event'; +import { describe, it } from 'vitest'; + +const undoAction = async (user: UserEvent) => { + act(() => { + renderFlow(); + }); + fireEvent.click(screen.getByTestId('show-agents')); + const agentFooter = screen.getByTestId('agent-footer-agent-0'); + expect(agentFooter).toBeTruthy(); + const cloneDiv = agentFooter.querySelector('.clone-agent'); + expect(cloneDiv).toBeTruthy(); + fireEvent.click(cloneDiv as HTMLElement); + vi.advanceTimersByTime(50); + const clonedAgentView = screen.queryAllByText('Node 0 (copy)'); + expect(clonedAgentView.length).toBeGreaterThanOrEqual(1); + await user.keyboard('{Control>}z{/Control}'); + vi.advanceTimersByTime(50); + const clonedAgentViewAfterUndo = screen.queryAllByText('Node 0 (copy)'); + expect(clonedAgentViewAfterUndo).toHaveLength(0); +}; + +describe('Flow Undo Redo', () => { + const user = userEvent.setup(); + it('should undo an action', async () => { + await undoAction(user); + }); + it('should redo an action', async () => { + await undoAction(user); + await user.keyboard('{Control>}y{/Control}'); + vi.advanceTimersByTime(50); + const clonedAgentViewAfterRedo = screen.queryAllByText('Node 0 (copy)'); + expect(clonedAgentViewAfterRedo.length).toBeGreaterThanOrEqual(1); + }); +}); + +describe('Flow Save', () => { + it('should save the flow', async () => { + onSave.mockClear(); + const user = userEvent.setup(); + act(() => { + renderFlow(); + }); + fireEvent.click(screen.getByTestId('show-agents')); + const agentFooter = screen.getByTestId('agent-footer-agent-0'); + expect(agentFooter).toBeTruthy(); + const cloneDiv = agentFooter.querySelector('.clone-agent'); + expect(cloneDiv).toBeTruthy(); + fireEvent.click(cloneDiv as HTMLElement); + await user.keyboard('{Control>}s{/Control}'); + vi.advanceTimersByTime(50); + expect(onSave).toHaveBeenCalledTimes(1); + onSave.mockClear(); + }); +}); + +describe('Flow Convert', () => { + it('should convert the flow to python', async () => { + onConvert.mockClear(); + act(() => { + renderFlow(); + }); + fireEvent.click(screen.getByTestId('show-agents')); + const convertToPyButton = screen.getByTestId(`convert-${flowId}-to-py`); + expect(convertToPyButton).toBeTruthy(); + fireEvent.click(convertToPyButton); + vi.advanceTimersByTime(50); + expect(onConvert).toHaveBeenCalledTimes(1); + onConvert.mockClear(); + }); + it('should convert the flow to ipynb', async () => { + onConvert.mockClear(); + act(() => { + renderFlow(); + }); + fireEvent.click(screen.getByTestId('show-agents')); + const convertToIpynbButton = screen.getByTestId(`convert-${flowId}-to-ipynb`); + expect(convertToIpynbButton).toBeTruthy(); + fireEvent.click(convertToIpynbButton); + vi.advanceTimersByTime(50); + expect(onConvert).toHaveBeenCalledTimes(1); + onConvert.mockClear(); + }); +}); diff --git a/tests/components/flow/flow.test.tsx b/tests/components/flow/flow.test.tsx index 26e71a3..0dd8faf 100644 --- a/tests/components/flow/flow.test.tsx +++ b/tests/components/flow/flow.test.tsx @@ -105,21 +105,21 @@ describe('WaldiezFlow', () => { act(() => { renderFlow(); }); - await userEvent.click(screen.getByTestId('run-flow')); + await userEvent.click(screen.getByTestId(`run-${flowId}`)); expect(onRun).toBeCalledTimes(1); }); it('should not call on run if there is no agent node', async () => { act(() => { renderFlow(true, false, true); }); - await userEvent.click(screen.getByTestId('run-flow')); + await userEvent.click(screen.getByTestId(`run-${flowId}`)); expect(onRun).not.toBeCalled(); }); it('should not call on run if there is one agent node', async () => { act(() => { renderFlow(false, true); }); - await userEvent.click(screen.getByTestId('run-flow')); + await userEvent.click(screen.getByTestId(`run-${flowId}`)); expect(onRun).not.toBeCalled(); }); it('should toggle dark mode', () => { diff --git a/tests/components/flow/flow.undo.redo.test.tsx b/tests/components/flow/flow.undo.redo.test.tsx deleted file mode 100644 index 7f4206f..0000000 --- a/tests/components/flow/flow.undo.redo.test.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { renderFlow } from './common'; -import { act, fireEvent, screen } from '@testing-library/react'; -import userEvent, { UserEvent } from '@testing-library/user-event'; -import { describe, it } from 'vitest'; - -const undoAction = async (user: UserEvent) => { - act(() => { - renderFlow(); - }); - fireEvent.click(screen.getByTestId('show-agents')); - const agentFooter = screen.getByTestId('agent-footer-agent-0'); - expect(agentFooter).toBeTruthy(); - const cloneDiv = agentFooter.querySelector('.clone-agent'); - expect(cloneDiv).toBeTruthy(); - fireEvent.click(cloneDiv as HTMLElement); - vi.advanceTimersByTime(50); - const clonedAgentView = screen.queryAllByText('Node 0 (copy)'); - expect(clonedAgentView.length).toBeGreaterThanOrEqual(1); - await user.keyboard('{Control>}z{/Control}'); - vi.advanceTimersByTime(50); - const clonedAgentViewAfterUndo = screen.queryAllByText('Node 0 (copy)'); - expect(clonedAgentViewAfterUndo).toHaveLength(0); -}; - -describe('Flow Undo Redo', () => { - const user = userEvent.setup(); - it('should undo an action', async () => { - await undoAction(user); - }); - it('should redo an action', async () => { - await undoAction(user); - await user.keyboard('{Control>}y{/Control}'); - vi.advanceTimersByTime(50); - const clonedAgentViewAfterRedo = screen.queryAllByText('Node 0 (copy)'); - expect(clonedAgentViewAfterRedo.length).toBeGreaterThanOrEqual(1); - }); -});