Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: SearchInput component with debounce functionality #250

Merged
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 3.0.31

- Updated SearchInput component with debounce functionality

## 3.0.30

- Fix linting error and warnings

## 3.0.29

- updated status tag color and also updated theme and chip with 'deleted' property
Expand Down
42 changes: 42 additions & 0 deletions docs/storybook/SearchInput.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
### Overview

- The SearchInput component is a versatile input field that supports both controlled and uncontrolled modes with debounce functionality.

### Interaction

- Users can enter search terms into the input field. If the `type` prop is not specified, it defaults to a search field, which browsers style differently to indicate its purpose.
- When the input is having search term, a clear icon appears to help users reset their search.

### Context and Usage

The SearchInput component supports both controlled and uncontrolled modes:
- It provides flexible input functionality with debounce support and can be used in both controlled and uncontrolled modes.
- Use the `onSearch` handler to manage API calls, which will be triggered based on the specified debounce timeout.

#### Common Behavior

- The `onChange` handler triggers on every character change.
- The debounce feature, with a default delay of 0 milliseconds, can be customized using the `debounceTimeout` prop.
- The `onSearch` function is called after the debounce delay, allowing for API calls.

#### Controlled Mode

- Use the `value` prop to control the input.

#### Uncontrolled Mode

- Use the `defaultValue` prop to set the initial input value.

### Guidance

- The `onChange` handler functions the same as in a standard input field.
- All props related to the MUI TextField component are supported.
- New props added for managing the debounce feature: `debounceTimeout` and `onSearch`.

## NOTICE

This work is licensed under the [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/legalcode).

- SPDX-License-Identifier: CC-BY-4.0
- SPDX-FileCopyrightText: Copyright (c) 2024 Contributors to the Eclipse Foundation
- Source URL: https://github.com/eclipse-tractusx/portal-shared-components
9 changes: 9 additions & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,13 @@ module.exports = {
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
moduleNameMapper: {
'\\.(css|scss)$': '<rootDir>/src/test/__mocks__/styleMock.ts',
'\\.svg$': '<rootDir>/src/test/__mocks__/fileMock.ts',
'@mui/material/styles/createPalette':
'<rootDir>/src/test/__mocks__/createPalette.ts',
'@mui/material/styles/createTypography':
'<rootDir>/src/test/__mocks__/createTypography.ts',
},
setupFiles: ['<rootDir>/src/test/setupTests.ts'],
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@catena-x/portal-shared-components",
"version": "3.0.29",
"version": "3.0.31",
"description": "Catena-X Portal Shared Components",
"author": "Catena-X Contributors",
"license": "Apache-2.0",
Expand Down
22 changes: 21 additions & 1 deletion src/components/basic/SearchInput/SearchInput.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,22 @@
* SPDX-License-Identifier: Apache-2.0
********************************************************************************/

import { useState } from 'react'
import { ComponentStory } from '@storybook/react'

import { SearchInput as Component } from '.'
import searchInputDoc from '../../../../docs/storybook/SearchInput.md?raw'

export default {
title: 'Form',
title: 'Form/SearchInput',
component: Component,
tags: ['autodocs'],
argTypes: {},
parameters: {
docs: {
description: { component: searchInputDoc },
},
},
}

const Template: ComponentStory<typeof Component> = (args: any) => (
Expand All @@ -35,3 +42,16 @@ const Template: ComponentStory<typeof Component> = (args: any) => (

export const SearchInput = Template.bind({})
SearchInput.args = {}

export const SearchInputWithDebounce = () => {
const [term, setTerm] = useState('')

return (
<Component
debounceTimeout={500}
value={term}
onSearch={(v) => console.log('onSearch', v)}
onChange={(e) => setTerm(e.target.value)}
/>
)
}
125 changes: 125 additions & 0 deletions src/components/basic/SearchInput/SearchInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/********************************************************************************
* Copyright (c) 2024 Contributors to the Eclipse Foundation
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Apache License, Version 2.0 which is available at
* https://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* SPDX-License-Identifier: Apache-2.0
********************************************************************************/

import { useState } from 'react'
oyo marked this conversation as resolved.
Show resolved Hide resolved
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { SearchInput } from '.'
import { render } from '../../../test/testUtils'

jest.useFakeTimers()

describe('SearchInput', () => {
const debounceTimeout = 300
const onSearch = jest.fn()
const onChange = jest.fn()

const ControlledSearchInput = () => {
const [value, setValue] = useState('')
return (
<SearchInput
debounceTimeout={debounceTimeout}
onSearch={onSearch}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Search"
/>
)
}
const UncontrolledSearchInput = () => (
<SearchInput onSearch={onSearch} onChange={onChange} placeholder="Search" />
)

beforeEach(() => {
jest.clearAllMocks()
})

test('renders the Search Input component', () => {
render(<SearchInput />)
})

test('should call search function after specified debounce timeout', async () => {
render(<UncontrolledSearchInput />)
const input = screen.getByPlaceholderText('Search')
fireEvent.change(input, { target: { value: 'te' } })
fireEvent.change(input, { target: { value: 'testing...' } })
expect(onSearch).not.toHaveBeenCalled()
expect(onChange).toHaveBeenCalledTimes(2)

jest.advanceTimersByTime(debounceTimeout)
await waitFor(() => expect(onSearch).toHaveBeenCalledWith('testing...'))
expect(onSearch).toHaveBeenCalledTimes(1)
})

test('should call onChange handler when input value changes', () => {
render(<UncontrolledSearchInput />)

const input = screen.getByPlaceholderText('Search')
fireEvent.change(input, { target: { value: 't' } })
expect(onChange).toHaveBeenCalledTimes(1)
fireEvent.change(input, { target: { value: 'te' } })
expect(onChange).toHaveBeenCalledTimes(2)
fireEvent.change(input, { target: { value: 'test' } })
expect(onChange).toHaveBeenCalledTimes(3)
})

test('should call debounced search function with new value after reset', async () => {
render(<ControlledSearchInput />)

const input = screen.getByPlaceholderText('Search')
fireEvent.change(input, { target: { value: 'test' } })

jest.advanceTimersByTime(debounceTimeout)
await waitFor(() => expect(onSearch).toHaveBeenCalledWith('test'))

fireEvent.change(input, { target: { value: '' } })

jest.advanceTimersByTime(debounceTimeout)
await waitFor(() => expect(onSearch).toHaveBeenCalledWith(''))
})

test('should not call search function on initial render when value is provided', () => {
render(<ControlledSearchInput />)

jest.advanceTimersByTime(debounceTimeout)
expect(onChange).not.toHaveBeenCalled()
})

test('should call search function when controlled value changes', async () => {
const { rerender } = render(
<SearchInput
debounceTimeout={debounceTimeout}
onSearch={onSearch}
value="initial"
onChange={onChange}
/>
)

rerender(
<SearchInput
debounceTimeout={debounceTimeout}
onSearch={onSearch}
value="updated"
onChange={onChange}
/>
)

jest.advanceTimersByTime(debounceTimeout)
await waitFor(() => expect(onSearch).toHaveBeenCalledWith('updated'))
})
})
44 changes: 41 additions & 3 deletions src/components/basic/SearchInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,64 @@
* SPDX-License-Identifier: Apache-2.0
********************************************************************************/

import { useCallback, useEffect, useState } from 'react'
import SearchIcon from '@mui/icons-material/Search'
import {
Box,
type IconProps,
debounce,
TextField,
type TextFieldProps,
useTheme,
} from '@mui/material'

interface SearchProps extends Omit<TextFieldProps, 'variant'> {
variant?: 'outlined'
endAdornment?: IconProps
endAdornment?: React.ReactNode
debounceTimeout?: number
onSearch?: (value: string) => void
}

export const SearchInput = ({
debounceTimeout = 0,
onSearch,
variant,
endAdornment,
...props
}: SearchProps) => {
const theme = useTheme()
const { icon01 } = theme.palette.icon
const { value, onChange } = props

const [isMounted, setIsMounted] = useState(false)

useEffect(() => {
setIsMounted(true)
}, [])

// Memoize the debounced function
const debouncedSearch = useCallback(
debounce((query: string) => {
onSearch?.(query)
}, debounceTimeout),
[]
)

// Handle debounce when input is controlled
useEffect(() => {
if (isMounted && value !== undefined) {
debouncedSearch(value as string)
}
}, [value])

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value

// Handle debounce when input is uncontrolled
if (value === undefined) {
debouncedSearch(newValue)
}
onChange?.(e)
}

return (
<Box className="cx-search-input">
Expand All @@ -52,9 +89,10 @@ export const SearchInput = ({
type={props.type ?? 'search'}
InputProps={{
startAdornment: <SearchIcon sx={{ color: icon01, marginRight: 2 }} />,
endAdornment: endAdornment === undefined ? endAdornment : null,
endAdornment: endAdornment ?? null,
}}
{...props}
onChange={handleChange}
/>
</Box>
)
Expand Down
22 changes: 22 additions & 0 deletions src/test/__mocks__/createPalette.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/********************************************************************************
* Copyright (c) 2024 Contributors to the Eclipse Foundation
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Apache License, Version 2.0 which is available at
* https://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* SPDX-License-Identifier: Apache-2.0
********************************************************************************/

import { type Palette } from '@mui/material'
const createPalette: (palette: Palette) => Palette = (palette) => palette
export default createPalette
30 changes: 30 additions & 0 deletions src/test/__mocks__/createTypography.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/********************************************************************************
* Copyright (c) 2024 Contributors to the Eclipse Foundation
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Apache License, Version 2.0 which is available at
* https://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* SPDX-License-Identifier: Apache-2.0
********************************************************************************/

import { type Palette } from '@mui/material'
import { type TypographyOptions } from '@mui/material/styles/createTypography'

const createTypography: (
palette: Palette,
typography: TypographyOptions
) => TypographyOptions = (palette, typography) => ({
...palette,
...typography,
})
export default createTypography
Loading
Loading