/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import * as FlowContextSelectorModule from '../../FlowContextSelector';
import type { MetaTreeNode } from '../../../flowContext';
import { createTestFlowContext, TestFlowContextWrapper } from './test-utils';
const FlowContextSelector = FlowContextSelectorModule.FlowContextSelector;
describe('FlowContextSelector', () => {
it('should render with default children', async () => {
const flowContext = createTestFlowContext();
render(
flowContext.getPropertyMetaTree()} />
,
);
await waitFor(() => {
expect(screen.getByRole('button')).toBeInTheDocument();
});
});
it('should render with custom children', async () => {
const flowContext = createTestFlowContext();
render(
flowContext.getPropertyMetaTree()}>
,
);
await waitFor(() => {
expect(screen.getByText('Custom Button')).toBeInTheDocument();
});
});
it('should handle value parsing and display selected path', async () => {
const onChange = vi.fn();
const flowContext = createTestFlowContext();
render(
flowContext.getPropertyMetaTree()}
value="{{ ctx.user.name }}"
onChange={onChange}
/>
,
);
const cascader = screen.getByRole('button');
fireEvent.click(cascader);
await waitFor(() => {
expect(screen.getByText('User')).toBeInTheDocument();
});
});
it('uses explicit active=false to keep the default trigger unhighlighted even when value parses to a path', async () => {
const flowContext = createTestFlowContext();
render(
flowContext.getPropertyMetaTree()}
value="{{ ctx.user.name }}"
active={false}
/>
,
);
const button = await screen.findByRole('button');
expect(button.className).not.toContain('ant-btn-primary');
});
it('should support function metaTree loading', async () => {
const flowContext = createTestFlowContext();
const metaTreeFn = vi.fn(() => flowContext.getPropertyMetaTree());
render(
,
);
const cascader = screen.getByRole('button');
fireEvent.click(cascader);
await waitFor(() => {
expect(metaTreeFn).toHaveBeenCalled();
});
});
it('should support async metaTree function', async () => {
const flowContext = createTestFlowContext();
const asyncMetaTree = async () => flowContext.getPropertyMetaTree();
render(
,
);
const cascader = screen.getByRole('button');
fireEvent.click(cascader);
await waitFor(() => {
expect(screen.getByText('User')).toBeInTheDocument();
});
});
it('should call onChange when option is selected', async () => {
const onChange = vi.fn();
const flowContext = createTestFlowContext();
render(
flowContext.getPropertyMetaTree()} onChange={onChange} />
,
);
const cascader = screen.getByRole('button');
fireEvent.click(cascader);
await waitFor(() => {
const userOption = screen.getByText('User');
fireEvent.click(userOption);
});
await waitFor(() => {
const nameOption = screen.getByText('Name');
fireEvent.click(nameOption);
});
expect(onChange).toHaveBeenCalledWith(
'{{ ctx.user.name }}',
expect.objectContaining({
name: 'name',
title: 'Name',
type: 'string',
paths: ['user', 'name'],
}),
);
});
it('should handle leaf node selection', async () => {
const onChange = vi.fn();
const flowContext = createTestFlowContext();
render(
flowContext.getPropertyMetaTree()} onChange={onChange} />
,
);
const cascader = screen.getByRole('button');
fireEvent.click(cascader);
await waitFor(() => {
const configOption = screen.getByText('Config');
fireEvent.click(configOption);
});
expect(onChange).toHaveBeenCalledWith(
'{{ ctx.config }}',
expect.objectContaining({
name: 'config',
title: 'Config',
type: 'string',
paths: ['config'],
}),
);
});
it('should support search functionality', async () => {
const flowContext = createTestFlowContext();
render(
flowContext.getPropertyMetaTree()} showSearch />
,
);
const cascader = screen.getByRole('button');
fireEvent.click(cascader);
const searchInput = await screen.findByPlaceholderText('Search');
await waitFor(() => {
expect(screen.getByText('User')).toBeInTheDocument();
});
fireEvent.change(searchInput, { target: { value: 'config' } });
await waitFor(() => {
expect(screen.getByText('Config')).toBeInTheDocument();
expect(screen.queryByText('User')).not.toBeInTheDocument();
});
fireEvent.change(searchInput, { target: { value: '' } });
await waitFor(() => {
expect(screen.getByText('User')).toBeInTheDocument();
});
});
it('should not render search input when search is disabled', async () => {
const flowContext = createTestFlowContext();
render(
flowContext.getPropertyMetaTree()} showSearch={false} />
,
);
fireEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByText('User')).toBeInTheDocument();
});
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument();
});
it('should load and expand lazy relation node after searching', async () => {
const flowContext = createTestFlowContext();
const loadOrgChildren = vi.fn(async () => [
{
name: 'org_name',
title: 'Org Name',
type: 'string',
paths: ['org_oho', 'org_name'],
parentTitles: ['org_oho'],
},
]);
const metaTree: MetaTreeNode[] = [
{
name: 'org_oho',
title: 'org_oho',
type: 'object',
paths: ['org_oho'],
children: loadOrgChildren,
},
{
name: 'staff',
title: 'staff',
type: 'string',
paths: ['staff'],
},
];
render(
,
);
fireEvent.click(screen.getByRole('button'));
const searchInput = await screen.findByPlaceholderText('Search');
fireEvent.change(searchInput, { target: { value: 'oho' } });
await waitFor(() => {
expect(screen.getByText('org_oho')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('org_oho'));
await waitFor(() => {
expect(loadOrgChildren).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(screen.getByText('Org Name')).toBeInTheDocument();
});
});
it('shows enabled variable item tooltip above the menu item', async () => {
const flowContext = createTestFlowContext();
const metaTree: MetaTreeNode[] = [
{
name: '$system',
title: 'System variables',
type: '',
paths: ['$system'],
children: [
{
name: 'instanceId',
title: 'Instance ID',
type: '',
paths: ['$system', 'instanceId'],
options: { tooltip: 'The ID of current server instance' },
},
],
},
];
render(
,
);
fireEvent.click(screen.getByRole('button'));
fireEvent.click(await screen.findByText('System variables'));
expect(await screen.findByText('Instance ID')).toBeInTheDocument();
const tooltipIcon = screen.getByLabelText('Instance ID tooltip');
fireEvent.mouseEnter(tooltipIcon);
const tooltip = await screen.findByText('The ID of current server instance');
expect(tooltip.closest('.ant-tooltip')).toHaveClass('ant-tooltip-placement-top');
});
it('should support inline search input when children is null and keep lazy expand', async () => {
const flowContext = createTestFlowContext();
const loadOrgChildren = vi.fn(async () => [
{
name: 'org_name',
title: 'Org Name',
type: 'string',
paths: ['org_oho', 'org_name'],
parentTitles: ['org_oho'],
},
]);
const metaTree: MetaTreeNode[] = [
{
name: 'org_oho',
title: 'org_oho',
type: 'object',
paths: ['org_oho'],
children: loadOrgChildren,
},
{
name: 'staff',
title: 'staff',
type: 'string',
paths: ['staff'],
},
];
render(
{null}
,
);
const inlineInput = screen.getByRole('textbox');
fireEvent.focus(inlineInput);
fireEvent.change(inlineInput, { target: { value: 'oho' } });
await waitFor(() => {
expect(screen.getByText('org_oho')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('org_oho'));
await waitFor(() => {
expect(loadOrgChildren).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(screen.getByText('Org Name')).toBeInTheDocument();
});
});
it('should keep dropdown open on first pointer click in inline input mode', async () => {
const flowContext = createTestFlowContext();
render(
flowContext.getPropertyMetaTree()}>{null}
,
);
const inlineInput = await screen.findByRole('textbox');
fireEvent.mouseDown(inlineInput);
fireEvent.focus(inlineInput);
fireEvent.mouseUp(inlineInput);
fireEvent.click(inlineInput);
await waitFor(() => {
expect(screen.getByText('User')).toBeInTheDocument();
});
});
it('should show selected path text when inline input dropdown is closed', async () => {
const flowContext = createTestFlowContext();
render(
flowContext.getPropertyMetaTree()} value="{{ ctx.user.name }}">
{null}
,
);
const inlineInput = screen.getByRole('textbox') as HTMLInputElement;
await waitFor(() => {
expect(inlineInput.value).toBe('User / Name');
});
});
it('should clear selected value when clearing inline input while dropdown is closed', async () => {
const onChange = vi.fn();
const flowContext = createTestFlowContext();
render(
flowContext.getPropertyMetaTree()}
value="{{ ctx.user.name }}"
onChange={onChange}
>
{null}
,
);
const inlineInput = screen.getByRole('textbox');
fireEvent.change(inlineInput, { target: { value: '' } });
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith('', undefined);
});
});
it('should clear selected value when clicking inline clear icon while dropdown is closed', async () => {
const onChange = vi.fn();
const flowContext = createTestFlowContext();
render(
flowContext.getPropertyMetaTree()}
value="{{ ctx.user.name }}"
onChange={onChange}
>
{null}
,
);
await screen.findByRole('textbox');
const clearIcon = document.querySelector('.ant-input-clear-icon') as HTMLElement | null;
expect(clearIcon).toBeInTheDocument();
if (!clearIcon) {
throw new Error('Expected inline clear icon to be present');
}
fireEvent.mouseDown(clearIcon);
fireEvent.mouseUp(clearIcon);
fireEvent.click(clearIcon);
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith('', undefined);
});
});
it('should handle FlowContext metaTree', async () => {
const flowContext = createTestFlowContext();
render(
flowContext.getPropertyMetaTree()} />
,
);
await waitFor(() => {
expect(screen.getByRole('button')).toBeInTheDocument();
});
});
it('should handle empty metaTree', async () => {
const flowContext = createTestFlowContext();
render(
,
);
await waitFor(() => {
const cascader = screen.getByRole('button');
fireEvent.click(cascader);
expect(screen.getByRole('button')).toBeInTheDocument();
});
});
it('should pass through cascader props', async () => {
const flowContext = createTestFlowContext();
render(
flowContext.getPropertyMetaTree()}
placeholder="Select a variable"
disabled
/>
,
);
// Check if basic rendering works with props
await waitFor(() => {
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
});
describe('Double-click functionality', () => {
it('should select leaf node with single click', async () => {
const onChange = vi.fn();
const flowContext = createTestFlowContext();
render(
flowContext.getPropertyMetaTree()} onChange={onChange} />
,
);
const cascader = screen.getByRole('button');
fireEvent.click(cascader);
await waitFor(() => {
const configOption = screen.getByText('Config');
fireEvent.click(configOption);
});
expect(onChange).toHaveBeenCalledWith(
'{{ ctx.config }}',
expect.objectContaining({
name: 'config',
title: 'Config',
type: 'string',
paths: ['config'],
}),
);
});
it('should support double-click selection for non-leaf nodes', async () => {
// This test verifies that the double-click logic is implemented
// In actual usage, users would double-click to select non-leaf nodes
const onChange = vi.fn();
const flowContext = createTestFlowContext();
render(
flowContext.getPropertyMetaTree()} onChange={onChange} />
,
);
// The implementation supports double-click detection with 300ms window
// For testing purposes, we verify the component renders correctly
await waitFor(() => {
expect(screen.getByRole('button')).toBeInTheDocument();
});
});
});
describe('Custom parsing and formatting functions', () => {
it('should use custom parseValueToPath function', async () => {
const onChange = vi.fn();
const flowContext = createTestFlowContext();
const customParseValueToPath = vi.fn().mockReturnValue(['user', 'name']);
render(
flowContext.getPropertyMetaTree()}
value="custom.user.name"
onChange={onChange}
parseValueToPath={customParseValueToPath}
/>
,
);
await waitFor(() => {
expect(customParseValueToPath).toHaveBeenCalledWith('custom.user.name');
});
});
it('should use custom formatPathToValue function', async () => {
const onChange = vi.fn();
const flowContext = createTestFlowContext();
const customFormatPathToValue = vi.fn().mockReturnValue('custom.formatted.value');
render(
flowContext.getPropertyMetaTree()}
onChange={onChange}
formatPathToValue={customFormatPathToValue}
/>
,
);
const cascader = screen.getByRole('button');
fireEvent.click(cascader);
await waitFor(() => {
const configOption = screen.getByText('Config');
fireEvent.click(configOption);
});
expect(customFormatPathToValue).toHaveBeenCalled();
expect(onChange).toHaveBeenCalledWith('custom.formatted.value', expect.any(Object));
});
it('should fallback to default functions when custom functions return undefined', async () => {
const onChange = vi.fn();
const flowContext = createTestFlowContext();
const customFormatPathToValue = vi.fn().mockReturnValue(undefined);
render(
flowContext.getPropertyMetaTree()}
onChange={onChange}
formatPathToValue={customFormatPathToValue}
/>
,
);
const cascader = screen.getByRole('button');
fireEvent.click(cascader);
await waitFor(() => {
const configOption = screen.getByText('Config');
fireEvent.click(configOption);
});
expect(customFormatPathToValue).toHaveBeenCalled();
// Should fallback to default formatPathToValue
expect(onChange).toHaveBeenCalledWith('{{ ctx.config }}', expect.any(Object));
});
});
describe('Open state control', () => {
it('should control dropdown open state with open prop', async () => {
const flowContext = createTestFlowContext();
const { rerender } = render(
flowContext.getPropertyMetaTree()} open={false} />
,
);
// When open=false, cascader should be closed
expect(screen.getByRole('button')).toBeInTheDocument();
expect(screen.queryByText('User')).not.toBeInTheDocument();
rerender(
flowContext.getPropertyMetaTree()} open={true} />
,
);
// When open=true, options should be visible after data loads
await waitFor(
() => {
expect(screen.getByText('User')).toBeInTheDocument();
},
{ timeout: 5000 },
);
});
it('should handle programmatic open state changes', async () => {
const flowContext = createTestFlowContext();
render(
flowContext.getPropertyMetaTree()} open={true} />
,
);
// Should show dropdown when open=true
await waitFor(() => {
// Check for dropdown container instead of specific text
expect(document.querySelector('.ant-cascader-dropdown')).toBeInTheDocument();
});
});
});
describe('Error handling and edge cases', () => {
it('should handle invalid metaTree function', async () => {
const invalidMetaTree = vi.fn().mockRejectedValue(new Error('Network error'));
const flowContext = createTestFlowContext();
render(
,
);
const cascader = screen.getByRole('button');
fireEvent.click(cascader);
await waitFor(() => {
expect(invalidMetaTree).toHaveBeenCalled();
});
// Should not crash and render properly
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('should handle null/undefined metaTree', () => {
const flowContext = createTestFlowContext();
render(
,
);
const cascader = screen.getByRole('button');
fireEvent.click(cascader);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('should handle invalid value format', async () => {
const flowContext = createTestFlowContext();
render(
flowContext.getPropertyMetaTree()} value="invalid.format" />
,
);
await waitFor(() => {
expect(screen.getByRole('button')).toBeInTheDocument();
});
});
it('should handle non-array parsed path in inline input mode', async () => {
const flowContext = createTestFlowContext();
const customParseValueToPath = vi.fn().mockReturnValue('' as any);
render(
flowContext.getPropertyMetaTree()}
value="invalid.path"
parseValueToPath={customParseValueToPath as any}
>
{null}
,
);
const inlineInput = await screen.findByRole('textbox');
expect(inlineInput).toBeInTheDocument();
expect((inlineInput as HTMLInputElement).value).toBe('');
fireEvent.focus(inlineInput);
await waitFor(() => {
expect(screen.getByText('User')).toBeInTheDocument();
});
});
it('should handle metaTree function returning non-array', async () => {
const invalidMetaTree = vi.fn().mockResolvedValue(null);
const flowContext = createTestFlowContext();
render(
,
);
const cascader = screen.getByRole('button');
fireEvent.click(cascader);
await waitFor(() => {
expect(invalidMetaTree).toHaveBeenCalled();
});
expect(screen.getByRole('button')).toBeInTheDocument();
});
});
describe('Cascader props validation', () => {
it('should accept and render with cascader props', async () => {
const flowContext = createTestFlowContext();
// Test that component accepts various Cascader props without crashing
render(
flowContext.getPropertyMetaTree()}
disabled={true}
placeholder="Select a variable"
size="large"
className="custom-cascader"
allowClear={true}
/>
,
);
// Should render without errors
await waitFor(() => {
expect(screen.getByRole('button')).toBeInTheDocument();
});
});
it('should pass through Cascader-specific props', async () => {
const flowContext = createTestFlowContext();
// Test expandTrigger and other Cascader-specific props
render(
flowContext.getPropertyMetaTree()}
expandTrigger="hover"
changeOnSelect={false}
displayRender={(labels) => labels.join(' > ')}
/>
,
);
await waitFor(() => {
expect(screen.getByRole('button')).toBeInTheDocument();
});
});
});
it('should only allow leaf selection when onlyLeafSelectable is true', async () => {
const onChange = vi.fn();
const flowContext = createTestFlowContext();
render(
flowContext.getPropertyMetaTree()}
onChange={onChange}
onlyLeafSelectable={true}
/>
,
);
const cascader = screen.getByRole('button');
fireEvent.click(cascader);
await waitFor(() => {
expect(screen.getByText('User')).toBeInTheDocument();
});
// Click on non-leaf node 'User' with onlyLeafSelectable=true
fireEvent.click(screen.getByText('User'));
// onChange should not be called for non-leaf node when onlyLeafSelectable=true
// It should only expand the node, not select it
expect(onChange).not.toHaveBeenCalled();
});
it('should expand but never select a node marked selectable=false', async () => {
const onChange = vi.fn();
const flowContext = createTestFlowContext();
const metaTree = [
{
name: 'date',
title: 'Date',
type: 'date',
paths: ['date'],
selectable: false,
children: [{ name: 'today', title: 'Today', type: 'date', paths: ['date', 'today'] }],
},
];
render(
,
);
fireEvent.click(screen.getByRole('button'));
await waitFor(() => expect(screen.getByText('Date')).toBeInTheDocument());
fireEvent.click(screen.getByText('Date'));
fireEvent.click(screen.getByText('Date'));
expect(onChange).not.toHaveBeenCalled();
await waitFor(() => expect(screen.getByText('Today')).toBeInTheDocument());
fireEvent.click(screen.getByText('Today'));
expect(onChange).toHaveBeenCalledWith(
'{{ ctx.date.today }}',
expect.objectContaining({ paths: ['date', 'today'] }),
);
});
});