diff --git a/package.json b/package.json index 2aa4ffc..36f0482 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kne-components/components-core", - "version": "0.4.72", + "version": "0.4.73", "files": [ "build" ], diff --git a/src/components/Filter/README.md b/src/components/Filter/README.md index 2654200..96dc004 100644 --- a/src/components/Filter/README.md +++ b/src/components/Filter/README.md @@ -4,7 +4,7 @@ Filter 是一个功能强大的筛选组件库,用于构建灵活的筛选条件界面。该组件提供了多种预置的筛选字段类型,支持自定义筛选项,并提供了完整的筛选值管理和展示功能。 -核心特性包括:丰富的预置筛选字段,涵盖文本输入、日期选择、城市选择、用户选择、行业选择、职能选择等多种类型;灵活的筛选值管理,支持受控和非受控模式;支持展开/收起筛选项,避免筛选条件过多导致界面混乱;提供高级筛选组件,适用于复杂筛选场景;支持自定义字段和组合使用,满足各种业务需求;内置搜索输入框和筛选值展示组件,提升用户体验。 +核心特性包括:丰富的预置筛选字段,涵盖文本输入、日期选择、城市选择、用户选择、行业选择、职能选择等多种类型;灵活的筛选值管理,支持受控和非受控模式;声明式筛选值映射(`createFilterValueMapper`),统一处理多选、单选、自定义转换等值提取逻辑;URL 参数序列化与初始化(`filterToUrlParams` / `createUrlFilterReader` / `useUrlFilter`),筛选值以 `filterParams[key]` 格式序列化到 URL(前缀可自定义),从 URL 参数构建初始筛选状态并自动清理已消费参数;值格式拦截器(`filterInterceptors`),自动处理 SuperSelect 组件 `{ id, name }` 与 Filter 上下文 `{ label, value }` 格式之间的转换;支持展开/收起筛选项,避免筛选条件过多导致界面混乱;提供高级筛选组件,适用于复杂筛选场景;支持自定义字段和组合使用,满足各种业务需求;内置搜索输入框和筛选值展示组件,提升用户体验。 适用于数据列表、表格筛选、报表查询等需要多条件筛选的场景。组件采用 Context API 进行状态管理,支持嵌套使用和组合,能够满足企业级应用中各种复杂的筛选需求。 @@ -845,6 +845,485 @@ render(); ``` +- 筛选值映射与提取 +- 展示 createFilterValueMapper 声明式值映射和 pickSelectValues 值提取工具的用法 +- _Filter(@components/Filter),antd(antd) + +```jsx +const { + default: Filter, + SuperSelectFilterItem, + CityFilterItem, + InputFilterItem, + getFilterValue, + pickSelectValues, + createFilterValueMapper, +} = _Filter; +const { useState } = React; +const { Space, Card, Divider, Typography } = antd; + +const { Text, Title } = Typography; + +// 声明式创建 mapFilterValue 函数 +const mapFilterValue = createFilterValueMapper({ + keyword: 'string', // 确保字符串类型 + city: 'multi', // 多选 → string[] + status: 'single', // 单选 → string +}); + +const BaseExample = () => { + const [value, onChange] = useState([ + { name: 'keyword', label: '关键词', value: { label: '搜索词', value: '搜索词' } }, + { name: 'city', label: '城市', value: [{ label: '上海', value: '010' }, { label: '北京', value: '020' }] }, + { name: 'status', label: '状态', value: [{ label: '启用', value: 'active', id: 'active' }] }, + ]); + + const rawFilterValue = getFilterValue(value); + const mappedFilterValue = mapFilterValue(value, getFilterValue); + + return ( + + , + , + , + ], + ]} + /> + + + {`pickSelectValues([{ value: 1 }, { id: 2 }, '3'])`} +
+ 结果:{JSON.stringify(pickSelectValues([{ value: 1 }, { id: 2 }, '3']))} + + {`pickSelectValues({ value: 'open' })`} +
+ 结果:{JSON.stringify(pickSelectValues({ value: 'open' }))} +
+ + + 原始 getFilterValue 结果 +
+          {JSON.stringify(rawFilterValue, null, 2)}
+        
+ createFilterValueMapper 映射后结果 +
+          {JSON.stringify(mappedFilterValue, null, 2)}
+        
+
+
+ ); +}; + +render(); + +``` + +- URL 筛选参数 +- 展示 filterToUrlParams、parseFilterEntry、takeFilterEntry、createUrlFilterReader 等 URL 参数序列化与反序列化工具的用法 +- _Filter(@components/Filter),antd(antd) + +```jsx +const { + default: Filter, + InputFilterItem, + CityFilterItem, + SuperSelectFilterItem, + filterToUrlParams, + parseFilterEntry, + takeFilterEntry, + createUrlFilterReader, + getFilterValue, + createFilterValueMapper, + pickSelectValues, +} = _Filter; +const { useState, useMemo } = React; +const { Space, Card, Divider, Typography, Button, Alert, Tag } = antd; + +const { Text, Title, Paragraph } = Typography; + +// ========== 示例数据 ========== +const sampleFilterValue = [ + { name: 'keyword', label: '关键词', value: { label: '前端开发', value: '前端开发' } }, + { name: 'city', label: '城市', value: [{ label: '上海', value: '010' }, { label: '北京', value: '020' }] }, + { name: 'status', label: '状态', value: { label: '招聘中', value: 'active', id: 'active' } }, +]; + +// 声明式创建 mapFilterValue,配合 URL 参数使用 +const mapFilterValue = createFilterValueMapper({ + keyword: 'string', + city: 'multi', + status: 'single', +}); + +const BaseExample = () => { + const [value, onChange] = useState([]); + + // ===== 1. filterToUrlParams:筛选值 → URL 参数 ===== + const sampleUrlParams = useMemo(() => filterToUrlParams(sampleFilterValue), []); + const liveUrlParams = useMemo(() => filterToUrlParams(value), [value]); + + // ===== 2. parseFilterEntry:解析单个值 ===== + const parsedEntries = useMemo(() => [ + { input: "'前端开发'", output: parseFilterEntry('前端开发') }, + { input: "'招聘中:active'", output: parseFilterEntry('招聘中:active') }, + { input: "'上海:010'", output: parseFilterEntry('上海:010') }, + ], []); + + // ===== 3. 从 URL 参数反序列化还原筛选状态 ===== + const roundTripResult = useMemo(() => { + const urlStr = sampleUrlParams.toString(); + const searchParams = new URLSearchParams(urlStr); + + // 使用 createUrlFilterReader 读取 + const reader = createUrlFilterReader(searchParams); + const keyword = reader.takeFilterEntry('keyword'); + const city = reader.takeFilterEntry('city', { multi: true }); + const status = reader.takeFilterEntry('status'); + const consumedKeys = reader.getConsumedKeys(); + + // 还原为 filter 数组 + const restored = []; + if (keyword) restored.push({ name: 'keyword', label: '关键词', value: keyword }); + if (city) restored.push({ name: 'city', label: '城市', value: city }); + if (status) restored.push({ name: 'status', label: '状态', value: status }); + + return { urlStr, keyword, city, status, consumedKeys, restored }; + }, [sampleUrlParams]); + + // ===== 4. takeFilterEntry 直接读取 ===== + const takeResults = useMemo(() => { + const sp = sampleUrlParams; + return [ + { input: "takeFilterEntry(params, 'keyword')", output: takeFilterEntry(sp, 'keyword') }, + { input: "takeFilterEntry(params, 'city', { multi: true })", output: takeFilterEntry(sp, 'city', { multi: true }) }, + { input: "takeFilterEntry(params, 'status')", output: takeFilterEntry(sp, 'status') }, + ]; + }, [sampleUrlParams]); + + // ===== 5. 映射后筛选值 ===== + const mappedSample = mapFilterValue(sampleFilterValue, getFilterValue); + + return ( + + + + {/* ===== 交互式 Filter ===== */} + + 选择筛选条件后,下方 URL 参数会实时更新 + , + , + , + ], + ]} + /> + {value.length > 0 && ( + <> + + 当前筛选值 +
+              {JSON.stringify(value, null, 2)}
+            
+ 序列化后的 URL 参数 +
+              {liveUrlParams.toString() || '(无)'}
+            
+ + )} +
+ + {/* ===== filterToUrlParams ===== */} + + + 将筛选值数组序列化为 URLSearchParams,保留 label 信息以便反序列化还原 + + 输入:筛选值数组 +
+{JSON.stringify(sampleFilterValue, null, 2)}
+        
+ 输出:URL 参数字符串 +
+          {roundTripResult.urlStr}
+        
+
+ + {/* ===== parseFilterEntry ===== */} + + + 将 URL 参数中的字符串反序列化为 {`{ label, value }`} 对象 + + {parsedEntries.map(({ input, output }, i) => ( +
+ {`parseFilterEntry(${input})`} + {' → '} + {JSON.stringify(output)} +
+ ))} +
+ + {/* ===== takeFilterEntry ===== */} + + + 直接从 URLSearchParams 中读取并反序列化指定 key 的筛选值 + + {takeResults.map(({ input, output }, i) => ( +
+ {input} +
+ 结果: + {JSON.stringify(output)} +
+ ))} +
+ + {/* ===== createUrlFilterReader + 完整还原流程 ===== */} + + + 使用 createUrlFilterReader 从 URL 参数读取并还原完整的筛选状态,同时追踪已消费的 key + + 步骤1:从 URL 参数读取各字段值 +
+{`const reader = createUrlFilterReader(searchParams);
+const keyword = reader.takeFilterEntry('keyword');     // → ${JSON.stringify(roundTripResult.keyword)}
+const city    = reader.takeFilterEntry('city', { multi: true }); // → ${JSON.stringify(roundTripResult.city)}
+const status  = reader.takeFilterEntry('status');      // → ${JSON.stringify(roundTripResult.status)}
+reader.getConsumedKeys();                               // → ${JSON.stringify(roundTripResult.consumedKeys)}`}
+        
+ 步骤2:还原为筛选值数组 +
+          {JSON.stringify(roundTripResult.restored, null, 2)}
+        
+ + 验证:还原后的数据与原始数据一致 + + 还原成功 + + keyword: {roundTripResult.keyword?.value} | city: [{roundTripResult.city?.map(c => c.value).join(', ')}] | status: {roundTripResult.status?.value} + + +
+ + {/* ===== createFilterValueMapper ===== */} + + + 实际业务中,先用 createFilterValueMapper 将筛选值映射为接口参数格式,再用 filterToUrlParams 序列化到 URL + + 映射后的筛选值(用于接口请求) +
+{`const mapFilterValue = createFilterValueMapper({
+  keyword: 'string',   // 确保字符串
+  city: 'multi',       // 多选 → string[]
+  status: 'single',    // 单选 → string
+});
+
+mapFilterValue(filterValue, getFilterValue);
+// →`}
+{'  ' + JSON.stringify(mappedSample, null, 2)}
+        
+
+
+ ); +}; + +render(); + +``` + +- 筛选值拦截器 +- 展示 filterInterceptors、singleSelectInterceptor、multiSelectInterceptor 拦截器的用法,解决 SuperSelect 组件 { id, name } 与 Filter 上下文 { label, value } 格式不匹配的问题 +- _Filter(@components/Filter),antd(antd) + +```jsx +const { + default: Filter, + SuperSelectFilterItem, + filterInterceptors, + singleSelectInterceptor, + multiSelectInterceptor, + getFilterValue, + pickSelectValues, +} = _Filter; +const { useState } = React; +const { Space, Card, Divider, Typography, Alert } = antd; + +const { Text, Title, Paragraph } = Typography; + +// filterInterceptors 提供了 single 和 multi 两种预设拦截器 +// 适用于 SuperSelectFilterItem 等使用 { id, name } 格式的组件 +// interceptor.input:将 { label, value } 转为 { id, name }(传入组件的 value 格式) +// interceptor.output:将 { id, name } 转回 { label, value }(筛选上下文的 value 格式) + +const BaseExample = () => { + const [value1, onChange1] = useState([]); + const [value2, onChange2] = useState([]); + + return ( + + + + + + 适用于 valueKey="id" labelKey="name" 的单选 SuperSelect 场景,自动在 {`{label, value}`} 和 {`{id, name}`} 之间转换 + + { + console.log('筛选值:', getFilterValue(value)); + onChange1(value); + }} + list={[ + [ + , + , + ], + ]} + /> + {value1.length > 0 && ( + <> + + 当前筛选值 +
+              {JSON.stringify(getFilterValue(value1), null, 2)}
+            
+ pickSelectValues 提取原始值 +
+              {JSON.stringify(Object.fromEntries(
+                value1.map(item => [item.name, pickSelectValues(item.value)])
+              ), null, 2)}
+            
+ + )} +
+ + + + 适用于 valueKey="id" labelKey="name" 的多选 SuperSelect 场景,自动在 {`[{label, value}]`} 和 {`[{id, name}]`} 之间转换 + + { + console.log('筛选值:', getFilterValue(value)); + onChange2(value); + }} + list={[ + [ + , + , + ], + ]} + /> + {value2.length > 0 && ( + <> + + 当前筛选值 +
+              {JSON.stringify(getFilterValue(value2), null, 2)}
+            
+ pickSelectValues 提取原始值 +
+              {JSON.stringify(Object.fromEntries(
+                value2.map(item => [item.name, pickSelectValues(item.value)])
+              ), null, 2)}
+            
+ + )} +
+ + + + filterInterceptors 对象同时提供了 single 和 multi 两种拦截器,可以直接解构使用: + +
+{`// 方式一:直接引用
+import { singleSelectInterceptor, multiSelectInterceptor } from '@components/Filter';
+
+// 方式二:从 filterInterceptors 解构
+const { single, multi } = filterInterceptors;
+
+// single 等价于 singleSelectInterceptor
+// multi 等价于 multiSelectInterceptor`}
+        
+
+
+ ); +}; + +render(); + +``` + ### API ### Filter 组件 @@ -1080,6 +1559,25 @@ render(); | label | 字段标签 | string | 否 | - | | name | 字段名 | string | 否 | - | | options | 选项数据数组 | array | 否 | - | +| interceptor | 值转换拦截器,用于在组件内部格式和筛选上下文格式之间转换 | object | 否 | - | +| interceptor.input | 输入拦截器,将上下文值转为组件内部格式 | function | 否 | - | +| interceptor.output | 输出拦截器,将组件内部值转为上下文格式 | function | 否 | - | +| value | 选择值 | object | 否 | - | +| onChange | 值变化回调函数 | function | 否 | - | + +### SuperSelectTableListFilterItem 组件 + +超级表格列表选择筛选项组件,以表格形式展示选项,支持展示多列信息。 + +#### 组件属性 + +| 属性名 | 说明 | 类型 | 必填 | 默认值 | +|--------|------|------|------|--------| +| label | 字段标签 | string | 否 | - | +| name | 字段名 | string | 否 | - | +| interceptor | 值转换拦截器 | object | 否 | - | +| interceptor.input | 输入拦截器 | function | 否 | - | +| interceptor.output | 输出拦截器 | function | 否 | - | | value | 选择值 | object | 否 | - | | onChange | 值变化回调函数 | function | 否 | - | @@ -1293,3 +1791,333 @@ withFieldItem(WrappedComponent): ReactComponent | interceptor | 拦截器,用于值转换 | object | | interceptor.input | 输入拦截器 | function | | interceptor.output | 输出拦截器 | function | + +#### pickSelectValues + +从筛选值中提取原始值数组。支持 `null`/`undefined`、原始值、`{ value }` 对象、`{ id }` 对象、以及它们的数组。 + +**函数签名:** +```javascript +pickSelectValues(value): string[] +``` + +**参数说明:** + +| 参数名 | 说明 | 类型 | +|--------|------|------| +| value | 筛选值,支持多种格式 | any | + +**返回值:** + +提取后的字符串值数组,空值会被过滤。 + +**示例:** +```javascript +pickSelectValues([{ value: 1 }, { id: 2 }, '3']) +// => ['1', '2', '3'] + +pickSelectValues({ value: 'open' }) +// => ['open'] + +pickSelectValues(null) +// => [] +``` + +#### createFilterValueMapper + +声明式创建 `mapFilterValue` 函数。`Filter.getFilterValue` 默认只读取 `{ value }` 格式,而 SuperSelectFilterItem 等组件使用 `{ id, name }` 格式,需要额外处理。此工具通过声明字段映射规则,自动生成符合 `(filter, getFilterValue) => value` 签名的函数。 + +**函数签名:** +```javascript +createFilterValueMapper(fieldMappers): function +``` + +**参数说明:** + +| 参数名 | 说明 | 类型 | 必填 | +|--------|------|------|------| +| fieldMappers | 字段名到映射规则的映射 | object | 是 | +| fieldMappers[fieldName] | 映射规则,支持字符串或函数 | string \| function | 是 | + +**映射规则类型:** + +| 类型值 | 说明 | 输出格式 | +|--------|------|----------| +| `'string'` | 确保值为字符串类型 | `string` | +| `'multi'` | 多选,从 filter entry 提取值数组 | `string[]` | +| `'single'` | 单选,从 filter entry 提取第一个值 | `string` | +| `function` | 自定义转换函数,接收 `(rawValue, { entry, filter, value })` 返回新值 | any | + +**返回值:** + +返回一个 `mapFilterValue` 函数,签名为 `(filter, getFilterValue) => object`,可直接传给 BizUnit 等组件的 `mapFilterValue` 选项。 + +**示例:** +```javascript +const mapFilterValue = createFilterValueMapper({ + id: 'string', + roles: 'multi', + tenantOrgId: 'single', + status: (rawValue) => normalizeStatus(rawValue) +}); +const filterValue = mapFilterValue(filter, Filter.getFilterValue); +``` + +#### useUrlFilter + +从 URL 参数初始化 Filter 状态的 Hook。读取 URL 参数构建初始筛选值,并在挂载后自动清除已消费的 URL 参数。 + +**函数签名:** +```javascript +useUrlFilter(options): [array, function] +``` + +**参数说明:** + +| 参数名 | 说明 | 类型 | 必填 | +|--------|------|------|------| +| options | 配置对象 | object | 是 | +| options.readUrlParams | 读取 URL 参数的函数,返回包含 `consumedKeys` 的对象 | function | 是 | +| options.buildFilter | 根据 readUrlParams 返回值构建初始 filter 数组 | function | 是 | + +**返回值:** + +| 返回值 | 说明 | 类型 | +|--------|------|------| +| [0] | 初始筛选值数组 | array | +| [1] | 设置筛选值的函数 | function | + +**示例:** +```javascript +const [filter, setFilter] = useUrlFilter({ + readUrlParams: (searchParams) => { + const { take, getConsumedKeys } = createUrlParamsReader(searchParams); + const orgId = take('tenantOrgId'); + return { consumedKeys: getConsumedKeys(), orgId }; + }, + buildFilter: ({ orgId }) => [ + ...(orgId ? [{ name: 'tenantOrgId', value: { label: orgId, value: orgId } }] : []) + ] +}); +``` + +#### createUrlParamsReader + +创建 URL 参数读取器,自动追踪已消费的参数 key。 + +**函数签名:** +```javascript +createUrlParamsReader(searchParams): { take, getConsumedKeys } +``` + +**参数说明:** + +| 参数名 | 说明 | 类型 | 必填 | +|--------|------|------|------| +| searchParams | React Router 的 searchParams 对象 | URLSearchParams | 是 | + +**返回值:** + +| 属性名 | 说明 | 类型 | +|--------|------|------| +| take | 读取指定 key 的值并标记为已消费 | function | +| getConsumedKeys | 获取所有已消费的 key 列表 | function | + +#### stripConsumedUrlParams + +从 URL 参数中移除已消费的 key,返回新的 URLSearchParams。无变化时返回 `null`。 + +**函数签名:** +```javascript +stripConsumedUrlParams(searchParams, consumedKeys): URLSearchParams | null +``` + +**参数说明:** + +| 参数名 | 说明 | 类型 | 必填 | +|--------|------|------|------| +| searchParams | 当前 URL 参数 | URLSearchParams | 是 | +| consumedKeys | 需要移除的 key 列表 | string[] | 是 | + +**返回值:** + +移除后的新 URLSearchParams 对象,无变化返回 `null`。 + +#### filterToUrlParams + +将筛选值数组序列化为 URLSearchParams,保留 label 信息以便反序列化还原完整筛选状态。参数以 `prefix[key]` 格式存入 URL,避免与其他查询参数冲突。 + +**序列化格式**(使用冒号分隔 label 和 value,逗号分隔多值): +- 单值且 label === value:`prefix[name]=value`(如输入框) +- 单值且 label !== value:`prefix[name]=label:value` +- 多值:`prefix[name]=label1:value1,label2:value2` + +**函数签名:** +```javascript +filterToUrlParams(filterValue, options?): URLSearchParams +``` + +**参数说明:** + +| 参数名 | 说明 | 类型 | 必填 | 默认值 | +|--------|------|------|------|--------| +| filterValue | 筛选值数组,格式为 `[{ name, label, value }, ...]` | array | 是 | - | +| options.prefix | URL 参数前缀 | string | 否 | `'filterParams'` | + +**示例:** +```javascript +const params = filterToUrlParams([ + { name: 'keyword', label: '关键词', value: { label: '测试', value: '测试' } }, + { name: 'city', label: '城市', value: [{ label: '上海', value: '010' }, { label: '北京', value: '020' }] }, + { name: 'status', label: '状态', value: { label: '启用', value: 'active' } }, +]); +// params.toString() => 'filterParams[keyword]=测试&filterParams[city]=上海:010,北京:020&filterParams[status]=启用:active' +``` + +#### createUrlFilterReader + +创建 URL 筛选参数读取器,自动追踪已消费的参数 key。配合 `useUrlFilter` 使用,读取后返回的 `consumedKeys` 可被自动从 URL 中清除。 + +**函数签名:** +```javascript +createUrlFilterReader(searchParams, options?): { takeFilterEntry, getConsumedKeys } +``` + +**参数说明:** + +| 参数名 | 说明 | 类型 | 必填 | 默认值 | +|--------|------|------|------|--------| +| searchParams | React Router 的 searchParams 对象 | URLSearchParams | 是 | - | +| options.prefix | URL 参数前缀,需与 `filterToUrlParams` 使用的前缀一致 | string | 否 | `'filterParams'` | + +**返回值:** + +| 属性名 | 说明 | 类型 | +|--------|------|------| +| takeFilterEntry | 读取指定 key 的筛选值并标记为已消费 | function | +| getConsumedKeys | 获取所有已消费的 key 列表(含前缀) | function | + +**takeFilterEntry 签名:** +```javascript +takeFilterEntry(key, options?): { label: string, value: string } | { label: string, value: string }[] | null +``` + +| 参数名 | 说明 | 类型 | 必填 | 默认值 | +|--------|------|------|------|--------| +| key | 参数名(不含前缀) | string | 是 | - | +| options.multi | 是否多选,多选返回数组 | boolean | 否 | false | + +**示例:** +```javascript +const [filter, setFilter] = useUrlFilter({ + readUrlParams: (searchParams) => { + const { takeFilterEntry, getConsumedKeys } = createUrlFilterReader(searchParams); + const keyword = takeFilterEntry('keyword'); + const city = takeFilterEntry('city', { multi: true }); + return { consumedKeys: getConsumedKeys(), keyword, city }; + }, + buildFilter: ({ keyword, city }) => [ + ...(keyword ? [{ name: 'keyword', label: '关键词', value: keyword }] : []), + ...(city ? [{ name: 'city', label: '城市', value: city }] : []), + ], +}); +``` + +#### parseFilterEntry + +解析 URL 参数中的单个筛选值项,反序列化为 `{ label, value }` 对象。 + +**解析规则:** +- 无冒号:label 和 value 相同,如 `"测试"` → `{ label: '测试', value: '测试' }` +- 有冒号:冒号前为 label,冒号后为 value,如 `"启用:active"` → `{ label: '启用', value: 'active' }` + +**函数签名:** +```javascript +parseFilterEntry(str): { label: string, value: string } +``` + +**参数说明:** + +| 参数名 | 说明 | 类型 | 必填 | +|--------|------|------|------| +| str | URL 参数中的原始字符串 | string | 是 | + +**示例:** +```javascript +parseFilterEntry('测试') +// => { label: '测试', value: '测试' } + +parseFilterEntry('启用:active') +// => { label: '启用', value: 'active' } +``` + +#### takeFilterEntry + +从 URL 参数中读取筛选值项(低级 API)。推荐使用 `createUrlFilterReader` 代替,可自动追踪已消费的 key。 + +**函数签名:** +```javascript +takeFilterEntry(searchParams, key, options?): { label: string, value: string } | { label: string, value: string }[] | null +``` + +**参数说明:** + +| 参数名 | 说明 | 类型 | 必填 | 默认值 | +|--------|------|------|------|--------| +| searchParams | URL 参数对象 | URLSearchParams | 是 | - | +| key | 参数名(不含前缀) | string | 是 | - | +| options.multi | 是否多选,多选返回数组 | boolean | 否 | false | +| options.prefix | URL 参数前缀 | string | 否 | `'filterParams'` | + +**示例:** +```javascript +// URL: ?filterParams[city]=上海:010,北京:020 +takeFilterEntry(searchParams, 'city', { multi: true }) +// => [{ label: '上海', value: '010' }, { label: '北京', value: '020' }] +``` + +#### filterInterceptors + +预设拦截器集合,提供用于 SuperSelect 系列组件的值格式转换拦截器。SuperSelect 等组件使用 `{ id, name }` 格式,而 Filter 上下文使用 `{ label, value }` 格式,需要通过拦截器进行自动转换。 + +**导出成员:** + +|| 名称 | 说明 | +||------|------| +|| filterInterceptors | 拦截器集合对象,包含 `single` 和 `multi` 两个拦截器 | +|| singleSelectInterceptor | 单选拦截器,用于 valueKey="id" labelKey="name" 的单选场景 | +|| multiSelectInterceptor | 多选拦截器,用于 valueKey="id" labelKey="name" 的多选场景 | + +**filterInterceptors 结构:** + +|| 属性名 | 说明 | 类型 | +||--------|------|------| +|| single | 单选拦截器,等价于 singleSelectInterceptor | object | +|| multi | 多选拦截器,等价于 multiSelectInterceptor | object | + +**拦截器结构:** + +每个拦截器包含 `input` 和 `output` 两个函数: + +|| 属性名 | 说明 | 转换方向 | +||--------|------|----------| +|| input | 输入拦截器 | 将上下文值 `{ label, value }` 转为组件内部格式 `{ id, name }` | +|| output | 输出拦截器 | 将组件内部值 `{ id, name }` 转回上下文格式 `{ label, value }` | + +**使用方式:** + +配合 `SuperSelectFilterItem` 的 `interceptor` 属性使用: + +```javascript +import { SuperSelectFilterItem, singleSelectInterceptor, multiSelectInterceptor } from '@components/Filter'; + +// 单选场景 + + +// 多选场景 + + +// 或通过 filterInterceptors 解构 +const { single, multi } = filterInterceptors; + +``` diff --git a/src/components/Filter/createFilterValueMapper.js b/src/components/Filter/createFilterValueMapper.js new file mode 100644 index 0000000..7198ba8 --- /dev/null +++ b/src/components/Filter/createFilterValueMapper.js @@ -0,0 +1,86 @@ +import pickSelectValues from './pickSelectValues'; + +/** + * 声明式创建 mapFilterValue 函数。 + * + * Filter.getFilterValue 默认只读取 { value },而 SuperSelectFilterItem 等 + * 组件使用 { id, name } 格式,需要额外处理。此工具通过声明字段映射规则, + * 自动生成符合 (filter, getFilterValue) => value 签名的函数。 + * + * @param {Object} fieldMappers - 字段名到映射规则的映射 + * @param {'string'} fieldMappers[field] - 确保值为字符串类型 + * @param {'multi'} fieldMappers[field] - 多选,从 filter entry 提取值数组 + * @param {'single'} fieldMappers[field] - 单选,从 filter entry 提取第一个值 + * @param {Function} fieldMappers[field] - 自定义转换,接收 (rawValue, { entry, filter, value }) 返回新值 + * + * @example + * const mapFilterValue = createFilterValueMapper({ + * id: 'string', + * roles: 'multi', + * tenantOrgId: 'single', + * status: (rawValue) => normalizeStatus(rawValue) + * }); + * + * // 使用方式与手写 mapFilterValue 一致 + * const filterValue = mapFilterValue(filter, Filter.getFilterValue); + * + * // 也可直接传给 BizUnit 的 options.mapFilterValue + * + */ +const createFilterValueMapper = (fieldMappers) => { + return (filter, getFilterValue) => { + const value = getFilterValue(filter); + + Object.entries(fieldMappers).forEach(([fieldName, mapper]) => { + const entry = filter.find(item => item.name === fieldName); + + if (mapper === 'string') { + if (value[fieldName] != null && value[fieldName] !== '') { + value[fieldName] = String(value[fieldName]); + } + } else if (mapper === 'multi') { + if (entry) { + const values = pickSelectValues(entry.value); + if (values.length) { + value[fieldName] = values; + } else { + delete value[fieldName]; + } + } + } else if (mapper === 'single') { + if (entry) { + const values = pickSelectValues(entry.value); + if (values[0] != null) { + value[fieldName] = values[0]; + } else { + delete value[fieldName]; + } + } else if (value[fieldName] != null && typeof value[fieldName] === 'object') { + const values = pickSelectValues(value[fieldName]); + if (values.length) { + value[fieldName] = values[0]; + } else { + delete value[fieldName]; + } + } + } else if (typeof mapper === 'function') { + let rawValue = value[fieldName]; + if (rawValue != null && typeof rawValue === 'object' && 'value' in rawValue) { + rawValue = rawValue.value; + } + if (rawValue != null && rawValue !== '') { + const result = mapper(rawValue, { entry, filter, value }); + if (result != null && result !== '') { + value[fieldName] = result; + } else { + delete value[fieldName]; + } + } + } + }); + + return value; + }; +}; + +export default createFilterValueMapper; diff --git a/src/components/Filter/doc/_update_api.py b/src/components/Filter/doc/_update_api.py new file mode 100644 index 0000000..e69de29 diff --git a/src/components/Filter/doc/api.md b/src/components/Filter/doc/api.md index 9397cf4..1205f4f 100644 --- a/src/components/Filter/doc/api.md +++ b/src/components/Filter/doc/api.md @@ -231,6 +231,25 @@ | label | 字段标签 | string | 否 | - | | name | 字段名 | string | 否 | - | | options | 选项数据数组 | array | 否 | - | +| interceptor | 值转换拦截器,用于在组件内部格式和筛选上下文格式之间转换 | object | 否 | - | +| interceptor.input | 输入拦截器,将上下文值转为组件内部格式 | function | 否 | - | +| interceptor.output | 输出拦截器,将组件内部值转为上下文格式 | function | 否 | - | +| value | 选择值 | object | 否 | - | +| onChange | 值变化回调函数 | function | 否 | - | + +### SuperSelectTableListFilterItem 组件 + +超级表格列表选择筛选项组件,以表格形式展示选项,支持展示多列信息。 + +#### 组件属性 + +| 属性名 | 说明 | 类型 | 必填 | 默认值 | +|--------|------|------|------|--------| +| label | 字段标签 | string | 否 | - | +| name | 字段名 | string | 否 | - | +| interceptor | 值转换拦截器 | object | 否 | - | +| interceptor.input | 输入拦截器 | function | 否 | - | +| interceptor.output | 输出拦截器 | function | 否 | - | | value | 选择值 | object | 否 | - | | onChange | 值变化回调函数 | function | 否 | - | @@ -444,3 +463,333 @@ withFieldItem(WrappedComponent): ReactComponent | interceptor | 拦截器,用于值转换 | object | | interceptor.input | 输入拦截器 | function | | interceptor.output | 输出拦截器 | function | + +#### pickSelectValues + +从筛选值中提取原始值数组。支持 `null`/`undefined`、原始值、`{ value }` 对象、`{ id }` 对象、以及它们的数组。 + +**函数签名:** +```javascript +pickSelectValues(value): string[] +``` + +**参数说明:** + +| 参数名 | 说明 | 类型 | +|--------|------|------| +| value | 筛选值,支持多种格式 | any | + +**返回值:** + +提取后的字符串值数组,空值会被过滤。 + +**示例:** +```javascript +pickSelectValues([{ value: 1 }, { id: 2 }, '3']) +// => ['1', '2', '3'] + +pickSelectValues({ value: 'open' }) +// => ['open'] + +pickSelectValues(null) +// => [] +``` + +#### createFilterValueMapper + +声明式创建 `mapFilterValue` 函数。`Filter.getFilterValue` 默认只读取 `{ value }` 格式,而 SuperSelectFilterItem 等组件使用 `{ id, name }` 格式,需要额外处理。此工具通过声明字段映射规则,自动生成符合 `(filter, getFilterValue) => value` 签名的函数。 + +**函数签名:** +```javascript +createFilterValueMapper(fieldMappers): function +``` + +**参数说明:** + +| 参数名 | 说明 | 类型 | 必填 | +|--------|------|------|------| +| fieldMappers | 字段名到映射规则的映射 | object | 是 | +| fieldMappers[fieldName] | 映射规则,支持字符串或函数 | string \| function | 是 | + +**映射规则类型:** + +| 类型值 | 说明 | 输出格式 | +|--------|------|----------| +| `'string'` | 确保值为字符串类型 | `string` | +| `'multi'` | 多选,从 filter entry 提取值数组 | `string[]` | +| `'single'` | 单选,从 filter entry 提取第一个值 | `string` | +| `function` | 自定义转换函数,接收 `(rawValue, { entry, filter, value })` 返回新值 | any | + +**返回值:** + +返回一个 `mapFilterValue` 函数,签名为 `(filter, getFilterValue) => object`,可直接传给 BizUnit 等组件的 `mapFilterValue` 选项。 + +**示例:** +```javascript +const mapFilterValue = createFilterValueMapper({ + id: 'string', + roles: 'multi', + tenantOrgId: 'single', + status: (rawValue) => normalizeStatus(rawValue) +}); +const filterValue = mapFilterValue(filter, Filter.getFilterValue); +``` + +#### useUrlFilter + +从 URL 参数初始化 Filter 状态的 Hook。读取 URL 参数构建初始筛选值,并在挂载后自动清除已消费的 URL 参数。 + +**函数签名:** +```javascript +useUrlFilter(options): [array, function] +``` + +**参数说明:** + +| 参数名 | 说明 | 类型 | 必填 | +|--------|------|------|------| +| options | 配置对象 | object | 是 | +| options.readUrlParams | 读取 URL 参数的函数,返回包含 `consumedKeys` 的对象 | function | 是 | +| options.buildFilter | 根据 readUrlParams 返回值构建初始 filter 数组 | function | 是 | + +**返回值:** + +| 返回值 | 说明 | 类型 | +|--------|------|------| +| [0] | 初始筛选值数组 | array | +| [1] | 设置筛选值的函数 | function | + +**示例:** +```javascript +const [filter, setFilter] = useUrlFilter({ + readUrlParams: (searchParams) => { + const { take, getConsumedKeys } = createUrlParamsReader(searchParams); + const orgId = take('tenantOrgId'); + return { consumedKeys: getConsumedKeys(), orgId }; + }, + buildFilter: ({ orgId }) => [ + ...(orgId ? [{ name: 'tenantOrgId', value: { label: orgId, value: orgId } }] : []) + ] +}); +``` + +#### createUrlParamsReader + +创建 URL 参数读取器,自动追踪已消费的参数 key。 + +**函数签名:** +```javascript +createUrlParamsReader(searchParams): { take, getConsumedKeys } +``` + +**参数说明:** + +| 参数名 | 说明 | 类型 | 必填 | +|--------|------|------|------| +| searchParams | React Router 的 searchParams 对象 | URLSearchParams | 是 | + +**返回值:** + +| 属性名 | 说明 | 类型 | +|--------|------|------| +| take | 读取指定 key 的值并标记为已消费 | function | +| getConsumedKeys | 获取所有已消费的 key 列表 | function | + +#### stripConsumedUrlParams + +从 URL 参数中移除已消费的 key,返回新的 URLSearchParams。无变化时返回 `null`。 + +**函数签名:** +```javascript +stripConsumedUrlParams(searchParams, consumedKeys): URLSearchParams | null +``` + +**参数说明:** + +| 参数名 | 说明 | 类型 | 必填 | +|--------|------|------|------| +| searchParams | 当前 URL 参数 | URLSearchParams | 是 | +| consumedKeys | 需要移除的 key 列表 | string[] | 是 | + +**返回值:** + +移除后的新 URLSearchParams 对象,无变化返回 `null`。 + +#### filterToUrlParams + +将筛选值数组序列化为 URLSearchParams,保留 label 信息以便反序列化还原完整筛选状态。参数以 `prefix[key]` 格式存入 URL,避免与其他查询参数冲突。 + +**序列化格式**(使用冒号分隔 label 和 value,逗号分隔多值): +- 单值且 label === value:`prefix[name]=value`(如输入框) +- 单值且 label !== value:`prefix[name]=label:value` +- 多值:`prefix[name]=label1:value1,label2:value2` + +**函数签名:** +```javascript +filterToUrlParams(filterValue, options?): URLSearchParams +``` + +**参数说明:** + +| 参数名 | 说明 | 类型 | 必填 | 默认值 | +|--------|------|------|------|--------| +| filterValue | 筛选值数组,格式为 `[{ name, label, value }, ...]` | array | 是 | - | +| options.prefix | URL 参数前缀 | string | 否 | `'filterParams'` | + +**示例:** +```javascript +const params = filterToUrlParams([ + { name: 'keyword', label: '关键词', value: { label: '测试', value: '测试' } }, + { name: 'city', label: '城市', value: [{ label: '上海', value: '010' }, { label: '北京', value: '020' }] }, + { name: 'status', label: '状态', value: { label: '启用', value: 'active' } }, +]); +// params.toString() => 'filterParams[keyword]=测试&filterParams[city]=上海:010,北京:020&filterParams[status]=启用:active' +``` + +#### createUrlFilterReader + +创建 URL 筛选参数读取器,自动追踪已消费的参数 key。配合 `useUrlFilter` 使用,读取后返回的 `consumedKeys` 可被自动从 URL 中清除。 + +**函数签名:** +```javascript +createUrlFilterReader(searchParams, options?): { takeFilterEntry, getConsumedKeys } +``` + +**参数说明:** + +| 参数名 | 说明 | 类型 | 必填 | 默认值 | +|--------|------|------|------|--------| +| searchParams | React Router 的 searchParams 对象 | URLSearchParams | 是 | - | +| options.prefix | URL 参数前缀,需与 `filterToUrlParams` 使用的前缀一致 | string | 否 | `'filterParams'` | + +**返回值:** + +| 属性名 | 说明 | 类型 | +|--------|------|------| +| takeFilterEntry | 读取指定 key 的筛选值并标记为已消费 | function | +| getConsumedKeys | 获取所有已消费的 key 列表(含前缀) | function | + +**takeFilterEntry 签名:** +```javascript +takeFilterEntry(key, options?): { label: string, value: string } | { label: string, value: string }[] | null +``` + +| 参数名 | 说明 | 类型 | 必填 | 默认值 | +|--------|------|------|------|--------| +| key | 参数名(不含前缀) | string | 是 | - | +| options.multi | 是否多选,多选返回数组 | boolean | 否 | false | + +**示例:** +```javascript +const [filter, setFilter] = useUrlFilter({ + readUrlParams: (searchParams) => { + const { takeFilterEntry, getConsumedKeys } = createUrlFilterReader(searchParams); + const keyword = takeFilterEntry('keyword'); + const city = takeFilterEntry('city', { multi: true }); + return { consumedKeys: getConsumedKeys(), keyword, city }; + }, + buildFilter: ({ keyword, city }) => [ + ...(keyword ? [{ name: 'keyword', label: '关键词', value: keyword }] : []), + ...(city ? [{ name: 'city', label: '城市', value: city }] : []), + ], +}); +``` + +#### parseFilterEntry + +解析 URL 参数中的单个筛选值项,反序列化为 `{ label, value }` 对象。 + +**解析规则:** +- 无冒号:label 和 value 相同,如 `"测试"` → `{ label: '测试', value: '测试' }` +- 有冒号:冒号前为 label,冒号后为 value,如 `"启用:active"` → `{ label: '启用', value: 'active' }` + +**函数签名:** +```javascript +parseFilterEntry(str): { label: string, value: string } +``` + +**参数说明:** + +| 参数名 | 说明 | 类型 | 必填 | +|--------|------|------|------| +| str | URL 参数中的原始字符串 | string | 是 | + +**示例:** +```javascript +parseFilterEntry('测试') +// => { label: '测试', value: '测试' } + +parseFilterEntry('启用:active') +// => { label: '启用', value: 'active' } +``` + +#### takeFilterEntry + +从 URL 参数中读取筛选值项(低级 API)。推荐使用 `createUrlFilterReader` 代替,可自动追踪已消费的 key。 + +**函数签名:** +```javascript +takeFilterEntry(searchParams, key, options?): { label: string, value: string } | { label: string, value: string }[] | null +``` + +**参数说明:** + +| 参数名 | 说明 | 类型 | 必填 | 默认值 | +|--------|------|------|------|--------| +| searchParams | URL 参数对象 | URLSearchParams | 是 | - | +| key | 参数名(不含前缀) | string | 是 | - | +| options.multi | 是否多选,多选返回数组 | boolean | 否 | false | +| options.prefix | URL 参数前缀 | string | 否 | `'filterParams'` | + +**示例:** +```javascript +// URL: ?filterParams[city]=上海:010,北京:020 +takeFilterEntry(searchParams, 'city', { multi: true }) +// => [{ label: '上海', value: '010' }, { label: '北京', value: '020' }] +``` + +#### filterInterceptors + +预设拦截器集合,提供用于 SuperSelect 系列组件的值格式转换拦截器。SuperSelect 等组件使用 `{ id, name }` 格式,而 Filter 上下文使用 `{ label, value }` 格式,需要通过拦截器进行自动转换。 + +**导出成员:** + +|| 名称 | 说明 | +||------|------| +|| filterInterceptors | 拦截器集合对象,包含 `single` 和 `multi` 两个拦截器 | +|| singleSelectInterceptor | 单选拦截器,用于 valueKey="id" labelKey="name" 的单选场景 | +|| multiSelectInterceptor | 多选拦截器,用于 valueKey="id" labelKey="name" 的多选场景 | + +**filterInterceptors 结构:** + +|| 属性名 | 说明 | 类型 | +||--------|------|------| +|| single | 单选拦截器,等价于 singleSelectInterceptor | object | +|| multi | 多选拦截器,等价于 multiSelectInterceptor | object | + +**拦截器结构:** + +每个拦截器包含 `input` 和 `output` 两个函数: + +|| 属性名 | 说明 | 转换方向 | +||--------|------|----------| +|| input | 输入拦截器 | 将上下文值 `{ label, value }` 转为组件内部格式 `{ id, name }` | +|| output | 输出拦截器 | 将组件内部值 `{ id, name }` 转回上下文格式 `{ label, value }` | + +**使用方式:** + +配合 `SuperSelectFilterItem` 的 `interceptor` 属性使用: + +```javascript +import { SuperSelectFilterItem, singleSelectInterceptor, multiSelectInterceptor } from '@components/Filter'; + +// 单选场景 + + +// 多选场景 + + +// 或通过 filterInterceptors 解构 +const { single, multi } = filterInterceptors; + +``` diff --git a/src/components/Filter/doc/example.json b/src/components/Filter/doc/example.json index 77b76b6..5a18f45 100644 --- a/src/components/Filter/doc/example.json +++ b/src/components/Filter/doc/example.json @@ -123,6 +123,51 @@ "packageName": "antd" } ] + }, + { + "title": "筛选值映射与提取", + "description": "展示 createFilterValueMapper 声明式值映射和 pickSelectValues 值提取工具的用法", + "code": "./filter-value-mapper.js", + "scope": [ + { + "name": "_Filter", + "packageName": "@components/Filter" + }, + { + "name": "antd", + "packageName": "antd" + } + ] + }, + { + "title": "URL 筛选参数", + "description": "展示 filterToUrlParams、parseFilterEntry、takeFilterEntry、createUrlFilterReader 等 URL 参数序列化与反序列化工具的用法", + "code": "./use-url-filter.js", + "scope": [ + { + "name": "_Filter", + "packageName": "@components/Filter" + }, + { + "name": "antd", + "packageName": "antd" + } + ] + }, + { + "title": "筛选值拦截器", + "description": "展示 filterInterceptors、singleSelectInterceptor、multiSelectInterceptor 拦截器的用法,解决 SuperSelect 组件 { id, name } 与 Filter 上下文 { label, value } 格式不匹配的问题", + "code": "./filter-interceptors.js", + "scope": [ + { + "name": "_Filter", + "packageName": "@components/Filter" + }, + { + "name": "antd", + "packageName": "antd" + } + ] } ] } diff --git a/src/components/Filter/doc/filter-interceptors.js b/src/components/Filter/doc/filter-interceptors.js new file mode 100644 index 0000000..c59cc3f --- /dev/null +++ b/src/components/Filter/doc/filter-interceptors.js @@ -0,0 +1,158 @@ +const { + default: Filter, + SuperSelectFilterItem, + filterInterceptors, + singleSelectInterceptor, + multiSelectInterceptor, + getFilterValue, + pickSelectValues, +} = _Filter; +const { useState } = React; +const { Space, Card, Divider, Typography, Alert } = antd; + +const { Text, Title, Paragraph } = Typography; + +// filterInterceptors 提供了 single 和 multi 两种预设拦截器 +// 适用于 SuperSelectFilterItem 等使用 { id, name } 格式的组件 +// interceptor.input:将 { label, value } 转为 { id, name }(传入组件的 value 格式) +// interceptor.output:将 { id, name } 转回 { label, value }(筛选上下文的 value 格式) + +const BaseExample = () => { + const [value1, onChange1] = useState([]); + const [value2, onChange2] = useState([]); + + return ( + + + + + + 适用于 valueKey="id" labelKey="name" 的单选 SuperSelect 场景,自动在 {`{label, value}`} 和 {`{id, name}`} 之间转换 + + { + console.log('筛选值:', getFilterValue(value)); + onChange1(value); + }} + list={[ + [ + , + , + ], + ]} + /> + {value1.length > 0 && ( + <> + + 当前筛选值 +
+              {JSON.stringify(getFilterValue(value1), null, 2)}
+            
+ pickSelectValues 提取原始值 +
+              {JSON.stringify(Object.fromEntries(
+                value1.map(item => [item.name, pickSelectValues(item.value)])
+              ), null, 2)}
+            
+ + )} +
+ + + + 适用于 valueKey="id" labelKey="name" 的多选 SuperSelect 场景,自动在 {`[{label, value}]`} 和 {`[{id, name}]`} 之间转换 + + { + console.log('筛选值:', getFilterValue(value)); + onChange2(value); + }} + list={[ + [ + , + , + ], + ]} + /> + {value2.length > 0 && ( + <> + + 当前筛选值 +
+              {JSON.stringify(getFilterValue(value2), null, 2)}
+            
+ pickSelectValues 提取原始值 +
+              {JSON.stringify(Object.fromEntries(
+                value2.map(item => [item.name, pickSelectValues(item.value)])
+              ), null, 2)}
+            
+ + )} +
+ + + + filterInterceptors 对象同时提供了 single 和 multi 两种拦截器,可以直接解构使用: + +
+{`// 方式一:直接引用
+import { singleSelectInterceptor, multiSelectInterceptor } from '@components/Filter';
+
+// 方式二:从 filterInterceptors 解构
+const { single, multi } = filterInterceptors;
+
+// single 等价于 singleSelectInterceptor
+// multi 等价于 multiSelectInterceptor`}
+        
+
+
+ ); +}; + +render(); diff --git a/src/components/Filter/doc/filter-value-mapper.js b/src/components/Filter/doc/filter-value-mapper.js new file mode 100644 index 0000000..e9b43a3 --- /dev/null +++ b/src/components/Filter/doc/filter-value-mapper.js @@ -0,0 +1,77 @@ +const { + default: Filter, + SuperSelectFilterItem, + CityFilterItem, + InputFilterItem, + getFilterValue, + pickSelectValues, + createFilterValueMapper, +} = _Filter; +const { useState } = React; +const { Space, Card, Divider, Typography } = antd; + +const { Text, Title } = Typography; + +// 声明式创建 mapFilterValue 函数 +const mapFilterValue = createFilterValueMapper({ + keyword: 'string', // 确保字符串类型 + city: 'multi', // 多选 → string[] + status: 'single', // 单选 → string +}); + +const BaseExample = () => { + const [value, onChange] = useState([ + { name: 'keyword', label: '关键词', value: { label: '搜索词', value: '搜索词' } }, + { name: 'city', label: '城市', value: [{ label: '上海', value: '010' }, { label: '北京', value: '020' }] }, + { name: 'status', label: '状态', value: [{ label: '启用', value: 'active', id: 'active' }] }, + ]); + + const rawFilterValue = getFilterValue(value); + const mappedFilterValue = mapFilterValue(value, getFilterValue); + + return ( + + , + , + , + ], + ]} + /> + + + {`pickSelectValues([{ value: 1 }, { id: 2 }, '3'])`} +
+ 结果:{JSON.stringify(pickSelectValues([{ value: 1 }, { id: 2 }, '3']))} + + {`pickSelectValues({ value: 'open' })`} +
+ 结果:{JSON.stringify(pickSelectValues({ value: 'open' }))} +
+ + + 原始 getFilterValue 结果 +
+          {JSON.stringify(rawFilterValue, null, 2)}
+        
+ createFilterValueMapper 映射后结果 +
+          {JSON.stringify(mappedFilterValue, null, 2)}
+        
+
+
+ ); +}; + +render(); diff --git a/src/components/Filter/doc/summary.md b/src/components/Filter/doc/summary.md index 6714f27..6fa7794 100644 --- a/src/components/Filter/doc/summary.md +++ b/src/components/Filter/doc/summary.md @@ -1,5 +1,5 @@ Filter 是一个功能强大的筛选组件库,用于构建灵活的筛选条件界面。该组件提供了多种预置的筛选字段类型,支持自定义筛选项,并提供了完整的筛选值管理和展示功能。 -核心特性包括:丰富的预置筛选字段,涵盖文本输入、日期选择、城市选择、用户选择、行业选择、职能选择等多种类型;灵活的筛选值管理,支持受控和非受控模式;支持展开/收起筛选项,避免筛选条件过多导致界面混乱;提供高级筛选组件,适用于复杂筛选场景;支持自定义字段和组合使用,满足各种业务需求;内置搜索输入框和筛选值展示组件,提升用户体验。 +核心特性包括:丰富的预置筛选字段,涵盖文本输入、日期选择、城市选择、用户选择、行业选择、职能选择等多种类型;灵活的筛选值管理,支持受控和非受控模式;声明式筛选值映射(`createFilterValueMapper`),统一处理多选、单选、自定义转换等值提取逻辑;URL 参数序列化与初始化(`filterToUrlParams` / `createUrlFilterReader` / `useUrlFilter`),筛选值以 `filterParams[key]` 格式序列化到 URL(前缀可自定义),从 URL 参数构建初始筛选状态并自动清理已消费参数;值格式拦截器(`filterInterceptors`),自动处理 SuperSelect 组件 `{ id, name }` 与 Filter 上下文 `{ label, value }` 格式之间的转换;支持展开/收起筛选项,避免筛选条件过多导致界面混乱;提供高级筛选组件,适用于复杂筛选场景;支持自定义字段和组合使用,满足各种业务需求;内置搜索输入框和筛选值展示组件,提升用户体验。 适用于数据列表、表格筛选、报表查询等需要多条件筛选的场景。组件采用 Context API 进行状态管理,支持嵌套使用和组合,能够满足企业级应用中各种复杂的筛选需求。 \ No newline at end of file diff --git a/src/components/Filter/doc/use-url-filter.js b/src/components/Filter/doc/use-url-filter.js new file mode 100644 index 0000000..5ca474b --- /dev/null +++ b/src/components/Filter/doc/use-url-filter.js @@ -0,0 +1,220 @@ +const { + default: Filter, + InputFilterItem, + CityFilterItem, + SuperSelectFilterItem, + filterToUrlParams, + parseFilterEntry, + takeFilterEntry, + createUrlFilterReader, + getFilterValue, + createFilterValueMapper, + pickSelectValues, +} = _Filter; +const { useState, useMemo } = React; +const { Space, Card, Divider, Typography, Button, Alert, Tag } = antd; + +const { Text, Title, Paragraph } = Typography; + +// ========== 示例数据 ========== +const sampleFilterValue = [ + { name: 'keyword', label: '关键词', value: { label: '前端开发', value: '前端开发' } }, + { name: 'city', label: '城市', value: [{ label: '上海', value: '010' }, { label: '北京', value: '020' }] }, + { name: 'status', label: '状态', value: { label: '招聘中', value: 'active', id: 'active' } }, +]; + +// 声明式创建 mapFilterValue,配合 URL 参数使用 +const mapFilterValue = createFilterValueMapper({ + keyword: 'string', + city: 'multi', + status: 'single', +}); + +const BaseExample = () => { + const [value, onChange] = useState([]); + + // ===== 1. filterToUrlParams:筛选值 → URL 参数 ===== + const sampleUrlParams = useMemo(() => filterToUrlParams(sampleFilterValue), []); + const liveUrlParams = useMemo(() => filterToUrlParams(value), [value]); + + // ===== 2. parseFilterEntry:解析单个值 ===== + const parsedEntries = useMemo(() => [ + { input: "'前端开发'", output: parseFilterEntry('前端开发') }, + { input: "'招聘中:active'", output: parseFilterEntry('招聘中:active') }, + { input: "'上海:010'", output: parseFilterEntry('上海:010') }, + ], []); + + // ===== 3. 从 URL 参数反序列化还原筛选状态 ===== + const roundTripResult = useMemo(() => { + const urlStr = sampleUrlParams.toString(); + const searchParams = new URLSearchParams(urlStr); + + // 使用 createUrlFilterReader 读取 + const reader = createUrlFilterReader(searchParams); + const keyword = reader.takeFilterEntry('keyword'); + const city = reader.takeFilterEntry('city', { multi: true }); + const status = reader.takeFilterEntry('status'); + const consumedKeys = reader.getConsumedKeys(); + + // 还原为 filter 数组 + const restored = []; + if (keyword) restored.push({ name: 'keyword', label: '关键词', value: keyword }); + if (city) restored.push({ name: 'city', label: '城市', value: city }); + if (status) restored.push({ name: 'status', label: '状态', value: status }); + + return { urlStr, keyword, city, status, consumedKeys, restored }; + }, [sampleUrlParams]); + + // ===== 4. takeFilterEntry 直接读取 ===== + const takeResults = useMemo(() => { + const sp = sampleUrlParams; + return [ + { input: "takeFilterEntry(params, 'keyword')", output: takeFilterEntry(sp, 'keyword') }, + { input: "takeFilterEntry(params, 'city', { multi: true })", output: takeFilterEntry(sp, 'city', { multi: true }) }, + { input: "takeFilterEntry(params, 'status')", output: takeFilterEntry(sp, 'status') }, + ]; + }, [sampleUrlParams]); + + // ===== 5. 映射后筛选值 ===== + const mappedSample = mapFilterValue(sampleFilterValue, getFilterValue); + + return ( + + + + {/* ===== 交互式 Filter ===== */} + + 选择筛选条件后,下方 URL 参数会实时更新 + , + , + , + ], + ]} + /> + {value.length > 0 && ( + <> + + 当前筛选值 +
+              {JSON.stringify(value, null, 2)}
+            
+ 序列化后的 URL 参数 +
+              {liveUrlParams.toString() || '(无)'}
+            
+ + )} +
+ + {/* ===== filterToUrlParams ===== */} + + + 将筛选值数组序列化为 URLSearchParams,保留 label 信息以便反序列化还原 + + 输入:筛选值数组 +
+{JSON.stringify(sampleFilterValue, null, 2)}
+        
+ 输出:URL 参数字符串 +
+          {roundTripResult.urlStr}
+        
+
+ + {/* ===== parseFilterEntry ===== */} + + + 将 URL 参数中的字符串反序列化为 {`{ label, value }`} 对象 + + {parsedEntries.map(({ input, output }, i) => ( +
+ {`parseFilterEntry(${input})`} + {' → '} + {JSON.stringify(output)} +
+ ))} +
+ + {/* ===== takeFilterEntry ===== */} + + + 直接从 URLSearchParams 中读取并反序列化指定 key 的筛选值 + + {takeResults.map(({ input, output }, i) => ( +
+ {input} +
+ 结果: + {JSON.stringify(output)} +
+ ))} +
+ + {/* ===== createUrlFilterReader + 完整还原流程 ===== */} + + + 使用 createUrlFilterReader 从 URL 参数读取并还原完整的筛选状态,同时追踪已消费的 key + + 步骤1:从 URL 参数读取各字段值 +
+{`const reader = createUrlFilterReader(searchParams);
+const keyword = reader.takeFilterEntry('keyword');     // → ${JSON.stringify(roundTripResult.keyword)}
+const city    = reader.takeFilterEntry('city', { multi: true }); // → ${JSON.stringify(roundTripResult.city)}
+const status  = reader.takeFilterEntry('status');      // → ${JSON.stringify(roundTripResult.status)}
+reader.getConsumedKeys();                               // → ${JSON.stringify(roundTripResult.consumedKeys)}`}
+        
+ 步骤2:还原为筛选值数组 +
+          {JSON.stringify(roundTripResult.restored, null, 2)}
+        
+ + 验证:还原后的数据与原始数据一致 + + 还原成功 + + keyword: {roundTripResult.keyword?.value} | city: [{roundTripResult.city?.map(c => c.value).join(', ')}] | status: {roundTripResult.status?.value} + + +
+ + {/* ===== createFilterValueMapper ===== */} + + + 实际业务中,先用 createFilterValueMapper 将筛选值映射为接口参数格式,再用 filterToUrlParams 序列化到 URL + + 映射后的筛选值(用于接口请求) +
+{`const mapFilterValue = createFilterValueMapper({
+  keyword: 'string',   // 确保字符串
+  city: 'multi',       // 多选 → string[]
+  status: 'single',    // 单选 → string
+});
+
+mapFilterValue(filterValue, getFilterValue);
+// →`}
+{'  ' + JSON.stringify(mappedSample, null, 2)}
+        
+
+
+ ); +}; + +render(); diff --git a/src/components/Filter/filterInterceptors.js b/src/components/Filter/filterInterceptors.js new file mode 100644 index 0000000..594264a --- /dev/null +++ b/src/components/Filter/filterInterceptors.js @@ -0,0 +1,67 @@ +const toIdName = item => { + if (!item) { + return null; + } + if (item.id != null) { + return item; + } + if (item.value != null) { + return {id: item.value, name: item.label}; + } + return item; +}; + +const toLabelValue = item => { + if (!item) { + return null; + } + return { + label: item.name ?? item.label, + value: item.id ?? item.value + }; +}; + +/** + * 单选 SuperSelect interceptor:{id, name} ↔ {label, value} + * 适用于 valueKey="id" labelKey="name" 的单选场景 + */ +export const singleSelectInterceptor = { + input: value => { + if (!value) { + return value; + } + const item = Array.isArray(value) ? value[0] : value; + const result = toIdName(item); + return result; + }, + output: selected => { + if (!selected) { + return selected; + } + const item = Array.isArray(selected) ? selected[0] : selected; + return toLabelValue(item); + } +}; + +/** + * 多选 SuperSelect interceptor:[{id, name}] ↔ [{label, value}] + * 适用于 valueKey="id" labelKey="name" 的多选场景 + */ +export const multiSelectInterceptor = { + input: value => { + if (!value) { + return value; + } + const list = Array.isArray(value) ? value : [value]; + return list.map(toIdName).filter(Boolean); + }, + output: selected => { + if (!selected) { + return selected; + } + const list = Array.isArray(selected) ? selected : [selected]; + return list.map(toLabelValue).filter(Boolean); + } +}; + +export default {single: singleSelectInterceptor, multi: multiSelectInterceptor}; diff --git a/src/components/Filter/filterToUrlParams.js b/src/components/Filter/filterToUrlParams.js new file mode 100644 index 0000000..a09f2e5 --- /dev/null +++ b/src/components/Filter/filterToUrlParams.js @@ -0,0 +1,192 @@ +/** + * 将筛选值数组序列化为 URLSearchParams,保留 label 信息以便反序列化还原完整筛选状态。 + * + * 序列化格式(使用冒号分隔 label 和 value,逗号分隔多值): + * - 单值且 label === value:prefix[name]=value(如输入框) + * - 单值且 label !== value:prefix[name]=label:value + * - 多值:prefix[name]=label1:value1,label2:value2 + * + * @param {Array} filterValue - 筛选值数组,格式为 [{ name, label, value }, ...] + * @param {Object} [options] - 选项 + * @param {string} [options.prefix='filterParams'] - URL 参数前缀,设为空字符串则不加前缀 + * @returns {URLSearchParams} + * + * @example + * const params = filterToUrlParams([ + * { name: 'keyword', label: '关键词', value: { label: '测试', value: '测试' } }, + * { name: 'city', label: '城市', value: [{ label: '上海', value: '010' }, { label: '北京', value: '020' }] }, + * { name: 'status', label: '状态', value: { label: '启用', value: 'active' } }, + * ]); + * // params.toString() => 'filterParams[keyword]=测试&filterParams[city]=上海:010,北京:020&filterParams[status]=启用:active' + * + * // 自定义前缀 + * filterToUrlParams(filterValue, { prefix: 'f' }) + * // => 'f[keyword]=测试&f[city]=上海:010,北京:020' + * + * // 无前缀(直接平铺) + * filterToUrlParams(filterValue, { prefix: '' }) + * // => 'keyword=测试&city=上海:010,北京:020' + */ +const filterToUrlParams = (filterValue, { prefix = 'filterParams' } = {}) => { + const params = new URLSearchParams(); + + if (!Array.isArray(filterValue)) return params; + + filterValue.forEach(({ name, value }) => { + if (value == null || value === '') return; + + const key = prefix ? `${prefix}[${name}]` : name; + + if (Array.isArray(value)) { + if (value.length === 0) return; + const serialized = value + .map((item) => serializeEntry(item)) + .filter(Boolean) + .join(','); + if (serialized) params.set(key, serialized); + } else { + const serialized = serializeEntry(value); + if (serialized) params.set(key, serialized); + } + }); + + return params; +}; + +/** + * 序列化单个筛选值项。 + * - label === value 时返回 value(简化格式) + * - 否则返回 label:value + */ +const serializeEntry = (item) => { + if (item == null) return null; + if (typeof item !== 'object') return String(item); + + const label = item.label ?? item.name; + const value = item.value ?? item.id; + + if (value == null || value === '') return null; + if (label === value) return String(value); + return `${label}:${value}`; +}; + +/** + * 解析 URL 参数中的单个筛选值项,反序列化为 { label, value } 对象。 + * 与 filterToUrlParams 配合使用。 + * + * 解析规则: + * - 无冒号:label 和 value 相同,如 "测试" → { label: '测试', value: '测试' } + * - 有冒号:冒号前为 label,冒号后为 value,如 "启用:active" → { label: '启用', value: 'active' } + * + * @param {string} str - URL 参数中的原始字符串 + * @returns {{ label: string, value: string }} + * + * @example + * parseFilterEntry('测试') + * // => { label: '测试', value: '测试' } + * + * parseFilterEntry('启用:active') + * // => { label: '启用', value: 'active' } + */ +const parseFilterEntry = (str) => { + const colonIndex = str.indexOf(':'); + if (colonIndex === -1) { + return { label: str, value: str }; + } + return { + label: str.slice(0, colonIndex), + value: str.slice(colonIndex + 1), + }; +}; + +/** + * 从 URL 参数中读取筛选值项,返回单选 { label, value } 或多选数组。 + * + * @param {URLSearchParams} searchParams - URL 参数对象 + * @param {string} key - 参数名(不含前缀) + * @param {Object} [options] - 选项 + * @param {boolean} [options.multi=false] - 是否多选,多选返回数组 + * @param {string} [options.prefix='filterParams'] - URL 参数前缀,设为空字符串则不加前缀 + * @returns {{ label: string, value: string } | { label: string, value: string }[] | null} + * + * @example + * // URL: ?filterParams[city]=上海:010,北京:020&filterParams[status]=启用:active + * takeFilterEntry(searchParams, 'city', { multi: true }) + * // => [{ label: '上海', value: '010' }, { label: '北京', value: '020' }] + * + * // 无前缀 + * takeFilterEntry(searchParams, 'keyword', { prefix: '' }) + * // => { label: '测试', value: '测试' } + */ +const takeFilterEntry = (searchParams, key, { multi = false, prefix = 'filterParams' } = {}) => { + const fullKey = prefix ? `${prefix}[${key}]` : key; + if (!searchParams.has(fullKey)) return null; + + const raw = searchParams.get(fullKey); + if (!raw) return null; + + if (multi) { + return raw.split(',').map(parseFilterEntry); + } + + // 单选时,如果包含逗号,只取第一个 + const firstEntry = raw.includes(',') ? raw.split(',')[0] : raw; + return parseFilterEntry(firstEntry); +}; + +/** + * 创建 URL 筛选参数读取器,自动追踪已消费的参数 key。 + * 配合 useUrlFilter 使用,readUrlParams 返回的 consumedKeys 可被自动清除。 + * + * @param {URLSearchParams} searchParams - React Router 的 searchParams 对象 + * @param {Object} [options] - 选项 + * @param {string} [options.prefix='filterParams'] - URL 参数前缀 + * @returns {{ takeFilterEntry, getConsumedKeys }} + * + * @example + * const { takeFilterEntry, getConsumedKeys } = createUrlFilterReader(searchParams); + * const keyword = takeFilterEntry('keyword'); + * const city = takeFilterEntry('city', { multi: true }); + * const status = takeFilterEntry('status'); + * // getConsumedKeys() => ['filterParams[keyword]', 'filterParams[city]', 'filterParams[status]'] + * + * // 配合 useUrlFilter 使用 + * const [filter, setFilter] = useUrlFilter({ + * readUrlParams: (searchParams) => { + * const { takeFilterEntry, getConsumedKeys } = createUrlFilterReader(searchParams); + * const keyword = takeFilterEntry('keyword'); + * const city = takeFilterEntry('city', { multi: true }); + * return { consumedKeys: getConsumedKeys(), keyword, city }; + * }, + * buildFilter: ({ keyword, city }) => [ + * ...(keyword ? [{ name: 'keyword', label: '关键词', value: keyword }] : []), + * ...(city ? [{ name: 'city', label: '城市', value: city }] : []), + * ], + * }); + */ +const createUrlFilterReader = (searchParams, { prefix = 'filterParams' } = {}) => { + const consumedKeys = []; + + const takeFilterEntry = (key, { multi = false } = {}) => { + const fullKey = prefix ? `${prefix}[${key}]` : key; + if (!searchParams.has(fullKey)) return null; + + consumedKeys.push(fullKey); + const raw = searchParams.get(fullKey); + if (!raw) return null; + + if (multi) { + return raw.split(',').map(parseFilterEntry); + } + + const firstEntry = raw.includes(',') ? raw.split(',')[0] : raw; + return parseFilterEntry(firstEntry); + }; + + const getConsumedKeys = () => consumedKeys; + + return { takeFilterEntry, getConsumedKeys }; +}; + +export default filterToUrlParams; +export { parseFilterEntry, takeFilterEntry, createUrlFilterReader }; diff --git a/src/components/Filter/index.js b/src/components/Filter/index.js index 16ab580..e2fe066 100644 --- a/src/components/Filter/index.js +++ b/src/components/Filter/index.js @@ -5,6 +5,11 @@ import {useContext as useFilter} from "./context"; import withFilterValue from "./withFilterValue"; import SearchInput from "./SearchInput"; import FilterProvider from './FilterProvider'; +import pickSelectValues from "./pickSelectValues"; +import createFilterValueMapper from "./createFilterValueMapper"; +import useUrlFilter, {createUrlParamsReader, stripConsumedUrlParams} from "./useUrlFilter"; +import filterToUrlParams, {parseFilterEntry, takeFilterEntry, createUrlFilterReader} from "./filterToUrlParams"; +import filterInterceptors, {singleSelectInterceptor, multiSelectInterceptor} from "./filterInterceptors"; Filter.fields = fields; Filter.getFilterValue = getFilterValue; @@ -12,8 +17,20 @@ Filter.useFilter = useFilter; Filter.SearchInput = SearchInput; Filter.withFilterValue = withFilterValue; Filter.FilterProvider = FilterProvider; +Filter.pickSelectValues = pickSelectValues; +Filter.createFilterValueMapper = createFilterValueMapper; +Filter.useUrlFilter = useUrlFilter; +Filter.createUrlParamsReader = createUrlParamsReader; +Filter.stripConsumedUrlParams = stripConsumedUrlParams; +Filter.filterToUrlParams = filterToUrlParams; +Filter.parseFilterEntry = parseFilterEntry; +Filter.takeFilterEntry = takeFilterEntry; +Filter.createUrlFilterReader = createUrlFilterReader; +Filter.filterInterceptors = filterInterceptors; +Filter.singleSelectInterceptor = singleSelectInterceptor; +Filter.multiSelectInterceptor = multiSelectInterceptor; export default Filter; -export {fields, getFilterValue, useFilter, withFilterValue, SearchInput, FilterProvider}; +export {fields, getFilterValue, useFilter, withFilterValue, SearchInput, FilterProvider, pickSelectValues, createFilterValueMapper, useUrlFilter, createUrlParamsReader, stripConsumedUrlParams, filterToUrlParams, parseFilterEntry, takeFilterEntry, createUrlFilterReader, filterInterceptors, singleSelectInterceptor, multiSelectInterceptor}; export {default as AdvancedFilter, advancedFields} from "./AdvancedFilter"; export {default as FilterValueDisplay} from "./FilterValueDisplay"; export {default as FilterItem} from "./FilterItem"; diff --git a/src/components/Filter/pickSelectValues.js b/src/components/Filter/pickSelectValues.js new file mode 100644 index 0000000..c6f5742 --- /dev/null +++ b/src/components/Filter/pickSelectValues.js @@ -0,0 +1,39 @@ +/** + * 从筛选值中提取原始值数组。 + * 支持:原始值、{ value } 对象、{ id } 对象、以及它们的数组。 + * + * @example + * pickSelectValues([{ value: 1 }, { id: 2 }, '3']) + * // => ['1', '2', '3'] + * + * pickSelectValues({ value: 'open' }) + * // => ['open'] + * + * pickSelectValues(null) + * // => [] + */ +const pickSelectValues = value => { + if (value == null || value === '') { + return []; + } + const list = Array.isArray(value) ? value : [value]; + return list + .map(item => { + if (item == null) { + return null; + } + if (typeof item !== 'object') { + return String(item); + } + if (item.value != null && item.value !== '') { + return String(item.value); + } + if (item.id != null && item.id !== '') { + return String(item.id); + } + return null; + }) + .filter(Boolean); +}; + +export default pickSelectValues; diff --git a/src/components/Filter/useUrlFilter.js b/src/components/Filter/useUrlFilter.js new file mode 100644 index 0000000..e6ba26e --- /dev/null +++ b/src/components/Filter/useUrlFilter.js @@ -0,0 +1,93 @@ +import { useState, useRef, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +/** + * 创建 URL 参数读取器,自动追踪已消费的参数 key。 + * + * @param {URLSearchParams} searchParams - React Router 的 searchParams 对象 + * @returns {{ take: (key: string) => string|null, getConsumedKeys: () => string[] }} + * + * @example + * const { take, getConsumedKeys } = createUrlParamsReader(searchParams); + * const orgId = take('tenantOrgId'); + * const orgName = take('orgName'); + * // getConsumedKeys() => ['tenantOrgId', 'orgName'] + */ +export const createUrlParamsReader = (searchParams) => { + const consumedKeys = []; + const take = (key) => { + if (!searchParams.has(key)) return null; + consumedKeys.push(key); + return searchParams.get(key); + }; + const getConsumedKeys = () => consumedKeys; + return { take, getConsumedKeys }; +}; + +/** + * 从 URL 参数中移除已消费的 key,返回新的 URLSearchParams 或 null(无变化时)。 + * + * @param {URLSearchParams} searchParams - 当前 URL 参数 + * @param {string[]} consumedKeys - 需要移除的 key 列表 + * @returns {URLSearchParams|null} + */ +export const stripConsumedUrlParams = (searchParams, consumedKeys) => { + if (!consumedKeys?.length) return null; + const next = new URLSearchParams(searchParams); + let changed = false; + consumedKeys.forEach(key => { + if (next.has(key)) { + next.delete(key); + changed = true; + } + }); + return changed ? next : null; +}; + +/** + * 从 URL 参数初始化 Filter 状态的 hook。 + * + * 读取 URL 参数构建初始筛选值,并在挂载后自动清除已消费的 URL 参数。 + * + * @param {Object} options + * @param {Function} options.readUrlParams - 读取 URL 参数并返回 { consumedKeys: string[], ...data } + * @param {Function} options.buildFilter - 接收 readUrlParams 的返回值,构建初始 filter 数组 + * @returns {[Array, Function]} - [filter, setFilter] + * + * @example + * const [filter, setFilter] = useUrlFilter({ + * readUrlParams: (searchParams) => { + * const { take, getConsumedKeys } = createUrlParamsReader(searchParams); + * const orgId = take('tenantOrgId'); + * return { consumedKeys: getConsumedKeys(), orgId }; + * }, + * buildFilter: ({ orgId }) => [ + * { name: 'status', value: { label: '开启', value: 'open' } }, + * ...(orgId ? [{ name: 'tenantOrgId', value: { label: orgId, value: orgId } }] : []) + * ] + * }); + */ +const useUrlFilter = ({ readUrlParams, buildFilter }) => { + const [searchParams, setSearchParams] = useSearchParams(); + const urlFilterSnapshotRef = useRef(null); + + if (urlFilterSnapshotRef.current === null) { + urlFilterSnapshotRef.current = readUrlParams(searchParams); + } + + const [filter, setFilter] = useState(() => buildFilter(urlFilterSnapshotRef.current)); + + const urlStrippedRef = useRef(false); + useEffect(() => { + if (urlStrippedRef.current) return; + urlStrippedRef.current = true; + const nextParams = stripConsumedUrlParams(searchParams, urlFilterSnapshotRef.current?.consumedKeys); + if (nextParams) { + setSearchParams(nextParams, { replace: true }); + } + }, [searchParams, setSearchParams]); + + return [filter, setFilter]; +}; + +export default useUrlFilter; diff --git a/src/components/FormInfo/preset.js b/src/components/FormInfo/preset.js index 48ed62b..9ea1954 100644 --- a/src/components/FormInfo/preset.js +++ b/src/components/FormInfo/preset.js @@ -102,6 +102,16 @@ const formPreset = async (options, otherOptions) => { return !value; }); + interceptors.input.use("object-output-value", (value) => { + if (!value) { + return value; + } + if (typeof value === "object") { + return value; + } + return {id: value}; + }); + interceptors.output.use("object-output-value", (value) => { if (!value) { return value; @@ -109,6 +119,16 @@ const formPreset = async (options, otherOptions) => { return value.value || value.id; }); + interceptors.input.use("array-output-value", (value) => { + if (!Array.isArray(value) || value.length === 0) { + return Array.isArray(value) ? value : []; + } + if (typeof value[0] === "object") { + return value; + } + return value.map((id) => ({id})); + }); + interceptors.output.use("array-output-value", (value) => { if (!(Array.isArray(value) && value.length > 0)) { return [];