feat(web): add hover clear button for provider search (#32707)

Signed-off-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
-LAN-
2026-02-27 20:42:30 +08:00
committed by GitHub
parent f9196f7bea
commit f73be8d69e
6 changed files with 51 additions and 77 deletions

View File

@@ -43,7 +43,7 @@ describe('Input component', () => {
it('shows left icon when showLeftIcon is true', () => {
render(<Input showLeftIcon />)
const searchIcon = document.querySelector('svg')
const searchIcon = document.querySelector('.i-ri-search-line')
expect(searchIcon).toBeInTheDocument()
const input = screen.getByPlaceholderText('Search')
expect(input).toHaveClass('pl-[26px]')
@@ -51,7 +51,7 @@ describe('Input component', () => {
it('shows clear icon when showClearIcon is true and has value', () => {
render(<Input showClearIcon value="test" />)
const clearIcon = document.querySelector('.group svg')
const clearIcon = document.querySelector('.i-ri-close-circle-fill')
expect(clearIcon).toBeInTheDocument()
const input = screen.getByDisplayValue('test')
expect(input).toHaveClass('pr-[26px]')
@@ -59,21 +59,21 @@ describe('Input component', () => {
it('does not show clear icon when disabled, even with value', () => {
render(<Input showClearIcon value="test" disabled />)
const clearIcon = document.querySelector('.group svg')
const clearIcon = document.querySelector('.i-ri-close-circle-fill')
expect(clearIcon).not.toBeInTheDocument()
})
it('calls onClear when clear icon is clicked', () => {
const onClear = vi.fn()
render(<Input showClearIcon value="test" onClear={onClear} />)
const clearIconContainer = document.querySelector('.group')
const clearIconContainer = screen.getByTestId('input-clear')
fireEvent.click(clearIconContainer!)
expect(onClear).toHaveBeenCalledTimes(1)
})
it('shows warning icon when destructive is true', () => {
render(<Input destructive />)
const warningIcon = document.querySelector('svg')
const warningIcon = document.querySelector('.i-ri-error-warning-line')
expect(warningIcon).toBeInTheDocument()
const input = screen.getByPlaceholderText('Please input')
expect(input).toHaveClass('border-components-input-border-destructive')

View File

@@ -1,6 +1,5 @@
import type { VariantProps } from 'class-variance-authority'
import type { ChangeEventHandler, CSSProperties, FocusEventHandler } from 'react'
import { RiCloseCircleFill, RiErrorWarningLine, RiSearchLine } from '@remixicon/react'
import { cva } from 'class-variance-authority'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
@@ -13,8 +12,8 @@ export const inputVariants = cva(
{
variants: {
size: {
regular: 'px-3 radius-md system-sm-regular',
large: 'px-4 radius-lg system-md-regular',
regular: 'px-3 system-sm-regular radius-md',
large: 'px-4 system-md-regular radius-lg',
},
},
defaultVariants: {
@@ -83,7 +82,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({
}
return (
<div className={cn('relative w-full', wrapperClassName)}>
{showLeftIcon && <RiSearchLine className={cn('absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-components-input-text-placeholder')} />}
{showLeftIcon && <span className={cn('i-ri-search-line absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-components-input-text-placeholder')} />}
<input
ref={ref}
style={styleCss}
@@ -115,11 +114,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({
onClick={onClear}
data-testid="input-clear"
>
<RiCloseCircleFill className="h-3.5 w-3.5 cursor-pointer text-text-quaternary group-hover:text-text-tertiary" />
<span className="i-ri-close-circle-fill h-3.5 w-3.5 cursor-pointer text-text-quaternary group-hover:text-text-tertiary" />
</div>
)}
{destructive && (
<RiErrorWarningLine className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-text-destructive-secondary" />
<span className="i-ri-error-warning-line absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-text-destructive-secondary" />
)}
{showCopyIcon && (
<div className={cn('group absolute right-0 top-1/2 -translate-y-1/2 cursor-pointer')}>
@@ -131,7 +130,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({
)}
{
unit && (
<div className="system-sm-regular absolute right-2 top-1/2 -translate-y-1/2 text-text-tertiary">
<div className="absolute right-2 top-1/2 -translate-y-1/2 text-text-tertiary system-sm-regular">
{unit}
</div>
)

View File

@@ -334,10 +334,10 @@ describe('FileList', () => {
it('should call resetKeywords prop when clear button is clicked', () => {
const mockResetKeywords = vi.fn()
const props = createDefaultProps({ resetKeywords: mockResetKeywords, keywords: 'to-reset' })
const { container } = render(<FileList {...props} />)
render(<FileList {...props} />)
// Act - Click the clear icon div (it contains RiCloseCircleFill icon)
const clearButton = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')?.parentElement
const clearButton = screen.getByTestId('input-clear')
expect(clearButton).toBeInTheDocument()
fireEvent.click(clearButton!)
@@ -346,12 +346,12 @@ describe('FileList', () => {
it('should reset inputValue to empty string when clear is clicked', () => {
const props = createDefaultProps({ keywords: 'to-be-reset' })
const { container } = render(<FileList {...props} />)
render(<FileList {...props} />)
const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')
fireEvent.change(input, { target: { value: 'some-search' } })
// Act - Find and click the clear icon
const clearButton = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')?.parentElement
const clearButton = screen.getByTestId('input-clear')
expect(clearButton).toBeInTheDocument()
fireEvent.click(clearButton!)

View File

@@ -93,8 +93,8 @@ describe('Header', () => {
const { container } = render(<Header {...props} />)
// Assert - Input should have search icon (RiSearchLine is rendered as svg)
const searchIcon = container.querySelector('svg.h-4.w-4')
// Assert - Input should have search icon class
const searchIcon = container.querySelector('.i-ri-search-line.h-4.w-4')
expect(searchIcon).toBeInTheDocument()
})
@@ -313,10 +313,10 @@ describe('Header', () => {
inputValue: 'to-clear',
handleResetKeywords: mockHandleResetKeywords,
})
const { container } = render(<Header {...props} />)
render(<Header {...props} />)
// Act - Find and click the clear icon container
const clearButton = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')?.parentElement
const clearButton = screen.getByTestId('input-clear')
expect(clearButton).toBeInTheDocument()
fireEvent.click(clearButton!)
@@ -325,19 +325,19 @@ describe('Header', () => {
it('should not show clear icon when inputValue is empty', () => {
const props = createDefaultProps({ inputValue: '' })
const { container } = render(<Header {...props} />)
render(<Header {...props} />)
// Act & Assert - Clear icon should not be visible
const clearIcon = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')
const clearIcon = screen.queryByTestId('input-clear')
expect(clearIcon).not.toBeInTheDocument()
})
it('should show clear icon when inputValue is not empty', () => {
const props = createDefaultProps({ inputValue: 'some-value' })
const { container } = render(<Header {...props} />)
render(<Header {...props} />)
// Act & Assert - Clear icon should be visible
const clearIcon = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')
const clearIcon = screen.getByTestId('input-clear')
expect(clearIcon).toBeInTheDocument()
})
})
@@ -570,13 +570,12 @@ describe('Header', () => {
inputValue: 'to-clear',
handleResetKeywords: mockHandleResetKeywords,
})
const { container, rerender } = render(<Header {...props} />)
const { rerender } = render(<Header {...props} />)
// Act - Click clear, rerender, click again
const clearButton = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')?.parentElement
fireEvent.click(clearButton!)
fireEvent.click(screen.getByTestId('input-clear'))
rerender(<Header {...props} />)
fireEvent.click(clearButton!)
fireEvent.click(screen.getByTestId('input-clear'))
expect(mockHandleResetKeywords).toHaveBeenCalledTimes(2)
})

View File

@@ -1,24 +1,8 @@
'use client'
import type { AccountSettingTab } from '@/app/components/header/account-setting/constants'
import {
RiBrain2Fill,
RiBrain2Line,
RiCloseLine,
RiColorFilterFill,
RiColorFilterLine,
RiDatabase2Fill,
RiDatabase2Line,
RiGroup2Fill,
RiGroup2Line,
RiMoneyDollarCircleFill,
RiMoneyDollarCircleLine,
RiPuzzle2Fill,
RiPuzzle2Line,
RiTranslate2,
} from '@remixicon/react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import SearchInput from '@/app/components/base/search-input'
import BillingPage from '@/app/components/billing/billing-page'
import CustomPage from '@/app/components/custom/custom-page'
import {
@@ -76,14 +60,14 @@ export default function AccountSetting({
{
key: ACCOUNT_SETTING_TAB.PROVIDER,
name: t('settings.provider', { ns: 'common' }),
icon: <RiBrain2Line className={iconClassName} />,
activeIcon: <RiBrain2Fill className={iconClassName} />,
icon: <span className={cn('i-ri-brain-2-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-brain-2-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.MEMBERS,
name: t('settings.members', { ns: 'common' }),
icon: <RiGroup2Line className={iconClassName} />,
activeIcon: <RiGroup2Fill className={iconClassName} />,
icon: <span className={cn('i-ri-group-2-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-group-2-fill', iconClassName)} />,
},
]
@@ -92,8 +76,8 @@ export default function AccountSetting({
key: ACCOUNT_SETTING_TAB.BILLING,
name: t('settings.billing', { ns: 'common' }),
description: t('plansCommon.receiptInfo', { ns: 'billing' }),
icon: <RiMoneyDollarCircleLine className={iconClassName} />,
activeIcon: <RiMoneyDollarCircleFill className={iconClassName} />,
icon: <span className={cn('i-ri-money-dollar-circle-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-money-dollar-circle-fill', iconClassName)} />,
})
}
@@ -101,14 +85,14 @@ export default function AccountSetting({
{
key: ACCOUNT_SETTING_TAB.DATA_SOURCE,
name: t('settings.dataSource', { ns: 'common' }),
icon: <RiDatabase2Line className={iconClassName} />,
activeIcon: <RiDatabase2Fill className={iconClassName} />,
icon: <span className={cn('i-ri-database-2-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-database-2-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION,
name: t('settings.apiBasedExtension', { ns: 'common' }),
icon: <RiPuzzle2Line className={iconClassName} />,
activeIcon: <RiPuzzle2Fill className={iconClassName} />,
icon: <span className={cn('i-ri-puzzle-2-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-puzzle-2-fill', iconClassName)} />,
},
)
@@ -116,8 +100,8 @@ export default function AccountSetting({
items.push({
key: ACCOUNT_SETTING_TAB.CUSTOM,
name: t('custom', { ns: 'custom' }),
icon: <RiColorFilterLine className={iconClassName} />,
activeIcon: <RiColorFilterFill className={iconClassName} />,
icon: <span className={cn('i-ri-color-filter-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-color-filter-fill', iconClassName)} />,
})
}
@@ -140,8 +124,8 @@ export default function AccountSetting({
{
key: ACCOUNT_SETTING_TAB.LANGUAGE,
name: t('settings.language', { ns: 'common' }),
icon: <RiTranslate2 className={iconClassName} />,
activeIcon: <RiTranslate2 className={iconClassName} />,
icon: <span className={cn('i-ri-translate-2', iconClassName)} />,
activeIcon: <span className={cn('i-ri-translate-2', iconClassName)} />,
},
],
},
@@ -171,13 +155,13 @@ export default function AccountSetting({
>
<div className="mx-auto flex h-[100vh] max-w-[1048px]">
<div className="flex w-[44px] flex-col border-r border-divider-burn pl-4 pr-6 sm:w-[224px]">
<div className="title-2xl-semi-bold mb-8 mt-6 px-3 py-2 text-text-primary">{t('userProfile.settings', { ns: 'common' })}</div>
<div className="mb-8 mt-6 px-3 py-2 text-text-primary title-2xl-semi-bold">{t('userProfile.settings', { ns: 'common' })}</div>
<div className="w-full">
{
menuItems.map(menuItem => (
<div key={menuItem.key} className="mb-2">
{!isCurrentWorkspaceDatasetOperator && (
<div className="system-xs-medium-uppercase mb-0.5 py-2 pb-1 pl-3 text-text-tertiary">{menuItem.name}</div>
<div className="mb-0.5 py-2 pb-1 pl-3 text-text-tertiary system-xs-medium-uppercase">{menuItem.name}</div>
)}
<div>
{
@@ -186,7 +170,7 @@ export default function AccountSetting({
key={item.key}
className={cn(
'mb-0.5 flex h-[37px] cursor-pointer items-center rounded-lg p-1 pl-3 text-sm',
activeMenu === item.key ? 'system-sm-semibold bg-state-base-active text-components-menu-item-text-active' : 'system-sm-medium text-components-menu-item-text',
activeMenu === item.key ? 'bg-state-base-active text-components-menu-item-text-active system-sm-semibold' : 'text-components-menu-item-text system-sm-medium',
)}
title={item.name}
onClick={() => {
@@ -213,25 +197,23 @@ export default function AccountSetting({
className="px-2"
onClick={onCancel}
>
<RiCloseLine className="h-5 w-5" />
<span className="i-ri-close-line h-5 w-5" />
</Button>
<div className="system-2xs-medium-uppercase mt-1 text-text-tertiary">ESC</div>
<div className="mt-1 text-text-tertiary system-2xs-medium-uppercase">ESC</div>
</div>
<div ref={scrollRef} className="w-full overflow-y-auto bg-components-panel-bg pb-4">
<div className={cn('sticky top-0 z-20 mx-8 mb-[18px] flex items-center bg-components-panel-bg pb-2 pt-[27px]', scrolled && 'border-b border-divider-regular')}>
<div className="title-2xl-semi-bold shrink-0 text-text-primary">
<div className="shrink-0 text-text-primary title-2xl-semi-bold">
{activeItem?.name}
{activeItem?.description && (
<div className="system-sm-regular mt-1 text-text-tertiary">{activeItem?.description}</div>
<div className="mt-1 text-text-tertiary system-sm-regular">{activeItem?.description}</div>
)}
</div>
{activeItem?.key === 'provider' && (
<div className="flex grow justify-end">
<Input
showLeftIcon
wrapperClassName="!w-[200px]"
className="!h-8 !text-[13px]"
onChange={e => setSearchValue(e.target.value)}
<SearchInput
className="w-[200px]"
onChange={setSearchValue}
value={searchValue}
/>
</div>

View File

@@ -2052,9 +2052,6 @@
"app/components/base/input/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
},
"app/components/base/linked-apps-panel/index.tsx": {
@@ -3994,9 +3991,6 @@
"app/components/header/account-setting/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 7
}
},
"app/components/header/account-setting/key-validator/declarations.ts": {