diff --git a/docs/zh-CN/components/crud.md b/docs/zh-CN/components/crud.md index 48cac667b..b47ac32e7 100755 --- a/docs/zh-CN/components/crud.md +++ b/docs/zh-CN/components/crud.md @@ -3999,6 +3999,7 @@ itemAction 里的 onClick 还能通过 `data` 参数拿到当前行的数据, | filterable | `boolean` \| [`QuickFilterConfig`](./crud#quickfilterconfig) | `false` | 是否可快速搜索,`options`属性为静态选项,支持设置`source`属性从接口获取选项 | | quickEdit | `boolean` \| [`QuickEditConfig`](./crud#quickeditconfig) | - | 快速编辑,一般需要配合`quickSaveApi`接口使用 | | quickEditEnabledOn | `SchemaExpression` | - | 开启快速编辑条件[表达式](../../docs/concepts/expression) | | +| textOverflow | `string` | `default` | 文本溢出后展示形式,默认换行处理。可选值 `ellipsis` 溢出隐藏展示, `noWrap` 不换行展示(仅在列为静态文本时生效) | `6.9.0` | #### QuickFilterConfig diff --git a/docs/zh-CN/components/form/input-tree.md b/docs/zh-CN/components/form/input-tree.md index 887c2fe48..af590e3ac 100755 --- a/docs/zh-CN/components/form/input-tree.md +++ b/docs/zh-CN/components/form/input-tree.md @@ -249,6 +249,8 @@ order: 59 `cascade`默认为 false,子节点禁止反选,值不包含子节点值,配置`"cascade": true`,子节点可以反选,值包含父子节点值(1.9.0 之前的版本 cascade 配置为 true 的效果为:选中父节点不默认选中子节点) +> 6.9.0 以上版本 autoCancelParent 配置为 true 的效果为:取消子节点,自动去除父节点的值(仅在多选和 cascade 为 true 时生效) + ```schema: scope="body" { "type": "form", @@ -292,7 +294,7 @@ order: 59 { "type": "divider" }, - { + { "type": "input-tree", "name": "tree2", "label": "子节点可以反选,值包含父子节点值", @@ -326,6 +328,45 @@ order: 59 "value": "c" } ] + }, + { + "type": "divider" + }, + { + "type": "input-tree", + "name": "tree3", + "label": "子节点可以反选,值包含父子节点值,取消子节点,自动去除父节点的值", + "multiple": true, + "cascade": true, + "autoCancelParent": true, + "options": [ + { + "label": "A", + "value": "a" + }, + { + "label": "B", + "value": "b", + "children": [ + { + "label": "B-1", + "value": "b-1" + }, + { + "label": "B-2", + "value": "b-2" + }, + { + "label": "B-3", + "value": "b-3" + } + ] + }, + { + "label": "C", + "value": "c" + } + ] } ] } @@ -1086,6 +1127,338 @@ true false false [{label: 'A/B/C', value: 'a/b/c'},{label: 'A 适用于需选择的数据/信息源较多时,用户可直观的知道自己所选择的数据/信息的场景。未配置 searchApi 是前端检索,配置之后就只能通过后端检索。 +## 节点行为配置 + +> 6.9.0 以上版本 + +设置`nodeBehavior`属性,可以更改节点的行为,默认为选中行为,支持配置多个行为。 + +```schema: scope="body" +{ + "type": "form", + "api": "/api/mock2/form/saveForm", + "body": [ + { + "type": "input-tree", + "name": "tree1", + "label": "选中", + "options": [ + { + "label": "Folder A", + "value": 1, + "children": [ + { + "label": "file A", + "value": 2, + } + ] + }, + { + "label": "file C", + "value": 3 + } + ] + }, + { + "type": "input-tree", + "name": "tree2", + "label": "展开", + "nodeBehavior": ["unfold"], + "options": [ + { + "label": "Folder A", + "value": 4, + "children": [ + { + "label": "file A", + "value": 5, + } + ] + }, + { + "label": "file C", + "value": 6 + } + ] + }, + { + "type": "input-tree", + "name": "tree3", + "label": "选中+展开", + "nodeBehavior": ["check", "unfold"], + "options": [ + { + "label": "Folder A", + "value": 7, + "children": [ + { + "label": "file A", + "value": 8, + } + ] + }, + { + "label": "file C", + "value": 9 + } + ] + } + ] +} +``` + +## 自定义选项操作 + +> 6.9.0 以上版本 + +> 使用`itemActions`属性,自定义下拉选项的操作。 + +```schema: scope="body" +{ + "type": "form", + "api": "/api/mock2/form/saveForm", + "body": [ + { + "type": "input-tree", + "name": "tree", + "label": "Tree", + "iconField": "icon", + "options": [ + { + "label": "采购单", + "value": "order", + "tag": "数据模型", + "icon": "fa fa-database", + "children": [ + { + "label": "ID", + "value": "id", + "tag": "数字", + "icon": "fa fa-check" + }, + { + "label": "采购人", + "value": "name", + "tag": "字符串", + "icon": "fa fa-check" + }, + { + "label": "采购时间", + "value": "time", + "tag": "日期时间", + "icon": "fa fa-check" + } + ] + } + ], + "itemActions": [ + { + "type": "button", + "icon": "fa fa-plus", + "level": "link", + "size": "xs", + "onEvent": { + "click": { + "weight": 0, + "actions": [ + { + "ignoreError": false, + "actionType": "toast", + "args": { + "msgType": "info", + "position": "top-right", + "closeButton": true, + "showIcon": true, + "msg": "自定义操作", + "className": "theme-toast-action-scope" + } + } + ] + } + } + } + ] + } + ] +} +``` + +## 工具栏区域 + +> 6.9.0 以上版本 +> 使用`toolbar`属性,自定义工具栏区域。(仅开启检索时生效) + +```schema: scope="body" +{ + "type": "form", + "api": "/api/mock2/form/saveForm", + "body": [ + { + "type": "input-tree", + "name": "tree", + "label": "Tree", + "searchable": true, + "toolbar": [ + { + "type": "button", + "label": "弹窗", + "onEvent": { + "click": { + "actions": [ + { + "actionType": "dialog", + "dialog": { + "type": "dialog", + "title": "未命名弹窗", + "body": [ + { + "type": "tpl", + "tpl": "弹窗内容" + } + ], + "actions": [ + { + "type": "button", + "actionType": "cancel", + "label": "取消" + }, + { + "type": "button", + "actionType": "confirm", + "label": "确定", + "primary": true + } + ] + } + } + ] + } + } + } + ], + "options": [ + { + "label": "Folder A", + "value": 1, + "collapsed": true, + "children": [ + { + "label": "file A", + "value": 2 + }, + { + "label": "file B", + "value": 3 + } + ] + }, + { + "label": "file D", + "value": 4 + } + ] + } + ] +} +``` + +## 虚拟列表 + +> 6.9.0 以上版本, 开启 heightAuto 后,虚拟列表将自适应高度 + +```schema: scope="body" +{ + "type": "page", + "aside": [ + { + "type": "flex", + "direction": "column", + "isFixedHeight": true, + "style": { + "height": "300px" + }, + "items": [ + { + "type": "input-tree", + "id": "tree", + "name": "tree", + "label": false, + "heightAuto": true, + "virtualThreshold": 5, + "options": [ + { + "label": "Folder A", + "value": 1, + "children": [ + { + "label": "file A", + "value": 2 + }, + { + "label": "file B", + "value": 3 + } + ] + }, + { + "label": "file C", + "value": 4 + }, + { + "label": "file D", + "value": 5 + }, + { + "label": "file E", + "value": 6 + }, + { + "label": "file F", + "value": 7 + }, + { + "label": "file G", + "value": 8 + }, + { + "label": "file H", + "value": 9 + }, + { + "label": "file I", + "value": 10 + }, + { + "label": "file J", + "value": 11 + }, + { + "label": "file K", + "value": 12 + }, + { + "label": "file L", + "value": 13 + } + ], + "wrapperCustomStyle": { + "root": { + "height": "100%" + } + } + } + ] + } + ], + "body": [ + { + "type": "tpl", + "tpl": "开启heightAuto后,设置tree的高度,虚拟列表将自适应" + } + ] +} +``` + ## 属性表 当做选择器表单项使用时,除了支持 [普通表单项属性表](./formitem#%E5%B1%9E%E6%80%A7%E8%A1%A8) 中的配置以外,还支持下面一些配置 @@ -1137,6 +1510,11 @@ true false false [{label: 'A/B/C', value: 'a/b/c'},{label: 'A | menuTpl | `string` | | 选项自定义渲染 HTML 片段 | `2.8.0` | | enableDefaultIcon | `boolean` | `true` | 是否为选项添加默认的前缀 Icon,父节点默认为`folder`,叶节点默认为`file` | `2.8.0` | | heightAuto | `boolean` | `false` | 默认高度会有个 maxHeight,即超过一定高度就会内部滚动,如果希望自动增长请设置此属性 | `3.0.0` | +| nodeBehavior | `Array<'unfold' \| 'check' \| ''>` | `['check']` | 节点行为配置,支持配置多个行为 | `6.9.0` | +| autoCancelParent | `boolean` | `false` | 子节点取消时自动取消父节点的值,仅在多选且 cascade 为 true 时生效 | `6.9.0` | +| toolbar | `SchemaNode` | | 工具栏区域,仅开启检索时生效 | `6.9.0` | +| toolbarClassName | `string` | | 工具栏区域类名 | `6.9.0` | +| itemActions | `SchemaNode` | | 节点操作栏区域 | `6.9.0` | ## 事件表 @@ -1151,6 +1529,7 @@ true false false [{label: 'A/B/C', value: 'a/b/c'},{label: 'A | editConfirm (3.6.4 及以上版本) | `[name]: object` 组件的值
`item: object` 编辑的节点信息
`items: object[]`选项集合 | 编辑节点提交时触发 | | deleteConfirm (3.6.4 及以上版本) | `[name]: string` 组件的值
`item: object` 删除的节点信息
`items: object[]`选项集合 | 删除节点提交时触发 | | deferLoadFinished (3.6.4 及以上版本) | `[name]: object` 组件的值
`result: object` deferApi 懒加载远程请求成功后返回的数据
`items: object[]`选项集合 | 懒加载接口远程请求成功时触发 | +| itemClick (6.9.0 以上版本) | `value: any`表单项的值,值格式取决于具体配置
`item: object` 点击的节点信息 | 节点点击时触发 | | add(不推荐) | `[name]: object` 新增的节点信息
`items: object[]`选项集合(< 2.3.2 及以下版本 为`options`) | 新增节点提交时触发 | | edit(不推荐) | `[name]: object` 编辑的节点信息
`items: object[]`选项集合(< 2.3.2 及以下版本 为`options`) | 编辑节点提交时触发 | | delete(不推荐) | `[name]: object` 删除的节点信息
`items: object[]`选项集合(< 2.3.2 及以下版本 为`options`) | 删除节点提交时触发 | @@ -1440,6 +1819,60 @@ true false false [{label: 'A/B/C', value: 'a/b/c'},{label: 'A } ``` +### itemClick + +```schema: scope="body" +{ + "type": "form", + "api": "/api/mock2/form/saveForm", + "debug": true, + "body": [ + { + "type": "input-tree", + "name": "tree", + "label": "Tree", + "nodeBehavior": [], + "onEvent": { + "itemClick": { + "actions": [ + { + "actionType": "toast", + "args": { + "msg": "${event.data.tree|json}" + } + } + ] + } + }, + "options": [ + { + "label": "Folder A", + "value": 1, + "children": [ + { + "label": "file A", + "value": 2 + }, + { + "label": "file B", + "value": 3 + } + ] + }, + { + "label": "file C", + "value": 4 + }, + { + "label": "file D", + "value": 5 + } + ] + } + ] +} +``` + ## 动作表 当前组件对外暴露以下特性动作,其他组件可以通过指定`actionType: 动作名称`、`componentId: 该组件id`来触发这些动作,动作配置可以通过`args: {动作配置项名称: xxx}`来配置具体的参数,详细请查看[事件动作](../../docs/concepts/event-action#触发其他组件的动作)。 @@ -1455,6 +1888,7 @@ true false false [{label: 'A/B/C', value: 'a/b/c'},{label: 'A | clear | - | 清空 | | reset | - | 将值重置为初始值。6.3.0 及以下版本为`resetValue` | | setValue | `value: string` \| `string[]` 更新的值 | 更新数据,开启`multiple`支持设置多项,开启`joinValues`时,多值用`,`分隔,否则多值用数组 | +| search | `keyword: string` 检索的值 | 检索数据 | ### clear @@ -1589,3 +2023,67 @@ true false false [{label: 'A/B/C', value: 'a/b/c'},{label: 'A ] } ``` + +### search + +```schema: scope="body" +{ + "type": "form", + "api": "/api/mock2/form/saveForm", + "debug": true, + "body": [ + { + "type": "search-box", + "name": "keyword", + "className": "mb-4", + "style": { + "width": "100%" + }, + "onEvent": { + "change": { + "actions": [ + { + "componentId": "tree", + "groupType": "component", + "actionType": "search", + "args": { + "keyword": "${event.data.value}" + } + } + ] + } + } + }, + { + "type": "input-tree", + "id": "tree", + "name": "tree", + "label": false, + "options": [ + { + "label": "Folder A", + "value": 1, + "children": [ + { + "label": "file A", + "value": 2 + }, + { + "label": "file B", + "value": 3 + } + ] + }, + { + "label": "file C", + "value": 4 + }, + { + "label": "file D", + "value": 5 + } + ] + } + ] +} +``` diff --git a/docs/zh-CN/components/form/treeselect.md b/docs/zh-CN/components/form/treeselect.md index 546eaaed6..d403afa21 100755 --- a/docs/zh-CN/components/form/treeselect.md +++ b/docs/zh-CN/components/form/treeselect.md @@ -418,6 +418,7 @@ order: 60 | editConfirm (3.6.4 及以上版本) | `[name]: object` 组件的值
`item: object` 编辑的节点信息
`items: object[]`选项集合 | 编辑节点提交时触发 | | deleteConfirm (3.6.4 及以上版本) | `[name]: string` 组件的值
`item: object` 删除的节点信息
`items: object[]`选项集合 | 删除节点提交时触发 | | deferLoadFinished (3.6.4 及以上版本) | `[name]: object` 组件的值
`result: object` deferApi 懒加载远程请求成功后返回的数据
`items: object[]`选项集合 | 懒加载接口远程请求成功时触发 | +| itemClick (6.9.0 以上版本) | `value: any`表单项的值,值格式取决于具体配置
`item: object` 点击的节点信息 | 节点点击时触发 | | add(不推荐) | `[name]: object` 新增的节点信息
`items: object[]`选项集合(< 2.3.2 及以下版本 为`options`) | 新增节点提交时触发 | | edit(不推荐) | `[name]: object` 编辑的节点信息
`items: object[]`选项集合(< 2.3.2 及以下版本 为`options`) | 编辑节点提交时触发 | | delete(不推荐) | `[name]: object` 删除的节点信息
`items: object[]`选项集合(< 2.3.2 及以下版本 为`options`) | 删除节点提交时触发 | @@ -812,6 +813,60 @@ order: 60 } ``` +### itemClick + +```schema: scope="body" +{ + "type": "form", + "api": "/api/mock2/form/saveForm", + "debug": true, + "body": [ + { + "type": "input-tree", + "name": "tree", + "label": "Tree", + "nodeBehavior": [], + "onEvent": { + "itemClick": { + "actions": [ + { + "actionType": "toast", + "args": { + "msg": "${event.data.tree|json}" + } + } + ] + } + }, + "options": [ + { + "label": "Folder A", + "value": 1, + "children": [ + { + "label": "file A", + "value": 2 + }, + { + "label": "file B", + "value": 3 + } + ] + }, + { + "label": "file C", + "value": 4 + }, + { + "label": "file D", + "value": 5 + } + ] + } + ] +} +``` + ## 动作表 当前组件对外暴露以下特性动作,其他组件可以通过指定`actionType: 动作名称`、`componentId: 该组件id`来触发这些动作,动作配置可以通过`args: {动作配置项名称: xxx}`来配置具体的参数,详细请查看[事件动作](../../docs/concepts/event-action#触发其他组件的动作)。 diff --git a/docs/zh-CN/components/page.md b/docs/zh-CN/components/page.md index 85f996e0c..4f807b964 100755 --- a/docs/zh-CN/components/page.md +++ b/docs/zh-CN/components/page.md @@ -251,6 +251,30 @@ Page 默认将页面分为几个区域,分别是**内容区(`body`)**、** 通过配置 `asideSticky` 来开关,默认是开启状态。 +## aside 展示位置 + +通过配置 `asidePosition`,可以控制侧边栏的展示位置。 + +```schema +{ + "type": "page", + "asideResizor": true, + "asidePosition": "right", + "aside": [ + { + "type": "tpl", + "tpl": "这是侧边栏部分" + } + ], + "body": [ + { + "type": "tpl", + "tpl": "这是内容区" + } + ] +} +``` + ## 属性表 | 属性名 | 类型 | 默认值 | 说明 | @@ -264,6 +288,7 @@ Page 默认将页面分为几个区域,分别是**内容区(`body`)**、** | asideMinWidth | `number` | | 页面边栏区域的最小宽度 | | asideMaxWidth | `number` | | 页面边栏区域的最大宽度 | | asideSticky | `boolean` | true | 用来控制边栏固定与否 | +| asidePosition | `"left" \| "right"` | `"left"` | 页面边栏区域的位置 | | toolbar | [SchemaNode](../../docs/types/schemanode) | | 往页面的右上角加内容,需要注意的是,当有 title 时,该区域在右上角,没有时该区域在顶部 | | body | [SchemaNode](../../docs/types/schemanode) | | 往页面的内容区域加内容 | | className | `string` | | 外层 dom 类名 | diff --git a/docs/zh-CN/components/tabs.md b/docs/zh-CN/components/tabs.md index c7c6cbe6d..11e23d5ab 100755 --- a/docs/zh-CN/components/tabs.md +++ b/docs/zh-CN/components/tabs.md @@ -881,6 +881,7 @@ order: 68 | 事件名称 | 事件参数 | 说明 | | -------- | ------------------------------------ | ---------------- | | change | `value: number \| string` 选项卡索引 | 切换选项卡时触发 | +| delete | `value: number \| string` 选项卡索引 | 删除选项卡时触发 | ### change @@ -918,6 +919,43 @@ order: 68 } ``` +### delete + +```schema: scope="body" +{ + "type": "tabs", + "closable": true, + "mode": "line", + "tabs": [ + { + "title": "选项卡1", + "body": "选项卡内容1" + }, + { + "title": "选项卡2", + "body": "选项卡内容2" + }, + { + "title": "选项卡3", + "body": "选项卡内容3" + } + ], + "onEvent": { + "delete": { + "actions": [ + { + "actionType": "toast", + "args": { + "msgType": "info", + "msg": "删除选项卡${event.data.value}" + } + } + ] + } + } +} +``` + ## 动作表 当前组件对外暴露以下特性动作,其他组件可以通过指定`actionType: 动作名称`、`componentId: 该组件id`来触发这些动作,动作配置可以通过`args: {动作配置项名称: xxx}`来配置具体的参数,详细请查看[事件动作](../../docs/concepts/event-action#触发其他组件的动作)。 @@ -925,6 +963,7 @@ order: 68 | 动作名称 | 动作配置 | 说明 | | --------------- | ---------------------------------------- | ---------------- | | changeActiveKey | `activeKey: number \| string` 选项卡索引 | 激活指定的选项卡 | +| deleteTab | `deleteHash: string` 选项卡 hash | 删除指定的选项卡 | ### changeActiveKey @@ -1007,3 +1046,88 @@ order: 68 } ] ``` + +### deleteTab + +可以尝试点击下方按钮,实现选项卡删除。 + +```schema: scope="body" +[ + { + "type": "action", + "label": "删除选项卡1", + "className": "mr-3 mb-3", + "onEvent": { + "click": { + "actions": [ + { + "actionType": "deleteTab", + "componentId": "tabs-change-receiver", + "args": { + "deleteHash": "tab1" + } + } + ] + } + } + }, + { + "type": "action", + "label": "删除选项卡2", + "className": "mr-3 mb-3", + "onEvent": { + "click": { + "actions": [ + { + "actionType": "deleteTab", + "componentId": "tabs-change-receiver", + "args": { + "deleteHash": "tab2" + } + } + ] + } + } + }, + { + "type": "action", + "label": "删除选项卡3", + "className": "mr-3 mb-3", + "onEvent": { + "click": { + "actions": [ + { + "actionType": "deleteTab", + "componentId": "tabs-change-receiver", + "args": { + "deleteHash": "tab3" + } + } + ] + } + } + }, + { + "id": "tabs-change-receiver", + "type": "tabs", + "mode": "line", + "tabs": [ + { + "title": "选项卡1", + "hash": "tab1", + "body": "选项卡内容1" + }, + { + "title": "选项卡2", + "hash": "tab2", + "body": "选项卡内容2" + }, + { + "title": "选项卡3", + "hash": "tab3", + "body": "选项卡内容3" + } + ] + } +] +``` diff --git a/packages/amis-core/__tests__/renderers/wrapControl.test.tsx b/packages/amis-core/__tests__/renderers/wrapControl.test.tsx index 2fbfeebbf..a6b46cdeb 100644 --- a/packages/amis-core/__tests__/renderers/wrapControl.test.tsx +++ b/packages/amis-core/__tests__/renderers/wrapControl.test.tsx @@ -28,7 +28,7 @@ describe('constructor', () => { OriginComponent.defaultProps = { name: 'test' }; - const WrappedComponent = wrapControl(OriginComponent as any); + const WrappedComponent = wrapControl({}, OriginComponent as any); renderComponent(WrappedComponent, {$schema: {}}); expect(screen.getByText('FormItemStore')).toBeInTheDocument(); @@ -39,7 +39,7 @@ describe('constructor', () => { // 用于断言formItem已被注册 return
{props.formItem?.storeType}
; }; - const WrappedComponent = wrapControl(OriginComponent as any); + const WrappedComponent = wrapControl({}, OriginComponent as any); renderComponent(WrappedComponent, {$schema: {name: 'test'}}); expect(screen.getByText('FormItemStore')).toBeInTheDocument(); @@ -50,7 +50,7 @@ describe('constructor', () => { // 用于断言formItem已被注册 return
{props.formItem?.storeType}
; }; - const WrappedComponent = wrapControl(OriginComponent as any); + const WrappedComponent = wrapControl({}, OriginComponent as any); renderComponent(WrappedComponent, {$schema: {}}); expect(screen.queryByText('FormItemStore')).not.toBeInTheDocument(); diff --git a/packages/amis-core/package.json b/packages/amis-core/package.json index c20307c7a..858b68268 100644 --- a/packages/amis-core/package.json +++ b/packages/amis-core/package.json @@ -35,8 +35,8 @@ "typescript": "^4.6.4" }, "scripts": { - "build": "npm run clean-dist && NODE_ENV=production rollup -c ", - "build-esm": "npm run clean-dist && NODE_ENV=production rollup -c rollup.esm.config.js", + "build": "npm run clean-dist && cross-env NODE_ENV=production rollup -c ", + "build-esm": "npm run clean-dist && cross-env NODE_ENV=production rollup -c rollup.esm.config.js", "dev": "rollup -c -w", "test": "jest", "update-snapshot": "jest --updateSnapshot", @@ -49,7 +49,8 @@ ], "dependencies": { "@rc-component/mini-decimal": "^1.0.1", - "amis-formula": "^6.9.0", + "cross-env": "^7.0.3", + "amis-formula": "*", "classnames": "2.3.2", "file-saver": "^2.0.2", "hoist-non-react-statics": "^3.3.2", diff --git a/packages/amis-core/src/actions/Action.ts b/packages/amis-core/src/actions/Action.ts index d139500e6..2edc9a9d3 100644 --- a/packages/amis-core/src/actions/Action.ts +++ b/packages/amis-core/src/actions/Action.ts @@ -10,6 +10,7 @@ import {IContinueAction} from './ContinueAction'; import {ILoopAction} from './LoopAction'; import {IParallelAction} from './ParallelAction'; import {ISwitchAction} from './SwitchAction'; +import {debug} from '../utils/debug'; // 循环动作执行状态 export enum LoopStatus { @@ -343,6 +344,9 @@ export const runAction = async ( console.group?.(`run action ${action.actionType}`); console.debug(`[${action.actionType}] action args, data`, args, data); + debug('action', `run action ${action.actionType} with args`, args); + debug('action', `run action ${action.actionType} with data`, data); + let stopped = false; const actionResult = await actionInstrance.run( { diff --git a/packages/amis-core/src/index.tsx b/packages/amis-core/src/index.tsx index 99315d0c0..139e77d30 100644 --- a/packages/amis-core/src/index.tsx +++ b/packages/amis-core/src/index.tsx @@ -81,7 +81,11 @@ import type { FormControlProps, FormItemProps } from './renderers/Item'; -import {OptionsControl, registerOptionsControl} from './renderers/Options'; +import { + OptionsControl, + registerOptionsControl, + OptionsControlBase +} from './renderers/Options'; import type {OptionsControlProps} from './renderers/Options'; import type {FormOptionsControl} from './renderers/Options'; import {Schema} from './types'; @@ -217,6 +221,7 @@ export { ErrorBoundary, addSchemaFilter, OptionsControlProps, + OptionsControlBase, FormOptionsControl, FormControlProps, FormBaseControl, @@ -241,6 +246,7 @@ export { envOverwrite, getGlobalOptions, setGlobalOptions, + wrapFetcher, SchemaRenderer }; diff --git a/packages/amis-core/src/renderers/Item.tsx b/packages/amis-core/src/renderers/Item.tsx index 5a8d5e3c4..ab62a8da5 100644 --- a/packages/amis-core/src/renderers/Item.tsx +++ b/packages/amis-core/src/renderers/Item.tsx @@ -481,6 +481,7 @@ export interface FormItemBasicConfig extends Partial { renderDescription?: boolean; test?: RegExp | TestFunc; storeType?: string; + formItemStoreType?: string; validations?: string; strictMode?: boolean; @@ -2205,6 +2206,7 @@ export function asFormItem(config: Omit) { } return wrapControl( + config, hoistNonReactStatic( class extends FormItemWrap { static defaultProps: any = { diff --git a/packages/amis-core/src/renderers/Options.tsx b/packages/amis-core/src/renderers/Options.tsx index aec8770dc..7e1e8d7d4 100644 --- a/packages/amis-core/src/renderers/Options.tsx +++ b/packages/amis-core/src/renderers/Options.tsx @@ -287,10 +287,1109 @@ export const detectProps = itemDetectProps.concat([ 'hideSelected' ]); +export class OptionsControlBase< + T extends OptionsProps = OptionsProps, + S = any +> extends React.Component { + toDispose: Array<() => void> = []; + + input: any; + mounted = false; + + constructor(props: T, readonly config: OptionsConfig) { + super(props); + + const { + initFetch, + formItem, + source, + data, + setPrinstineValue, + defaultValue, + multiple, + joinValues, + extractValue, + addHook, + formInited, + valueField, + options, + value, + defaultCheckAll + } = props; + + if (!formItem) { + return; + } + + formItem.setOptions( + normalizeOptions(options, undefined, valueField), + this.changeOptionValue, + data + ); + + this.toDispose.push( + reaction( + () => JSON.stringify([formItem.loading, formItem.filteredOptions]), + () => this.mounted && this.forceUpdate() + ) + ); + + // 默认全选。这里会和默认值\回填值逻辑冲突,所以如果有配置source则不执行默认全选 + if ( + multiple && + defaultCheckAll && + formItem.filteredOptions?.length && + !source + ) { + this.defaultCheckAll(); + } + + let loadOptions: boolean = initFetch !== false; + let setInitValue: Function | null = null; + + if (joinValues === false && defaultValue) { + setInitValue = () => { + const selectedOptions = extractValue + ? formItem + .getSelectedOptions(value) + .map( + (selectedOption: Option) => + selectedOption[valueField || 'value'] + ) + : formItem.getSelectedOptions(value); + setPrinstineValue( + multiple ? selectedOptions.concat() : selectedOptions[0] + ); + }; + } + + if (loadOptions && config.autoLoadOptionsFromSource !== false) { + this.toDispose.push( + formInited || !addHook + ? formItem.addInitHook(async () => { + await this.reload(); + setInitValue?.(); + }) + : addHook(async (data: any) => { + await this.initOptions(data); + setInitValue?.(); + }, 'init') + ); + } else { + setInitValue?.(); + } + } + + componentDidMount() { + this.mounted = true; + this.normalizeValue(); + } + + shouldComponentUpdate(nextProps: OptionsProps) { + if (this.config.strictMode === false || nextProps.strictMode === false) { + return true; + } else if (nextProps.source || nextProps.autoComplete) { + return true; + } else if (nextProps.formItem?.expressionsInOptions) { + return true; + } else if (anyChanged(detectProps, this.props, nextProps)) { + return true; + } + + return false; + } + + componentDidUpdate(prevProps: OptionsProps) { + const props = this.props; + const formItem = props.formItem as IFormItemStore; + + if (!props.source && prevProps.options !== props.options && formItem) { + formItem.setOptions( + normalizeOptions(props.options || [], undefined, props.valueField), + this.changeOptionValue, + props.data + ); + this.normalizeValue(); + } else if ( + this.config.autoLoadOptionsFromSource !== false && + (props.formInited || typeof props.formInited === 'undefined') && + props.source && + formItem && + (prevProps.source !== props.source || prevProps.data !== props.data) + ) { + if (isPureVariable(props.source as string)) { + const prevOptions = resolveVariableAndFilter( + prevProps.source as string, + prevProps.data, + '| raw' + ); + const options = resolveVariableAndFilter( + props.source as string, + props.data, + '| raw' + ); + + if (prevOptions !== options) { + formItem.loadOptionsFromDataScope( + props.source as string, + props.data, + this.changeOptionValue + ); + + this.normalizeValue(); + } + } else if ( + isEffectiveApi(props.source, props.data) && + isApiOutdated( + prevProps.source, + props.source, + prevProps.data, + props.data + ) + ) { + formItem + .loadOptions( + props.source, + props.data, + undefined, + true, + this.changeOptionValue + ) + .then(() => this.normalizeValue()); + } + } + + if (prevProps.value !== props.value || formItem?.expressionsInOptions) { + formItem?.syncOptions(undefined, props.data); + } + } + + componentWillUnmount() { + this.props.removeHook?.(this.reload, 'init'); + this.mounted = false; + this.toDispose.forEach(fn => fn()); + this.toDispose = []; + } + + // 不推荐使用,缺少组件值 + async oldDispatchOptionEvent(eventName: string, eventData: any = '') { + const {dispatchEvent, options} = this.props; + const rendererEvent = await dispatchEvent( + eventName, + resolveEventData( + this.props, + {value: eventData, options, items: options} // 为了保持名字统一 + ) + ); + // 返回阻塞标识 + return !!rendererEvent?.prevented; + } + + async dispatchOptionEvent(eventName: string, eventData: any = '') { + const {dispatchEvent, options, value} = this.props; + const rendererEvent = await dispatchEvent( + eventName, + resolveEventData( + this.props, + {value, options, items: options, ...eventData} // 为了保持名字统一 + ) + ); + // 返回阻塞标识 + return !!rendererEvent?.prevented; + } + + doAction(action: ActionObject, data: object, throwErrors: boolean) { + const {resetValue, onChange} = this.props; + const actionType = action?.actionType as string; + + if (actionType === 'clear') { + onChange?.(''); + } else if (actionType === 'reset') { + onChange?.(resetValue ?? ''); + } + } + + // 当前值,跟设置预期的值格式不一致时自动转换。 + normalizeValue() { + const { + joinValues, + extractValue, + value, + multiple, + formItem, + valueField, + enableNodePath, + pathSeparator, + onChange + } = this.props; + + if (!formItem || joinValues !== false || !formItem.options.length) { + return; + } + + if ( + extractValue === false && + (typeof value === 'string' || typeof value === 'number') + ) { + const selectedOptions = formItem.getSelectedOptions(value); + onChange?.(multiple ? selectedOptions.concat() : selectedOptions[0]); + } else if ( + extractValue === true && + value && + !( + (Array.isArray(value) && + value.every( + (val: any) => typeof val === 'string' || typeof val === 'number' + )) || + typeof value === 'string' || + typeof value === 'number' + ) + ) { + const selectedOptions = formItem + .getSelectedOptions(value) + .map((selectedOption: Option) => selectedOption[valueField || 'value']); + onChange?.(multiple ? selectedOptions.concat() : selectedOptions[0]); + } + } + + getWrappedInstance() { + return this.input; + } + + @autobind + inputRef(ref: any) { + this.input = ref; + } + + @autobind + async handleToggle( + option: Option, + submitOnChange?: boolean, + changeImmediately?: boolean + ) { + const {onChange, formItem, value} = this.props; + + if (!formItem) { + return; + } + + let newValue: string | Array