Skip to content

Commit

Permalink
feat: update SearchInput component with debounce functionality (#250)
Browse files Browse the repository at this point in the history
#222

---------

Co-authored-by: Martin Rohrmeier <[email protected]>
  • Loading branch information
charmi-v and oyo authored Aug 8, 2024
1 parent 6a2d5a8 commit 71062de
Show file tree
Hide file tree
Showing 13 changed files with 400 additions and 5 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 3.0.32

- Updated SearchInput component with debounce functionality

## 3.0.31

- Fix linting error and warnings
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.31",
"version": "3.0.32",
"description": "Catena-X Portal Shared Components",
"author": "Catena-X Contributors",
"license": "Apache-2.0",
Expand Down
26 changes: 25 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 { type 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> = (
Expand All @@ -35,3 +42,20 @@ const Template: ComponentStory<typeof Component> = (

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'
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

0 comments on commit 71062de

Please sign in to comment.