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
@@ -1,4 +1,5 @@
import {Input} from '@/components/ui/input';
import {Spinner} from '@/components/ui/spinner';
import WorkflowNodesTabs from '@/pages/platform/workflow-editor/components/workflow-nodes-tabs/WorkflowNodesTabs';
import useWorkflowDataStore from '@/pages/platform/workflow-editor/stores/useWorkflowDataStore';
import {TaskDispatcherDefinition} from '@/shared/middleware/platform/configuration';
Expand Down Expand Up @@ -60,7 +61,7 @@ const WorkflowNodesPopoverMenuComponentList = memo(
);
const {nodes} = useWorkflowDataStore(useShallow((state) => ({nodes: state.nodes})));

const {componentsWithActions, filter, setFilter, trimmedFilter} =
const {componentsWithActions, filter, isSearchFetching, setFilter, trimmedFilter} =
useFilteredComponentDefinitions(componentDefinitions);

const getFeatureFlag = useFeatureFlagsStore();
Expand Down Expand Up @@ -134,14 +135,20 @@ const WorkflowNodesPopoverMenuComponentList = memo(
return (
<div className={twMerge('rounded-lg', actionPanelOpen ? 'w-node-popover-width' : 'w-full')}>
<header className="flex items-center gap-1 rounded-t-lg px-3 pt-3 text-center">
<Input
className="bg-white shadow-none"
id="filter-components"
name="workflowNodeFilter"
onChange={(event) => setFilter(event.target.value)}
placeholder="Filter components"
value={filter}
/>
<div className="relative w-full">
<Input
className={twMerge('bg-white shadow-none', isSearchFetching && 'pr-8')}
id="filter-components"
name="workflowNodeFilter"
onChange={(event) => setFilter(event.target.value)}
placeholder="Filter components"
value={filter}
/>

{isSearchFetching && (
<Spinner className="absolute right-2 top-1/2 -translate-y-1/2 text-content-neutral-secondary" />
)}
</div>
</header>

<div className="h-96 rounded-b-lg pb-3">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {act, renderHook} from '@testing-library/react';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';

const hoisted = vi.hoisted(() => {
const mockQueryResult = {
data: null as Array<{actionsCount: number; name: string; triggersCount: number; version: number}> | null,
isFetching: false,
};

return {mockQueryResult};
});

vi.mock('use-debounce', () => ({
useDebounce: (value: string) => [value],
}));

vi.mock('@/shared/queries/platform/componentDefinitionsGraphQL.queries', () => ({
useGetComponentDefinitionsWithActionsQuery: () => hoisted.mockQueryResult,
}));

const componentDefinitions = [
{actionsCount: 3, name: 'gmail', triggersCount: 1, version: 1},
{actionsCount: 2, name: 'slack', triggersCount: 0, version: 1},
{actionsCount: 1, name: 'github', triggersCount: 2, version: 1},
];

describe('useFilteredComponentDefinitions', () => {
beforeEach(() => {
hoisted.mockQueryResult.data = null;
hoisted.mockQueryResult.isFetching = false;
});

afterEach(() => {
vi.clearAllMocks();
});

it('should return original componentDefinitions when filter is empty', async () => {
const {useFilteredComponentDefinitions} = await import('../useFilteredComponentDefinitions');

const {result} = renderHook(() => useFilteredComponentDefinitions(componentDefinitions));

expect(result.current.componentsWithActions).toBe(componentDefinitions);
expect(result.current.filter).toBe('');
expect(result.current.isSearchFetching).toBe(false);
});

it('should return search results when filter is set and data is available', async () => {
const searchResults = [{actionsCount: 3, name: 'gmail', triggersCount: 1, version: 1}];

hoisted.mockQueryResult.data = searchResults;
hoisted.mockQueryResult.isFetching = false;

const {useFilteredComponentDefinitions} = await import('../useFilteredComponentDefinitions');

const {result} = renderHook(() => useFilteredComponentDefinitions(componentDefinitions));

act(() => {
result.current.setFilter('gmail');
});

expect(result.current.componentsWithActions).toBe(searchResults);
});

it('should return isSearchFetching as true when query is in flight', async () => {
hoisted.mockQueryResult.isFetching = true;

const {useFilteredComponentDefinitions} = await import('../useFilteredComponentDefinitions');

const {result} = renderHook(() => useFilteredComponentDefinitions(componentDefinitions));

expect(result.current.isSearchFetching).toBe(true);
});

it('should return previous search results while fetching new ones (keepPreviousData)', async () => {
const previousResults = [{actionsCount: 3, name: 'gmail', triggersCount: 1, version: 1}];

hoisted.mockQueryResult.data = previousResults;
hoisted.mockQueryResult.isFetching = true;

const {useFilteredComponentDefinitions} = await import('../useFilteredComponentDefinitions');

const {result} = renderHook(() => useFilteredComponentDefinitions(componentDefinitions));

act(() => {
result.current.setFilter('gma');
});

expect(result.current.componentsWithActions).toBe(previousResults);
expect(result.current.isSearchFetching).toBe(true);
});

it('should return original componentDefinitions when filter is whitespace only', async () => {
const {useFilteredComponentDefinitions} = await import('../useFilteredComponentDefinitions');

const {result} = renderHook(() => useFilteredComponentDefinitions(componentDefinitions));

act(() => {
result.current.setFilter(' ');
});

expect(result.current.componentsWithActions).toBe(componentDefinitions);
});

it('should expose trimmedFilter as debounced and trimmed value', async () => {
const {useFilteredComponentDefinitions} = await import('../useFilteredComponentDefinitions');

const {result} = renderHook(() => useFilteredComponentDefinitions(componentDefinitions));

act(() => {
result.current.setFilter(' gmail ');
});

expect(result.current.trimmedFilter).toBe('gmail');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {useDebounce} from 'use-debounce';
type UseFilteredComponentDefinitionsReturnType = {
componentsWithActions: Array<ComponentDefinitionBasic | ComponentDefinitionWithActionsProps>;
filter: string;
isSearchLoading: boolean;
isSearchFetching: boolean;
setFilter: Dispatch<SetStateAction<string>>;
trimmedFilter: string;
};
Expand All @@ -23,15 +23,16 @@ export const useFilteredComponentDefinitions = (

const trimmedFilter = debouncedFilter.trim();

const {data: searchedComponentDefinitions, isLoading: isSearchLoading} =
const {data: searchedComponentDefinitions, isFetching: isSearchFetching} =
useGetComponentDefinitionsWithActionsQuery(trimmedFilter);

const componentsWithActions = useMemo(() => {
if (trimmedFilter && searchedComponentDefinitions && !isSearchLoading) {
if (trimmedFilter && searchedComponentDefinitions) {
return searchedComponentDefinitions;
}

return componentDefinitions;
}, [trimmedFilter, searchedComponentDefinitions, isSearchLoading, componentDefinitions]);
}, [trimmedFilter, searchedComponentDefinitions, componentDefinitions]);

return {componentsWithActions, filter, isSearchLoading, setFilter, trimmedFilter};
return {componentsWithActions, filter, isSearchFetching, setFilter, trimmedFilter};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {render, resetAll, screen, windowResizeObserver} from '@/shared/util/test-utils';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';

const hoisted = vi.hoisted(() => ({
mockFilterResult: {
componentsWithActions: [
{actionsCount: 3, name: 'gmail', triggersCount: 1, version: 1},
{actionsCount: 2, name: 'slack', triggersCount: 0, version: 1},
],
filter: '',
isSearchFetching: false,
setFilter: vi.fn(),
trimmedFilter: '',
},
}));

vi.mock('../hooks/useFilteredComponentDefinitions', () => ({
useFilteredComponentDefinitions: () => hoisted.mockFilterResult,
}));

vi.mock('../stores/useWorkflowDataStore', () => ({
default: Object.assign(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(selector: any) =>
selector({
componentDefinitions: [
{actionsCount: 3, name: 'gmail', triggersCount: 1, version: 1},
{actionsCount: 2, name: 'slack', triggersCount: 0, version: 1},
],
nodes: [],
taskDispatcherDefinitions: [],
}),
{getState: vi.fn()}
),
}));

vi.mock('@/shared/stores/useFeatureFlagsStore', () => ({
useFeatureFlagsStore: () => () => false,
}));

vi.mock('@/shared/stores/useApplicationInfoStore', () => ({
useApplicationInfoStore: () => false,
}));

vi.mock('../components/workflow-nodes-tabs/WorkflowNodesTabs', () => ({
default: () => <div data-testid="workflow-nodes-tabs">Tabs</div>,
}));

describe('WorkflowNodesPopoverMenuComponentList', () => {
beforeEach(() => {
windowResizeObserver();

hoisted.mockFilterResult.isSearchFetching = false;
hoisted.mockFilterResult.filter = '';
hoisted.mockFilterResult.trimmedFilter = '';
});

afterEach(() => {
resetAll();
});

it('should render filter input', async () => {
const {default: WorkflowNodesPopoverMenuComponentList} =
await import('../components/WorkflowNodesPopoverMenuComponentList');

render(<WorkflowNodesPopoverMenuComponentList actionPanelOpen={false} />);

expect(screen.getByPlaceholderText('Filter components')).toBeInTheDocument();
});

it('should show loading spinner when search is fetching', async () => {
hoisted.mockFilterResult.isSearchFetching = true;

const {default: WorkflowNodesPopoverMenuComponentList} =
await import('../components/WorkflowNodesPopoverMenuComponentList');

render(<WorkflowNodesPopoverMenuComponentList actionPanelOpen={false} />);

expect(screen.getByRole('status')).toBeInTheDocument();
});

it('should not show loading spinner when search is not fetching', async () => {
hoisted.mockFilterResult.isSearchFetching = false;

const {default: WorkflowNodesPopoverMenuComponentList} =
await import('../components/WorkflowNodesPopoverMenuComponentList');

render(<WorkflowNodesPopoverMenuComponentList actionPanelOpen={false} />);

expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {useComponentDefinitionSearchQuery} from '@/shared/middleware/graphql';
import {ComponentDefinitionBasic} from '@/shared/middleware/platform/configuration';
import {keepPreviousData} from '@tanstack/react-query';
import {useMemo} from 'react';

export interface ComponentDefinitionWithActionsProps extends ComponentDefinitionBasic {
Expand Down Expand Up @@ -29,6 +30,7 @@ export const useGetComponentDefinitionsWithActionsQuery = (searchQuery?: string)
{
enabled: hasSearchQuery,
gcTime: 30 * 60 * 1000,
placeholderData: keepPreviousData,
staleTime: 10 * 60 * 1000,
}
);
Expand All @@ -44,5 +46,6 @@ export const useGetComponentDefinitionsWithActionsQuery = (searchQuery?: string)
return {
...result,
data: transformedData,
isFetching: result.isFetching,
};
};
Loading