diff --git a/package.json b/package.json index 9269da9ed..659f98861 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "0.0.26", + "version": "0.0.27", "description": "Supporting common component library", "main": "dist/index.js", "scripts": { diff --git a/src/Assets/Icon/ic-error-cross.svg b/src/Assets/Icon/ic-error-cross.svg new file mode 100644 index 000000000..ea48072bf --- /dev/null +++ b/src/Assets/Icon/ic-error-cross.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Common/DebouncedSearch/DebouncedSearch.tsx b/src/Common/DebouncedSearch/DebouncedSearch.tsx new file mode 100644 index 000000000..bbf38c541 --- /dev/null +++ b/src/Common/DebouncedSearch/DebouncedSearch.tsx @@ -0,0 +1,75 @@ +import React, { useEffect, useState } from 'react' +import { useDebouncedEffect } from './Utils' +import { ReactComponent as ICClear } from '../../Assets/Icon/ic-error-cross.svg' +import { DebouncedSearchProps } from './Types' + +/** + * @param onSearch - Callback function to be called on search + * @param Icon - (Optional) Icon to be shown before the input + * @param iconClass - (Optional) Class for the icon + * @param children - (Optional) In case we want to add another button or any other element + * @param placeholder - (Optional) Placeholder for the input + * @param containerClass - (Optional) Class for the container + * @param inputClass - (Optional) Class for the input field + * @param debounceTimeout - (Optional) Timeout for the debounce with default value of 500ms + * @param clearSearch - (Optional) To clear the search text + * @param showClearIcon - (Optional) To show the clear icon default value is true + */ +export default function DebouncedSearch({ + onSearch, + Icon, + children, + placeholder, + containerClass = '', + iconClass = '', + inputClass = '', + debounceTimeout = 500, + clearSearch, + showClearIcon = true, +}: DebouncedSearchProps) { + const [searchText, setSearchText] = useState('') + + useEffect(() => { + setSearchText('') + }, [clearSearch]) + + const handleSearchTextChange = (e: React.ChangeEvent) => { + setSearchText(e.target.value) + } + + const handleClearSearch = () => { + setSearchText('') + } + + useDebouncedEffect(() => onSearch(searchText), debounceTimeout, [searchText, onSearch]) + + return ( +
+ {Icon && } + + + + {showClearIcon && !!searchText && ( + + )} + + {/* In case we want to add another button or something */} + {children} +
+ ) +} diff --git a/src/Common/DebouncedSearch/Types.ts b/src/Common/DebouncedSearch/Types.ts new file mode 100644 index 000000000..972d8a407 --- /dev/null +++ b/src/Common/DebouncedSearch/Types.ts @@ -0,0 +1,12 @@ +export interface DebouncedSearchProps { + onSearch: (query: string) => void + Icon?: React.FunctionComponent> + placeholder?: string + inputClass?: string + containerClass?: string + iconClass?: string + children?: React.ReactNode + debounceTimeout?: number + clearSearch?: boolean + showClearIcon?: boolean +} diff --git a/src/Common/DebouncedSearch/Utils.ts b/src/Common/DebouncedSearch/Utils.ts new file mode 100644 index 000000000..1891ca56c --- /dev/null +++ b/src/Common/DebouncedSearch/Utils.ts @@ -0,0 +1,19 @@ +import { useEffect, useRef } from 'react' + +export function useDebouncedEffect(callback, delay, deps: unknown[] = []) { + // function will be executed only after the specified time once the user stops firing the event. + const firstUpdate = useRef(true) + useEffect(() => { + if (firstUpdate.current) { + firstUpdate.current = false + return + } + const handler = setTimeout(() => { + callback() + }, delay) + + return () => { + clearTimeout(handler) + } + }, [delay, ...deps]) +} diff --git a/src/Common/DebouncedSearch/__tests__/DebouncedSearch.test.tsx b/src/Common/DebouncedSearch/__tests__/DebouncedSearch.test.tsx new file mode 100644 index 000000000..9df596f1f --- /dev/null +++ b/src/Common/DebouncedSearch/__tests__/DebouncedSearch.test.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { ReactComponent as ICClear } from '../../../../assets/icons/ic-error.svg' +import DebouncedSearch from '../DebouncedSearch' + +jest.mock('../../helpers/Helpers', () => ({ + useDebouncedEffect: jest.fn().mockImplementation((fn) => fn()), +})) + +describe('When DebouncedSearch mounts', () => { + it('should have a input field', () => { + render() + expect(screen.getByRole('textbox')).toBeTruthy() + }) + + it('should have a clear icon when clearSearch is true and user has typed something', () => { + const { getByTestId } = render() + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'test' } }) + expect(getByTestId('clear-search')).toBeTruthy() + }) + + it('should call onSearch when input value changes', () => { + const onSearch = jest.fn() + render() + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'test' } }) + expect(onSearch).toHaveBeenCalled() + }) + + it('should clear input value when clear icon is clicked', () => { + const { container } = render() + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'test' } }) + const inputBar = container.querySelector('input') + expect(inputBar.value).toBe('test') + fireEvent.click(screen.getByRole('button')) + expect(inputBar.value).toBe('') + }) + + it('should have a placeholder', () => { + render() + expect(screen.getByPlaceholderText('test')).toBeTruthy() + }) + + it('should not show clear icon when showClearIcon is false', () => { + render() + expect(screen.queryByRole('button')).toBeNull() + }) + + it('should have a custom Icon', () => { + const { container } = render() + expect(container.querySelector('.icon-class')).toBeTruthy() + }) + + it('should support children', () => { + render( + +
test
+
, + ) + expect(screen.getByText('test')).toBeTruthy() + }) +}) diff --git a/src/Common/Grid/Grid.tsx b/src/Common/Grid/Grid.tsx new file mode 100644 index 000000000..108a7c50c --- /dev/null +++ b/src/Common/Grid/Grid.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import { GridProps } from './types' + +// This is meant to be a reusable component that will provide a grid like dynamic layout where xs is the number of columns out of 12 that the item will take up +export default function Grid({ + container, + spacing = 0, + item, + xs, + containerClass = '', + itemClass = '', + children, +}: GridProps) { + const containerStyles = container ? { gap: spacing + 'px' } : {} + + if (item) { + const getColumnWidth = () => { + const percentageWidth = (xs / 12) * 100 + // DONT CHANGE IT FROM CALC SINCE CALC CONVERTS TO PX which is needed to handle text overflow + return `calc(${percentageWidth}%)` + } + + const itemStyles = { + flex: `1 1 ${getColumnWidth()}`, + } + + return ( +
+ {children} +
+ ) + } + + return ( +
+ {children} +
+ ) +} diff --git a/src/Common/Grid/types.ts b/src/Common/Grid/types.ts new file mode 100644 index 000000000..82bbce7ca --- /dev/null +++ b/src/Common/Grid/types.ts @@ -0,0 +1,9 @@ +export interface GridProps { + container?: boolean + spacing?: number + item?: boolean + xs?: number + containerClass?: string + itemClass?: string + children?: React.ReactNode +} diff --git a/src/Common/index.ts b/src/Common/index.ts index d79e17934..b7ac8d78e 100644 --- a/src/Common/index.ts +++ b/src/Common/index.ts @@ -34,4 +34,6 @@ export * from './Policy.Types' export { default as DeleteComponent } from './DeleteComponentModal/DeleteComponent' export * from './ImageTags' export * from './ImageTags.Types' -export * from './ResizableTextarea' \ No newline at end of file +export * from './ResizableTextarea' +export { default as DebouncedSearch } from './DebouncedSearch/DebouncedSearch' +export { default as Grid } from './Grid/Grid'