feat: SearchBox组件支持disabled & loading状态 (#8821)

This commit is contained in:
RUNZE LU 2023-12-18 18:20:58 +08:00 committed by GitHub
parent 02e2552f00
commit c936aaedf2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 300 additions and 14 deletions

View File

@ -29,6 +29,161 @@ icon:
} }
``` ```
## 禁用样式
> `6.0.0`及以上版本
```schema
{
"type": "page",
"initApi": "/api/mock2/page/initData?keywords=${keywords}",
"body": [
{
"type": "flex",
"direction": "column",
"justify": "flex-start",
"alignItems": "flex-start",
"items": [
{
"type": "search-box",
"name": "keywords",
"disabled": true,
"style": {
"marginBottom": "10px"
}
},
{
"type": "search-box",
"name": "keywords",
"mini": true,
"disabled": true
}
]
}
]
}
```
## 加载状态
> `6.0.0` 及以上版本
设置`"loading": true`, 标识开关操作的异步任务仍在执行中。另外`loadingOn`支持表达式,配合`ajax`动作实现搜索操作时的loading状态。
```schema
{
"type": "page",
"id": "demo-page",
"data": {
"isFetching": false,
"fetched": false
},
"body": [
{
"type": "search-box",
"name": "keywords",
"clearable": true,
"loadingOn": "${isFetching}",
"className": "mb-2",
"onEvent": {
"search": {
"actions": [
{
"actionType": "setValue",
"componentId": "demo-page",
"args": {
"value": {
"isFetching": true
}
}
},
{
"actionType": "toast",
"args": {
"msgType": "warning",
"msg": "开始检索..."
}
},
{
"actionType": "ajax",
"api": {
"url": "/api/mock2/sample?perPage=5&waitSeconds=2&keywords=${keywords}",
"method": "get",
"messages": {
"success": "检索完成",
"failed": "检索失败"
}
}
},
{
"actionType": "setValue",
"componentId": "demo-page",
"args": {
"value": {
"isFetching": false,
"fetched": true,
"total": "${event.data.responseResult.count}",
"datasource": "${event.data.responseResult.rows}"
}
}
}
]
}
}
},
{
"type": "flex",
"visibleOn": "${fetched && !isFetching}",
"justify": "flex-start",
"alignItems": "flex-start",
"direction": "column",
"items": [
{
"type": "flex",
"className": "mb-2",
"alignItems": "self-end",
"items": [
{
"type": "status",
"value": "success",
"className": "mr-2",
"labelMap": {
"success": "检索成功:"
}
},
{
"type": "tpl",
"tpl": "总计${total}条当前仅展示5条"
},
]
},
{
"type": "list",
"source": "${datasource}",
"listItem": {
"body": [
{
"type": "hbox",
"columns": [
{
"label": "Engine",
"name": "engine"
},
{
"name": "version",
"label": "Version"
}
]
}
]
}
}
]
}
]
}
```
## 加强样式 ## 加强样式
```schema ```schema
@ -224,6 +379,8 @@ icon:
| mini | `boolean` | | 是否为 mini 模式 | | mini | `boolean` | | 是否为 mini 模式 |
| searchImediately | `boolean` | | 是否立即搜索 | | searchImediately | `boolean` | | 是否立即搜索 |
| clearAndSubmit | `boolean` | | 清空搜索框内容后立即执行搜索 | `2.8.0` | | clearAndSubmit | `boolean` | | 清空搜索框内容后立即执行搜索 | `2.8.0` |
| disabled | `boolean` | `false` | 是否为禁用状态 | `6.0.0` |
| loading | `boolean` | `false` | 是否处于加载状态 | `6.0.0` |
## 事件表 ## 事件表

View File

@ -826,6 +826,7 @@ $Table-strip-bg: transparent;
--SearchBox-enhonce-disabled-color: var(--colors-neutral-text-9); --SearchBox-enhonce-disabled-color: var(--colors-neutral-text-9);
--SearchBox-enhonce-disabled-search-color: var(--colors-neutral-text-6); --SearchBox-enhonce-disabled-search-color: var(--colors-neutral-text-6);
--SearchBox-enhonce-clearable-gap: var(--borders-radius-4); --SearchBox-enhonce-clearable-gap: var(--borders-radius-4);
--SearchBox-search-btn-color--disabled: var(--colors-neutral-fill-6);
--IconSelect-searchBox-width: #{px2rem(246px)}; --IconSelect-searchBox-width: #{px2rem(246px)};
--IconSelect-type-item-height: #{px2rem(48px)}; --IconSelect-type-item-height: #{px2rem(48px)};

View File

@ -41,6 +41,11 @@
&-searchBtn { &-searchBtn {
display: inline-block; display: inline-block;
padding: #{px2rem(5px)} #{px2rem(10px)}; padding: #{px2rem(5px)} #{px2rem(10px)};
&--loading {
display: inline-flex;
align-items: center;
}
} }
&-activeBtn, &-activeBtn,
@ -65,7 +70,7 @@
&.is-disabled &-activeBtn, &.is-disabled &-activeBtn,
&.is-disabled &-searchBtn, &.is-disabled &-searchBtn,
&.is-disabled &-cancelBtn { &.is-disabled &-cancelBtn {
color: var(--icon-onDisabled-color); color: var(--SearchBox-search-btn-color--disabled);
pointer-events: none; pointer-events: none;
} }

View File

@ -9,6 +9,9 @@ import {autobind} from 'amis-core';
import {LocaleProps, localeable} from 'amis-core'; import {LocaleProps, localeable} from 'amis-core';
import chain from 'lodash/chain'; import chain from 'lodash/chain';
import Input from './Input'; import Input from './Input';
import Spinner from './Spinner';
import {SpinnerExtraProps} from './Spinner';
export interface HistoryRecord { export interface HistoryRecord {
/** 历史记录值 */ /** 历史记录值 */
@ -28,7 +31,10 @@ export interface SearchHistoryOptions {
dropdownClassName?: string; dropdownClassName?: string;
} }
export interface SearchBoxProps extends ThemeProps, LocaleProps { export interface SearchBoxProps
extends ThemeProps,
LocaleProps,
SpinnerExtraProps {
name?: string; name?: string;
disabled?: boolean; disabled?: boolean;
mini?: boolean; mini?: boolean;
@ -42,13 +48,14 @@ export interface SearchBoxProps extends ThemeProps, LocaleProps {
active?: boolean; active?: boolean;
defaultActive?: boolean; defaultActive?: boolean;
onActiveChange?: (active: boolean) => void; onActiveChange?: (active: boolean) => void;
onSearch?: (value: string) => void; onSearch?: (value: string) => any;
onCancel?: () => void; onCancel?: () => void;
onFocus?: () => void; onFocus?: () => void;
onBlur?: () => void; onBlur?: () => void;
/** 历史记录配置 */ /** 历史记录配置 */
history?: SearchHistoryOptions; history?: SearchHistoryOptions;
clearAndSubmit?: boolean; clearAndSubmit?: boolean;
loading?: boolean;
} }
export interface SearchBoxState { export interface SearchBoxState {
@ -87,6 +94,7 @@ export class SearchBox extends React.Component<SearchBoxProps, SearchBoxState> {
lazyEmitSearch = debounce( lazyEmitSearch = debounce(
() => { () => {
const onSearch = this.props.onSearch; const onSearch = this.props.onSearch;
onSearch?.(this.state.inputValue ?? ''); onSearch?.(this.state.inputValue ?? '');
}, },
250, 250,
@ -276,6 +284,7 @@ export class SearchBox extends React.Component<SearchBoxProps, SearchBoxState> {
renderInput(isHistoryMode?: boolean) { renderInput(isHistoryMode?: boolean) {
const { const {
classnames: cx, classnames: cx,
classPrefix,
active, active,
name, name,
className, className,
@ -286,7 +295,9 @@ export class SearchBox extends React.Component<SearchBoxProps, SearchBoxState> {
enhance, enhance,
clearable, clearable,
mobileUI, mobileUI,
translate: __ translate: __,
loading,
loadingConfig
} = this.props; } = this.props;
const {isFocused, inputValue} = this.state; const {isFocused, inputValue} = this.state;
const {enable} = this.getHistoryOptions(); const {enable} = this.getHistoryOptions();
@ -325,8 +336,26 @@ export class SearchBox extends React.Component<SearchBoxProps, SearchBoxState> {
) : null} ) : null}
{!mini ? ( {!mini ? (
<a className={cx('SearchBox-searchBtn')} onClick={this.handleSearch}> <a
<Icon icon="search" className="icon" /> className={cx('SearchBox-searchBtn', {
'SearchBox-searchBtn--loading': loading
})}
onClick={this.handleSearch}
>
{loading ? (
<Spinner
classnames={cx}
classPrefix={classPrefix}
className={cx('SearchBox-spinner')}
spinnerClassName={cx('SearchBox-spinner-icon')}
disabled={disabled}
size="sm"
icon="loading-outline"
loadingConfig={loadingConfig}
/>
) : (
<Icon icon="search" className="icon" />
)}
</a> </a>
) : active ? ( ) : active ? (
<a className={cx('SearchBox-cancelBtn')} onClick={this.handleCancel}> <a className={cx('SearchBox-cancelBtn')} onClick={this.handleCancel}>

View File

@ -249,3 +249,82 @@ test('6. Renderer: Searchbox is not supposed to be triggered with composition in
keywords: 'test' keywords: 'test'
}); });
}); });
test('Renderer:Searchbox with searchImediately & className', async () => {
const onQuery = jest.fn();
const {container} = render(
amisRender({
type: 'search-box',
name: 'keywords',
mini: true,
searchImediately: true,
className: 'testClass',
onQuery
})
);
expect(container.querySelector('.cxd-SearchBox')).toHaveClass('testClass');
const input = container.querySelector('.cxd-SearchBox input')!;
fireEvent.change(input, {
target: {value: 'aa'}
});
await wait(400);
expect(onQuery).toBeCalledTimes(1);
expect(onQuery.mock.calls[0][0]).toEqual({
keywords: 'aa'
});
fireEvent.change(input, {
target: {value: 'aabb'}
});
await wait(400);
expect(onQuery).toBeCalledTimes(2);
expect(onQuery.mock.calls[1][0]).toEqual({
keywords: 'aabb'
});
});
test('Renderer: Searchbox with disbaled', async () => {
const onQuery = jest.fn();
const {container} = render(
amisRender(
{
type: 'search-box',
name: 'keywords',
disabled: true
},
{
onQuery
}
)
);
const inputEl = container.querySelector('.cxd-SearchBox input')!;
expect(inputEl).toBeInTheDocument();
/** Input元素上存在disabled attribute */
expect((inputEl.attributes as any).disabled).not.toEqual(undefined);
expect(inputEl.getAttribute('disabled')).toEqual('');
});
test('Renderer: Searchbox with loading', async () => {
const onQuery = jest.fn();
const {container} = render(
amisRender(
{
type: 'search-box',
name: 'keywords',
loading: true
},
{
onQuery
}
)
);
const spinner = container.querySelector('.cxd-SearchBox .cxd-SearchBox-spinner');
expect(spinner).toBeInTheDocument();
});

View File

@ -1,3 +1,4 @@
import React from 'react';
import { import {
createObject, createObject,
IScopedContext, IScopedContext,
@ -5,13 +6,17 @@ import {
RendererProps, RendererProps,
resolveEventData, resolveEventData,
ScopedComponentType, ScopedComponentType,
ScopedContext ScopedContext,
autobind,
getPropValue,
setVariable
} from 'amis-core'; } from 'amis-core';
import React from 'react';
import {BaseSchema, SchemaClassName} from '../Schema'; import {BaseSchema, SchemaClassName} from '../Schema';
import {SearchBox} from 'amis-ui'; import {SearchBox} from 'amis-ui';
import {autobind, getPropValue, getVariable, setVariable} from 'amis-core';
import type {ListenerAction} from 'amis-core'; import type {ListenerAction} from 'amis-core';
import type {SpinnerExtraProps} from 'amis-ui';
/** /**
* *
@ -65,13 +70,18 @@ export interface SearchBoxSchema extends BaseSchema {
* *
*/ */
clearAndSubmit?: boolean; clearAndSubmit?: boolean;
/** 是否处于加载状态 */
loading?: boolean;
} }
interface SearchBoxProps interface SearchBoxProps
extends RendererProps, extends RendererProps,
Omit<SearchBoxSchema, 'type' | 'className'> { Omit<SearchBoxSchema, 'type' | 'className'>,
SpinnerExtraProps {
name: string; name: string;
onQuery?: (query: {[propName: string]: string}) => any; onQuery?: (query: {[propName: string]: string}) => any;
loading?: boolean;
} }
export interface SearchBoxState { export interface SearchBoxState {
@ -199,18 +209,23 @@ export class SearchBoxRenderer extends React.Component<
onChange, onChange,
className, className,
style, style,
mobileUI mobileUI,
loading,
loadingConfig,
onEvent
} = this.props; } = this.props;
const value = this.state.value; const value = this.state.value;
/** 有可能通过Search事件处理 */
const isDisabled = (!onQuery && !onEvent?.search) || disabled;
return ( return (
<SearchBox <SearchBox
className={className} className={className}
style={style} style={style}
name={name} name={name}
// disabled={!onQuery} disabled={isDisabled}
disabled={disabled} loading={loading}
loadingConfig={loadingConfig}
defaultActive={!!value} defaultActive={!!value}
defaultValue={onChange ? undefined : value} defaultValue={onChange ? undefined : value}
value={value} value={value}