From c8774b60a3fc4c18f279eaee00d3003d2e35db2e Mon Sep 17 00:00:00 2001 From: sallerli1 Date: Wed, 3 Aug 2022 11:49:23 +0800 Subject: [PATCH] feat(pro:search): add validate function --- packages/pro/search/demo/ConvertToKeyword.md | 14 +++ packages/pro/search/demo/ConvertToKeyword.vue | 117 ++++++++++++++++++ packages/pro/search/demo/Invalid.md | 4 +- packages/pro/search/demo/Invalid.vue | 90 ++++---------- packages/pro/search/docs/Index.zh.md | 9 +- packages/pro/search/index.ts | 3 +- packages/pro/search/src/ProSearch.tsx | 11 +- .../search/src/composables/useSearchItem.ts | 4 +- .../src/composables/useSearchItemErrors.ts | 45 +++++++ .../search/src/composables/useSearchStates.ts | 75 +++++------ .../src/composables/useSegmentStates.ts | 25 ++-- .../pro/search/src/searchItem/SearchItem.tsx | 16 ++- .../src/segments/CreateSelectSegment.tsx | 4 +- .../search/src/segments/createInputSegment.ts | 1 - packages/pro/search/src/types.ts | 28 +++-- packages/pro/search/style/index.less | 16 ++- .../search/style/themes/default.variable.less | 3 + 17 files changed, 320 insertions(+), 145 deletions(-) create mode 100644 packages/pro/search/demo/ConvertToKeyword.md create mode 100644 packages/pro/search/demo/ConvertToKeyword.vue create mode 100644 packages/pro/search/src/composables/useSearchItemErrors.ts 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;