diff --git a/packages/pro/search/demo/ConvertToKeyword.md b/packages/pro/search/demo/ConvertToKeyword.md
new file mode 100644
index 000000000..fb39225de
--- /dev/null
+++ b/packages/pro/search/demo/ConvertToKeyword.md
@@ -0,0 +1,14 @@
+---
+order: 21
+title:
+ zh: 非法搜索项转换为关键字
+ en: Convert Illegal Search Value To Keyword
+---
+
+## zh
+
+使用 `onItemConfirm` 事件将非法搜索项转换为关键字。
+
+## en
+
+convert illgale search value to keyword via `onItemConfirm` event.
diff --git a/packages/pro/search/demo/ConvertToKeyword.vue b/packages/pro/search/demo/ConvertToKeyword.vue
new file mode 100644
index 000000000..48fa88683
--- /dev/null
+++ b/packages/pro/search/demo/ConvertToKeyword.vue
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
diff --git a/packages/pro/search/demo/Invalid.md b/packages/pro/search/demo/Invalid.md
index a75b2d44e..0f5fe47ef 100644
--- a/packages/pro/search/demo/Invalid.md
+++ b/packages/pro/search/demo/Invalid.md
@@ -7,8 +7,8 @@ title:
## zh
-使用 `onItemInvalid` 事件处理非法搜索项。
+通过 `searchField.validator` 校验搜索项。
## en
-Handle invalid search value via `onItemInvalid` event.
+validate search value via `searchField.validator`.
diff --git a/packages/pro/search/demo/Invalid.vue b/packages/pro/search/demo/Invalid.vue
index 9024f99bf..710165c73 100644
--- a/packages/pro/search/demo/Invalid.vue
+++ b/packages/pro/search/demo/Invalid.vue
@@ -1,20 +1,21 @@
diff --git a/packages/pro/search/docs/Index.zh.md b/packages/pro/search/docs/Index.zh.md
index cfab7c5bc..9321688a0 100644
--- a/packages/pro/search/docs/Index.zh.md
+++ b/packages/pro/search/docs/Index.zh.md
@@ -14,7 +14,8 @@ subtitle: 复合搜索
| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 |
| --- | --- | --- | --- | --- | --- |
-| `v-model:value` | 复合选中的搜索值 | - | - | ✅ | - |
+| `v-model:value` | 选中的搜索值 | - | - | - | - |
+| `v-model:errors` | 校验错误 | `{ index: number, message: string }` | - | - | - |
| `clearable` | 是否可清除 | `boolean` | `true` | ✅ | - |
| `clearIcon` | 清除图标 | `string \| VNode \| #clearIcon` | `close-circle` | ✅ | - |
| `disabled` | 是否禁用 | `boolean` | `false` | - | - |
@@ -24,7 +25,7 @@ subtitle: 复合搜索
| `onChange` | 搜索条件改变之后的回调 | `(value: searchValue[] \| undefined, oldValue: searchValue[] \| undefined) => void` | - | - | - |
| `onClear` | 清除搜索条件的回调 | `() => void` | - | - | - |
| `onItemRemove` | 搜索条件删除时的回调 | `(item: SearchValue) => void` | - | - | - |
-| `onItemInvalid` | 搜索条件不合法时触发的回调 | `(item: InvalidSearchValue) => void` | - | - | - |
+| `onItemConfirm` | 搜索条件不合法时触发的回调 | `(item: SearchItemConfirmContext) => void` | - | - | - |
| `onSearch` | 搜索按钮触发的回调 | `(value: searchValue[] \| undefined) => void` | - | - | - |
#### ProSearchSlots
@@ -40,10 +41,11 @@ interface SearchValue {
value: V // 搜索值
operator?: string // 搜索操作符
}
-interface InvalidSearchValue extends Partial> {
+interface SearchItemConfirmContext extends Partial> {
nameInput?: string // 搜索字段名称输入
operatorInput?: string // 操作符输入
valueInput?: string // 值输入
+ removed: boolean // 是否被移除
}
```
@@ -64,6 +66,7 @@ interface InvalidSearchValue extends Partial> {
| `defaultOperator` | 默认的操作符 | `string` | - | - | 提供时,会自动填入默认的操作符 |
| `defaultValue` | 默认值 | - | - | - | 提供时,会自动填入默认值 |
| `inputClassName` | 输入框class | `string` | - | - | 用于自定义输入框样式 |
+| `validator` | 搜索项校验函数 | `(value: SearchValue) => { message?: string } | undefined` | - | - | 返回错误信息 |
#### InputSearchField
diff --git a/packages/pro/search/index.ts b/packages/pro/search/index.ts
index e8a88b2ca..4fb97ef4c 100644
--- a/packages/pro/search/index.ts
+++ b/packages/pro/search/index.ts
@@ -19,5 +19,6 @@ export type {
ProSearchPublicProps as ProSearchProps,
SearchField,
SearchValue,
- InvalidSearchValue,
+ SearchItemError,
+ SearchItemConfirmContext,
} from './src/types'
diff --git a/packages/pro/search/src/ProSearch.tsx b/packages/pro/search/src/ProSearch.tsx
index f73a5db8a..620d321af 100644
--- a/packages/pro/search/src/ProSearch.tsx
+++ b/packages/pro/search/src/ProSearch.tsx
@@ -18,6 +18,7 @@ import { useGlobalConfig } from '@idux/pro/config'
import { useActiveSegment } from './composables/useActiveSegment'
import { useCommonOverlayProps } from './composables/useCommonOverlayProps'
import { useSearchItems } from './composables/useSearchItem'
+import { useSearchItemErrors } from './composables/useSearchItemErrors'
import { tempSearchStateKey, useSearchStates } from './composables/useSearchStates'
import { useSearchValues } from './composables/useSearchValues'
import SearchItemComp from './searchItem/SearchItem'
@@ -37,7 +38,15 @@ export default defineComponent({
const { searchValues, searchValueEmpty, setSearchValues } = useSearchValues(props)
const searchStateContext = useSearchStates(props, dateConfig, searchValues, setSearchValues)
- const searchItems = useSearchItems(props, slots, mergedPrefixCls, searchStateContext.searchStates, dateConfig)
+ const errors = useSearchItemErrors(props, searchValues)
+ const searchItems = useSearchItems(
+ props,
+ slots,
+ mergedPrefixCls,
+ searchStateContext.searchStates,
+ errors,
+ dateConfig,
+ )
const activeSegmentContext = useActiveSegment(props, searchItems, searchStateContext.tempSearchStateAvailable)
const commonOverlayProps = useCommonOverlayProps(mergedPrefixCls, props, config)
diff --git a/packages/pro/search/src/composables/useSearchItem.ts b/packages/pro/search/src/composables/useSearchItem.ts
index 0c20ee6e7..dbc438607 100644
--- a/packages/pro/search/src/composables/useSearchItem.ts
+++ b/packages/pro/search/src/composables/useSearchItem.ts
@@ -5,7 +5,7 @@
* found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE
*/
-import type { ProSearchProps, SearchField, SearchItem } from '../types'
+import type { ProSearchProps, SearchField, SearchItem, SearchItemError } from '../types'
import type { SearchState } from './useSearchStates'
import type { DateConfig } from '@idux/components/config'
@@ -24,6 +24,7 @@ export function useSearchItems(
slots: Slots,
mergedPrefixCls: ComputedRef,
searchStates: ComputedRef,
+ searchItemErrors: ComputedRef,
dateConfig: DateConfig,
): ComputedRef {
const searchStatesKeys = computed(() => new Set(searchStates.value?.map(state => state.fieldKey)))
@@ -40,6 +41,7 @@ export function useSearchItems(
return {
key: searchState.key,
optionKey: searchState.fieldKey,
+ error: searchItemErrors.value?.find(error => error.index === searchState.index),
segments: searchState.segmentValues
.map(segmentValue => {
if (segmentValue.name === 'name') {
diff --git a/packages/pro/search/src/composables/useSearchItemErrors.ts b/packages/pro/search/src/composables/useSearchItemErrors.ts
new file mode 100644
index 000000000..469095623
--- /dev/null
+++ b/packages/pro/search/src/composables/useSearchItemErrors.ts
@@ -0,0 +1,45 @@
+/**
+ * @license
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE
+ */
+
+import type { ProSearchProps, SearchField, SearchItemError, SearchValue } from '../types'
+
+import { type ComputedRef, watch } from 'vue'
+
+import { useControlledProp } from '@idux/cdk/utils'
+
+export function useSearchItemErrors(
+ props: ProSearchProps,
+ searchValues: ComputedRef,
+): ComputedRef {
+ const [errors, setErrors] = useControlledProp(props, 'errors')
+
+ watch(
+ searchValues,
+ () => {
+ setErrors(getErrors(props.searchFields, searchValues.value))
+ },
+ { immediate: true },
+ )
+
+ return errors
+}
+
+function getErrors(
+ searchFields: SearchField[] | undefined,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ searchValues: SearchValue[] | undefined,
+): SearchItemError[] | undefined {
+ return searchValues?.reduce((errors, searchValue, index) => {
+ const error = searchFields?.find(field => field.key === searchValue.key)?.validator?.(searchValue)
+
+ if (error) {
+ errors.push({ index, ...error })
+ }
+
+ return errors
+ }, [] as SearchItemError[])
+}
diff --git a/packages/pro/search/src/composables/useSearchStates.ts b/packages/pro/search/src/composables/useSearchStates.ts
index e47fb9ee4..3456d02f6 100644
--- a/packages/pro/search/src/composables/useSearchStates.ts
+++ b/packages/pro/search/src/composables/useSearchStates.ts
@@ -27,6 +27,7 @@ export interface SegmentValue {
}
export interface SearchState {
key: VKey
+ index?: number
fieldKey?: VKey
segmentValues: SegmentValue[]
}
@@ -100,7 +101,7 @@ export function useSearchStates(
return { searchState: searchStates.value[index], index }
}
- function _convertStateToValue(state: SearchState) {
+ function _convertStateToValue(state: SearchState) {
const operatorSegment = state.segmentValues.find(value => value.name === 'operator')
const contentSegment = state.segmentValues.find(value => searchDataTypes.includes(value.name as SearchDataTypes))
@@ -109,7 +110,7 @@ export function useSearchStates(
name: props.searchFields?.find(field => field.key === state.fieldKey)?.label,
operator: operatorSegment?.value as string,
value: toRaw(contentSegment?.value),
- } as SearchValue
+ } as SearchValue
}
function setSegmentValue(searchState: SearchState, name: string, value: unknown) {
@@ -132,10 +133,24 @@ export function useSearchStates(
}
}
+ function checkSearchStateValid(searchState: SearchState, dataKeyCountMap: Map, existed?: boolean) {
+ if (!searchState.fieldKey) {
+ return false
+ }
+
+ const searchField = props.searchFields?.find(field => field.key === searchState.fieldKey)
+
+ const count = dataKeyCountMap.get(searchState.fieldKey)
+ if (count && count > (existed ? 1 : 0)) {
+ return !!searchField?.multiple
+ }
+
+ return searchState.segmentValues.every(segmentValue => !isNil(segmentValue.value))
+ }
+
const validateSearchState = (key: VKey) => {
const { searchState } = getSearchStateByKey(key)
-
- return checkSearchStateValid(searchState, props.searchFields, fieldKeyCountMap.value, key !== tempSearchStateKey)
+ return checkSearchStateValid(searchState, fieldKeyCountMap.value, key !== tempSearchStateKey)
}
const convertStateToValue = (key: VKey) => {
@@ -152,23 +167,23 @@ export function useSearchStates(
const dataKeyCountMap = new Map()
searchStates.value = (
- searchValues.value?.map(searchValue => {
+ searchValues.value?.map((searchValue, index) => {
const fieldKey = searchValue.key
- const searchData = props.searchFields?.find(field => field.key === fieldKey)
- if (!searchData) {
+ const searchField = props.searchFields?.find(field => field.key === fieldKey)
+ if (!searchField) {
return
}
- const segmentValues = generateSegmentValues(searchData, searchValue, dateConfig)
- const index = dataKeyCountMap.has(fieldKey) ? dataKeyCountMap.get(fieldKey)! : 0
- const key = getKey(fieldKey, index)
+ const segmentValues = generateSegmentValues(searchField, searchValue, dateConfig)
+ const count = dataKeyCountMap.has(fieldKey) ? dataKeyCountMap.get(fieldKey)! : 0
+ const key = getKey(fieldKey, count)
- const searchState = { key, fieldKey, segmentValues }
- if (!checkSearchStateValid(searchState, props.searchFields, dataKeyCountMap)) {
+ const searchState = { key, index, fieldKey, segmentValues } as SearchState
+ if (!checkSearchStateValid(searchState, dataKeyCountMap)) {
return
}
- dataKeyCountMap.set(fieldKey, index + 1)
+ dataKeyCountMap.set(fieldKey, count + 1)
return searchState
}) ?? []
).filter(Boolean) as SearchState[]
@@ -182,17 +197,7 @@ export function useSearchStates(
return
}
- const operatorSegmentValue = state.segmentValues.find(value => value.name === 'operator')
- const contentSegmentValue = state.segmentValues.find(value =>
- searchDataTypes.includes(value.name as SearchDataTypes),
- )
-
- return {
- key: state.fieldKey,
- name: props.searchFields?.find(fileld => fileld.key === state.fieldKey)?.label,
- operator: operatorSegmentValue?.value,
- value: toRaw(contentSegmentValue?.value),
- }
+ return _convertStateToValue(state)
})
.filter(Boolean) as SearchValue[]
@@ -320,25 +325,3 @@ function generateSegmentValues(
].filter(Boolean) as SegmentValue[]
/* eslint-enable indent */
}
-
-function checkSearchStateValid(
- searchState: SearchState,
- searchFields: SearchField[] | undefined,
- dataKeyCountMap: Map,
- existed?: boolean,
-) {
- if (!searchState.fieldKey) {
- return false
- }
-
- if (searchState.segmentValues.some(segmentValue => isNil(segmentValue.value))) {
- return false
- }
-
- const count = dataKeyCountMap.get(searchState.fieldKey)
- if (count && count > (existed ? 1 : 0)) {
- return !!searchFields?.find(field => field.key === searchState.fieldKey)?.multiple
- }
-
- return true
-}
diff --git a/packages/pro/search/src/composables/useSegmentStates.ts b/packages/pro/search/src/composables/useSegmentStates.ts
index a6a088660..372627d42 100644
--- a/packages/pro/search/src/composables/useSegmentStates.ts
+++ b/packages/pro/search/src/composables/useSegmentStates.ts
@@ -80,20 +80,25 @@ export function useSegmentStates(
}
const confirmSearchItem = () => {
const key = props.searchItem!.key
- if (validateSearchState(key)) {
- updateSearchState(key)
- } else {
- const valueName = searchDataTypes.find(name => !!segmentStates.value[name])
+ const validateRes = validateSearchState(key)
+
+ if (!validateRes) {
removeSearchState(key)
- callEmit(proSearchProps.onItemInvalid, {
- ...convertStateToValue(key),
- nameInput: segmentStates.value.name?.input,
- operatorInput: segmentStates.value.operator?.input,
- valueInput: valueName && segmentStates.value[valueName]?.input,
- })
initTempSearchState()
+ } else {
+ updateSearchState(key)
}
+ const valueName = searchDataTypes.find(name => !!segmentStates.value[name])
+
+ callEmit(proSearchProps.onItemConfirm, {
+ ...convertStateToValue(key),
+ nameInput: segmentStates.value.name?.input,
+ operatorInput: segmentStates.value.operator?.input,
+ valueInput: valueName && segmentStates.value[valueName]?.input,
+ removed: !validateRes,
+ })
+
if (key !== tempSearchStateKey) {
setFakeActive()
} else {
diff --git a/packages/pro/search/src/searchItem/SearchItem.tsx b/packages/pro/search/src/searchItem/SearchItem.tsx
index 600c85118..35f0e3fb7 100644
--- a/packages/pro/search/src/searchItem/SearchItem.tsx
+++ b/packages/pro/search/src/searchItem/SearchItem.tsx
@@ -7,6 +7,8 @@
import { computed, defineComponent, inject, normalizeClass, provide } from 'vue'
+import { IxTooltip } from '@idux/components/tooltip'
+
import { tempSearchStateKey } from '../composables/useSearchStates'
import { useSegmentOverlayUpdate } from '../composables/useSegmentOverlayUpdate'
import { useSegmentStates } from '../composables/useSegmentStates'
@@ -58,6 +60,7 @@ export default defineComponent({
return normalizeClass({
[prefixCls.value]: true,
[`${prefixCls.value}-tag`]: !isActive.value,
+ [`${prefixCls.value}-invalid`]: !!props.searchItem?.error,
})
})
@@ -133,7 +136,7 @@ export default defineComponent({
}
}
- return (
+ const itemNode = (
)
+
+ const message = props.searchItem?.error?.message
+ if (isActive.value || !message) {
+ return itemNode
+ }
+
+ return (
+
+ {itemNode}
+
+ )
}
},
})
diff --git a/packages/pro/search/src/segments/CreateSelectSegment.tsx b/packages/pro/search/src/segments/CreateSelectSegment.tsx
index 6789dec16..91a4f1be7 100644
--- a/packages/pro/search/src/segments/CreateSelectSegment.tsx
+++ b/packages/pro/search/src/segments/CreateSelectSegment.tsx
@@ -7,7 +7,7 @@
import type { PanelRenderContext, Segment, SelectPanelData, SelectSearchField } from '../types'
-import { toString } from 'lodash-es'
+import { isNil, toString } from 'lodash-es'
import { type VKey, convertArray } from '@idux/cdk/utils'
@@ -96,7 +96,7 @@ function parseInput(input: string, searchField: SelectSearchField): VKey | VKey[
function formatValue(value: VKey | VKey[] | undefined, searchField: SelectSearchField): string {
const { dataSource, separator } = searchField.fieldConfig
- if (!value) {
+ if (isNil(value)) {
return ''
}
diff --git a/packages/pro/search/src/segments/createInputSegment.ts b/packages/pro/search/src/segments/createInputSegment.ts
index f1d710002..ee09e05b4 100644
--- a/packages/pro/search/src/segments/createInputSegment.ts
+++ b/packages/pro/search/src/segments/createInputSegment.ts
@@ -20,6 +20,5 @@ export function createInputSegment(prefixCls: string, searchField: InputSearchFi
defaultValue,
parse: input => input,
format: value => (trim ? value?.trim() : value) ?? '',
- panel: null,
}
}
diff --git a/packages/pro/search/src/types.ts b/packages/pro/search/src/types.ts
index 431026eaa..d60590ee7 100644
--- a/packages/pro/search/src/types.ts
+++ b/packages/pro/search/src/types.ts
@@ -19,10 +19,16 @@ export interface SearchValue {
operator?: string
}
-export interface InvalidSearchValue extends Partial> {
+export interface SearchItemError {
+ index: number
+ message?: string
+}
+
+export interface SearchItemConfirmContext extends Partial> {
nameInput?: string
operatorInput?: string
valueInput?: string
+ removed: boolean
}
export interface PanelRenderContext {
@@ -49,18 +55,19 @@ export type InputParser = (input: string) => V | null
export interface SearchItem {
key: VKey
optionKey?: VKey
+ error?: SearchItemError
segments: Segment[]
}
-interface SearchFieldBase {
+interface SearchFieldBase {
key: VKey
label: string
multiple?: boolean
operators?: string[]
defaultOperator?: string
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- defaultValue?: any
+ defaultValue?: V
inputClassName?: string
+ validator?: (value: SearchValue) => Omit | undefined
}
export type SelectPanelData = Required> & SelectData
@@ -68,7 +75,7 @@ export type SelectPanelData = Required> & Sele
export const searchDataTypes = ['select', 'input', 'datePicker', 'dateRangePicker', 'custom'] as const
export type SearchDataTypes = typeof searchDataTypes[number]
-export interface SelectSearchField extends SearchFieldBase {
+export interface SelectSearchField extends SearchFieldBase {
type: 'select'
fieldConfig: {
dataSource: SelectPanelData[]
@@ -81,14 +88,14 @@ export interface SelectSearchField extends SearchFieldBase {
}
}
-export interface InputSearchField extends SearchFieldBase {
+export interface InputSearchField extends SearchFieldBase {
type: 'input'
fieldConfig: {
trim?: boolean
}
}
-export interface DatePickerSearchField extends SearchFieldBase {
+export interface DatePickerSearchField extends SearchFieldBase {
type: 'datePicker'
fieldConfig: {
format?: string
@@ -99,7 +106,7 @@ export interface DatePickerSearchField extends SearchFieldBase {
}
}
-export interface DateRangePickerSearchField extends SearchFieldBase {
+export interface DateRangePickerSearchField extends SearchFieldBase {
type: 'dateRangePicker'
fieldConfig: {
format?: string
@@ -140,6 +147,7 @@ export const proSearchProps = {
type: Boolean,
default: false,
},
+ errors: Array as PropType,
overlayContainer: {
type: [String, HTMLElement, Function] as PropType,
default: undefined,
@@ -149,13 +157,14 @@ export const proSearchProps = {
//events
'onUpdate:value': [Function, Array] as PropType void>>,
+ 'onUpdate:errors': [Function, Array] as PropType void>>,
onChange: [Function, Array] as PropType<
MaybeArray<(value: SearchValue[] | undefined, oldValue: SearchValue[] | undefined) => void>
>,
onClear: [Function, Array] as PropType void>>,
onItemRemove: [Array, Function] as PropType void>>,
onSearch: [Array, Function] as PropType void>>,
- onItemInvalid: [Array, Function] as PropType void>>,
+ onItemConfirm: [Array, Function] as PropType void>>,
} as const
export type ProSearchProps = ExtractInnerPropTypes
@@ -170,6 +179,7 @@ export const searchItemProps = {
type: Object as PropType,
required: true,
},
+ error: Object as PropType,
tagOnly: {
type: Boolean,
default: false,
diff --git a/packages/pro/search/style/index.less b/packages/pro/search/style/index.less
index ea4f39503..9d772aa2e 100644
--- a/packages/pro/search/style/index.less
+++ b/packages/pro/search/style/index.less
@@ -144,6 +144,10 @@
display: inline-block;
.ellipsis();
}
+
+ &.@{pro-search-prefix}-search-item-invalid {
+ border: 1px solid @pro-search-item-tag-invalid-border-color;
+ }
}
&-close-icon {
@@ -153,6 +157,15 @@
cursor: pointer;
z-index: 1;
}
+
+ &-invalid-tooltip {
+ background-color: @pro-search-item-tag-invalid-tooltip-background-color;
+ color: @pro-search-item-tag-invalid-tooltip-color;
+
+ .@{overlay-prefix}-arrow {
+ color: @pro-search-item-tag-invalid-tooltip-background-color;
+ }
+ }
}
&:not(&-focused) {
.@{pro-search-prefix}-search-item-tag {
@@ -258,7 +271,8 @@
}
.panel-footer() {
- border-top: @pro-search-panel-footer-border-width @pro-search-panel-footer-border-style @pro-search-panel-footer-border-color;
+ border-top: @pro-search-panel-footer-border-width @pro-search-panel-footer-border-style
+ @pro-search-panel-footer-border-color;
padding: @pro-search-panel-footer-padding-vertical @pro-search-panel-footer-padding-horizontal;
text-align: right;
diff --git a/packages/pro/search/style/themes/default.variable.less b/packages/pro/search/style/themes/default.variable.less
index d5ec2f8bd..9644fdf22 100644
--- a/packages/pro/search/style/themes/default.variable.less
+++ b/packages/pro/search/style/themes/default.variable.less
@@ -51,6 +51,9 @@
@pro-search-item-tag-margin-bottom: @spacing-xs;
@pro-search-item-tag-disabled-border-color: @pro-search-border-color;
@pro-search-item-tag-disabled-background-color: @color-graphite-l40;
+@pro-search-item-tag-invalid-border-color: @color-error-d10;
+@pro-search-item-tag-invalid-tooltip-background-color: @form-item-invalid-color;
+@pro-search-item-tag-invalid-tooltip-color: @color-white;
@pro-search-segment-padding-horizontal: @spacing-xs;
@pro-search-segment-margin: @spacing-xs;