fix: update ActionModel to set title and icon properties correctly

This commit is contained in:
Zeke Zhang 2025-06-25 08:58:04 +08:00
parent fcaa643a37
commit 3143c2dbd8
2 changed files with 507 additions and 27 deletions

View File

@ -7,48 +7,527 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { MultiRecordResource } from '@nocobase/flow-engine';
import { ButtonProps, Input, Popover } from 'antd';
import { Button, ButtonProps, Input, Popover } from 'antd';
import _ from 'lodash';
import React from 'react';
import React, { FC } from 'react';
import { GlobalActionModel } from '../base/ActionModel';
import { useToken } from '../../../style/useToken';
import { CloseCircleOutlined } from '@ant-design/icons';
import { Trans, useTranslation } from 'react-i18next';
import { Cascader, Select, Space } from 'antd';
import { css } from '@emotion/css';
import { useApp } from '../../../application/hooks/useApp';
import { observer } from '@formily/reactive-react';
import { useFlowModel, useStepSettingContext } from '@nocobase/flow-engine';
const findOperator = (obj) => {
let current = obj;
while (current && typeof current === 'object') {
const keys = Object.keys(current);
// 如果没有属性或者有多个属性,停止遍历
if (keys.length !== 1) {
break;
}
const key = keys[0];
// 如果遇到以 $ 开头的属性,停止遍历
if (key.startsWith('$')) {
return key;
}
// 移动到下一层
current = current[key];
}
};
const findFieldValue = (obj) => {
let current = obj;
while (current && typeof current === 'object') {
const keys = Object.keys(current);
// 如果没有属性或者有多个属性,停止遍历
if (keys.length !== 1) {
break;
}
const key = keys[0];
// 如果遇到以 $ 开头的属性,停止遍历
if (key.startsWith('$')) {
return current[key];
}
// 移动到下一层
current = current[key];
}
};
function extractPath(obj) {
const path = [];
let current = obj;
while (current && typeof current === 'object') {
const keys = Object.keys(current);
// 如果没有属性或者有多个属性,停止遍历
if (keys.length !== 1) {
break;
}
const key = keys[0];
// 如果遇到以 $ 开头的属性,停止遍历
if (key.startsWith('$')) {
break;
}
// 将当前属性添加到路径中
path.push(key);
// 移动到下一层
current = current[key];
}
return path;
}
function findFieldInOptions(options, fieldNames) {
return fieldNames.reduce((currentOptions, fieldName, index) => {
if (!currentOptions) return null;
const foundField = currentOptions.find((field) => field.name === fieldName);
// 如果是最后一层,返回找到的字段;否则返回其 children 供下一次迭代
return index === fieldNames.length - 1 ? foundField : foundField?.children;
}, options);
}
const field2option = (field, depth, nonfilterable, dataSourceManager) => {
if (nonfilterable.length && depth === 1 && nonfilterable.includes(field.name)) {
return;
}
if (!field.interface) {
return;
}
if (field.filterable === false) {
return;
}
const fieldInterface = dataSourceManager?.collectionFieldInterfaceManager.getFieldInterface(field.interface);
if (!fieldInterface?.filterable) {
return;
}
const { nested, children, operators } = fieldInterface.filterable;
const option = {
name: field.name,
type: field.type,
target: field.target,
title: field?.uiSchema?.title || field.name,
schema: field?.uiSchema,
operators:
operators?.filter?.((operator) => {
return !operator?.visible || operator.visible(field);
}) || [],
};
if (field.target && depth > 2) {
return;
}
if (depth > 2) {
return option;
}
if (children?.length) {
option['children'] = children;
}
if (nested) {
const targetFields = dataSourceManager
.getDataSource(field.dataSourceKey)
.collectionManager.getCollectionFields(field.target);
const options = getOptions(targetFields, depth + 1, nonfilterable, dataSourceManager).filter(Boolean);
option['children'] = option['children'] || [];
option['children'].push(...options);
}
return option;
};
const getOptions = (fields, depth, nonfilterable, dataSourceManager) => {
const options = [];
fields.forEach((field) => {
const option = field2option(field, depth, nonfilterable, dataSourceManager);
if (option) {
options.push(option);
}
});
return options;
};
const FieldComponent: FC<{ component: string; value: any; onChange: any; [key: string]: any }> = (props) => {
const { component, ...others } = props;
const app = useApp();
const Component = app.getComponent(component);
if (!Component) {
return <Input {...others} />;
}
return <Component {...others} />;
};
const FilterItem: FC<{
options: any[];
value: any;
onRemove: () => void;
onChange?: (value: any) => void;
}> = observer(
(props) => {
const fieldNames = extractPath(props.value);
// 使用提取的函数在树结构中查找字段
const filed = findFieldInOptions(props.options, fieldNames);
const operators = filed?.operators || [];
const operator = findOperator(props.value);
const operatorOption = operators.find((op) => op.value === operator) || {};
const fieldValue = findFieldValue(props.value);
const component = operatorOption?.schema?.['x-component'] || filed?.schema?.['x-component'] || 'Input';
const componentProps = operatorOption?.schema?.['x-component-props'] || filed?.schema?.['x-component-props'] || {};
const { t } = useTranslation();
const onChangeFieldName = (value) => {
if (!value) {
return;
}
const newFieldPath = value.join('.');
delete props.value[fieldNames[0]];
_.set(props.value, newFieldPath, {});
props.onChange?.(props.value);
};
const onChangeFieldValue = (event) => {
const value = event?.target?.value || event;
const fieldPath = fieldNames.join('.');
_.set(props.value, `${fieldPath}.${operator}`, value);
props.onChange?.(props.value);
};
const onClearFieldName = () => {
delete props.value[fieldNames[0]];
props.onChange?.(props.value);
};
const onChangeOperator = (value) => {
const fieldPath = fieldNames.join('.');
const operatorObject = _.get(props.value, fieldPath);
const prevOperator = Object.keys(operatorObject || {})[0];
_.set(props.value, `${fieldPath}.${value}`, _.get(props.value, `${fieldPath}.${prevOperator}`));
if (prevOperator) {
delete operatorObject[prevOperator];
}
props.onChange?.(props.value);
};
return (
<div style={{ marginBottom: 8 }}>
<Space wrap>
<Cascader
// @ts-ignore
role="button"
data-testid="select-filter-field"
className={css`
width: 160px;
`}
popupClassName={css`
.ant-cascader-menu {
height: fit-content;
max-height: 50vh;
}
`}
allowClear
fieldNames={{
label: 'title',
value: 'name',
children: 'children',
}}
value={fieldNames}
options={props.options}
onChange={onChangeFieldName}
placeholder={t('Select field')}
changeOnSelect={false}
onClear={onClearFieldName}
/>
<Select
// @ts-ignore
role="button"
data-testid="select-filter-operator"
className={css`
min-width: 110px;
`}
popupMatchSelectWidth={false}
value={operator}
options={operators}
onChange={onChangeOperator}
placeholder={t('Comparision')}
/>
{!operatorOption.noValue ? (
<FieldComponent
component={component}
value={fieldValue}
onChange={onChangeFieldValue}
{...componentProps}
/>
) : null}
<a role="button" aria-label="icon-close">
<CloseCircleOutlined onClick={props.onRemove} style={{ color: '#bfbfbf' }} />
</a>
</Space>
</div>
);
},
{
displayName: 'FilterItem',
},
);
const FilterGroup: FC<{
value: any;
options: any[];
showBorder?: boolean;
onRemove?: () => void;
onChange?: (value: any) => void;
}> = observer(
(props) => {
const { token } = useToken();
const { t } = useTranslation();
const logic = Object.keys(props.value).includes('$or') ? '$or' : '$and';
const items = props.value[logic] || [];
const style: React.CSSProperties = props.showBorder
? {
position: 'relative',
border: `1px dashed ${token.colorBorder}`,
padding: token.paddingSM,
marginBottom: token.marginXS,
}
: {
position: 'relative',
marginBottom: token.marginXS,
};
const onChangeLogic = (value) => {
const prevLogic = Object.keys(props.value)[0];
props.value[value] = props.value[prevLogic];
delete props.value[prevLogic];
props.onChange?.(props.value);
};
const onAddCondition = () => {
const subItems = Object.values(props.value)[0] as any[];
subItems.push({});
props.onChange?.(props.value);
};
const onAddConditionGroup = () => {
const subItems = Object.values(props.value)[0] as any[];
subItems.push({ $and: [{}] });
props.onChange?.(props.value);
};
return (
<div style={style}>
{props.showBorder && (
<a role="button" aria-label="icon-close">
<CloseCircleOutlined
style={{
position: 'absolute',
right: 10,
top: 10,
color: '#bfbfbf',
}}
onClick={props.onRemove}
/>
</a>
)}
<div style={{ marginBottom: 8, color: token.colorText }}>
<Trans>
{'Meet '}
<Select
// @ts-ignore
role="button"
data-testid="filter-select-all-or-any"
style={{ width: 'auto' }}
value={logic}
onChange={onChangeLogic}
>
<Select.Option value={'$and'}>All</Select.Option>
<Select.Option value={'$or'}>Any</Select.Option>
</Select>
{' conditions in the group'}
</Trans>
</div>
<div>
{items.map((item, index) =>
item.$and || item.$or ? (
<FilterGroup
key={index}
value={item}
showBorder
options={props.options}
onRemove={() => {
items.splice(index, 1);
}}
onChange={props.onChange}
/>
) : (
<FilterItem
key={index}
value={item}
options={props.options}
onRemove={() => {
items.splice(index, 1);
}}
onChange={props.onChange}
/>
),
)}
</div>
<Space size={16} style={{ marginTop: 8, marginBottom: 8 }}>
<a onClick={onAddCondition}>{t('Add condition')}</a>
<a onClick={onAddConditionGroup}>{t('Add condition group')}</a>
</Space>
</div>
);
},
{
displayName: 'FilterGroup',
},
);
const FilterContent: FC<{ value: any }> = (props) => {
const modelInstance = useFlowModel();
const currentBlockModel = modelInstance.ctx.shared.currentBlockModel;
const fields = currentBlockModel.collection.options.fields;
const ignoreFieldsNames = modelInstance.props.ignoreFieldsNames || [];
const options = getOptions(fields, 1, ignoreFieldsNames, currentBlockModel.ctx.globals.app.dataSourceManager).filter(
Boolean,
);
return (
<>
<FilterGroup value={props.value} options={options} />
<Space style={{ width: '100%', display: 'flex', justifyContent: 'flex-end' }}>
<Button onClick={() => modelInstance.dispatchEvent('reset')}>Reset</Button>
<Button type="primary" onClick={() => modelInstance.dispatchEvent('submit')}>
Submit
</Button>
</Space>
</>
);
};
export class FilterActionModel extends GlobalActionModel {
defaultProps: ButtonProps = {
declare props: ButtonProps & {
filterValue?: any;
ignoreFieldsNames?: string[];
};
defaultProps: any = {
type: 'default',
children: 'Filter',
icon: 'FilterOutlined',
filterValue: { $and: [] },
ignoreFieldsNames: [],
};
render() {
return (
<Popover
content={
<div>
<Input
placeholder="Nickname, email, phone, etc."
onChange={_.debounce((e) => {
const resource = this.ctx.shared?.currentBlockModel?.resource as MultiRecordResource;
if (!resource) {
return;
}
resource.addFilterGroup(this.uid, {
$or: [
{ ['nickname.$includes']: e.target.value },
{ ['email.$includes']: e.target.value },
{ ['phone.$includes']: e.target.value },
],
});
resource.refresh();
}, 500)}
/>
</div>
}
content={<FilterContent value={this.props.filterValue || this.defaultProps.filterValue} />}
trigger="click"
placement="bottom"
placement="bottomLeft"
>
{super.render()}
</Popover>
);
}
}
FilterActionModel.registerFlow({
key: 'filterSettings',
title: '筛选配置',
auto: true,
steps: {
ignoreFieldsNames: {
title: '可筛选字段',
uiSchema: {
ignoreFieldsNames: {
type: 'array',
'x-decorator': 'FormItem',
'x-component': (props) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { model } = useStepSettingContext();
const options = model.ctx.shared.currentBlockModel.collection.options.fields.map((field) => {
return {
label: field.uiSchema?.title || field.name,
value: field.name,
};
});
return <Select {...props} options={options} />;
},
'x-component-props': {
mode: 'multiple',
placeholder: '请选择不可筛选的字段',
},
},
},
defaultParams(ctx) {
return {
ignoreFieldsNames: ctx.model.defaultProps.ignoreFieldsNames || [],
};
},
handler(ctx, params) {
ctx.model.setProps('ignoreFieldsNames', params.ignoreFieldsNames);
},
},
defaultValue: {
title: '默认筛选条件',
uiSchema: {
filterValue: {
type: 'object',
'x-decorator': 'FormItem',
'x-component': (props) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { model: modelInstance } = useStepSettingContext();
const currentBlockModel = modelInstance.ctx.shared.currentBlockModel;
const fields = currentBlockModel.collection.options.fields;
const ignoreFieldsNames = modelInstance.props.ignoreFieldsNames || [];
const options = getOptions(
fields,
1,
ignoreFieldsNames,
currentBlockModel.ctx.globals.app.dataSourceManager,
).filter(Boolean);
return <FilterGroup value={props.value} options={options} />;
},
},
},
defaultParams(ctx) {
return {
filterValue: ctx.model.defaultProps.filterValue,
};
},
handler(ctx, params) {
ctx.model.setProps('filterValue', params.filterValue);
},
},
},
});

View File

@ -61,7 +61,8 @@ ActionModel.registerFlow({
};
},
handler(ctx, params) {
ctx.model.setProps(params);
ctx.model.setProps('title', params.title);
ctx.model.setProps('icon', params.icon);
ctx.model.setProps('onClick', (event) => {
ctx.model.dispatchEvent('click', {
...ctx.extra,