From 9f85f7bb354ada3bfb325e7572919afd2eaac1b2 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Sat, 7 Nov 2026 16:08:50 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E9=80=9A=E8=BF=87=E5=8A=A8=E4=BD=9C?= =?UTF-8?q?=E5=9C=A8=E6=89=93=E5=BC=80=E5=AE=A1=E6=89=B9=E6=84=8F=E8=A7=81?= =?UTF-8?q?=E6=A1=86=E5=89=8D=E5=85=88=E6=A0=A1=E9=AA=8C=E8=A1=A8=E5=8D=95?= =?UTF-8?q?=20(#246)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FlowActionPresenter 新增 validateActionForm(actionId),复用通过动作的表单校验逻辑,非通过动作直接放行 - PC/移动端 pass.tsx 点击时先预校验,校验失败由表单展示错误且不再弹框 - 提交阶段保留原校验,拦截器仍在提交校验之前执行 - 补充单测与插件文档 Closes #246 --- docs/integration/plugin-customization.md | 5 +- .../src/presenters/action.ts | 25 ++++ .../tests/action-form-validation.test.ts | 138 ++++++++++++++++++ .../flow-approval/components/action/pass.tsx | 22 ++- .../flow-approval/components/action/pass.tsx | 22 ++- 5 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 packages/flow-approval-presenter/tests/action-form-validation.test.ts diff --git a/docs/integration/plugin-customization.md b/docs/integration/plugin-customization.md index 7c1c73b..2d3d9ca 100644 --- a/docs/integration/plugin-customization.md +++ b/docs/integration/plugin-customization.md @@ -517,10 +517,13 @@ export class ActionInterceptorManager { public addActionInterceptor(interceptor: ActionInterceptor): () => void; // 订阅 public removeActionInterceptor(interceptor: ActionInterceptor): void; // 取消 public async interceptAction(actionId: string, params?: any): Promise; // 手动触发拦截 +public async validateActionForm(actionId: string): Promise; // 手动触发通过动作的业务表单校验 public async action(actionId: string, params?: any): Promise; // 提交(自动先拦截) ``` - `action()` 提交前自动调用 `interceptAction(actionId, params)`,被拦截时返回 `{ success: false, intercepted: true }`。 +- `validateActionForm()` 仅对通过动作(PASS 及其 triggerType = PASS 的自定义动作)校验业务表单,其他动作直接返回 `true`;点击「通过」时框架会在打开审批意见框**之前**调用它,校验失败由表单展示错误且不弹框(issue-246)。自定义拦截器可用同一方法手动触发表单校验。 +- 拦截器与业务表单校验的相对顺序不变:拦截器仍在**提交阶段**的 `FormActionContext.validate()` 之前执行。 - 配置了 `triggerFrontEvent` 的自定义按钮不经过 `action()`,但框架默认路径**已在派发事件前自动执行拦截器**:PC 端 `custom.tsx` 与移动端 `dispatchApprovalFrontEvent`(`flow-approval-actions.tsx` 的 `handlerAction` / footer / 更多按钮)都先调用 `interceptAction`,全部放行后才 `emit`。仅当集成方用 ViewBindPlugin **完全替换**整个自定义按钮并自行处理 `emit` 时,才需在自定义组件内手动调用 `interceptAction` 以保持「点击即拦截」语义。 **使用方式**: @@ -584,7 +587,7 @@ const MyCustomFormView = () => { }; ``` -> **关键点**:`FlowActionPresenter.action()` 内,通过动作(PASS 及其 triggerType = PASS 的自定义动作)会先 `FormActionContext.validate()` 再提交;其他动作 `save()` 直接提交。组件卸载时务必 `removeAction()` 清理。 +> **关键点**:`FlowActionPresenter.action()` 内,通过动作(PASS 及其 triggerType = PASS 的自定义动作)会先 `FormActionContext.validate()` 再提交;其他动作 `save()` 直接提交。点击「通过」时,视图层还会在打开审批意见框前调用 `validateActionForm()` 做一次预校验,避免「先弹意见框、后提示表单必填」的交互错位(issue-246);提交阶段的校验继续保留,防止弹框期间表单被修改。组件卸载时务必 `removeAction()` 清理。 ### 3.7 FlowMockContext / mockKey — Mock 模式 diff --git a/packages/flow-approval-presenter/src/presenters/action.ts b/packages/flow-approval-presenter/src/presenters/action.ts index dd76afc..78792ee 100644 --- a/packages/flow-approval-presenter/src/presenters/action.ts +++ b/packages/flow-approval-presenter/src/presenters/action.ts @@ -332,6 +332,31 @@ export class FlowActionPresenter { return this.formActionContext.save(); } + /** + * 校验指定动作的业务表单。 + * + * 通过动作(PASS 及其 triggerType = PASS 的自定义动作)提交前会校验表单必填项, + * 与 {@link executeAction} 的提交校验保持一致;其他动作不校验表单字段 + * (服务端也仅对 PassAction 做表单校验),直接放行。 + * + * 视图层应在打开审批意见框之前调用本方法做预校验,避免「先弹框、填完意见后 + * 才提示表单必填」的交互错位(issue-246);自定义拦截器也可据此手动触发校验。 + * + * @param actionId 动作 ID + * @returns 校验通过(或该动作无需校验)返回 true;校验失败返回 false + */ + public async validateActionForm(actionId: string): Promise { + if (!this.isPassAction(actionId)) { + return true; + } + try { + await this.formActionContext.validate(); + return true; + } catch { + return false; + } + } + private async executeAction(actionId: string, params?: any) { let formData; if (this.isPassAction(actionId)) { diff --git a/packages/flow-approval-presenter/tests/action-form-validation.test.ts b/packages/flow-approval-presenter/tests/action-form-validation.test.ts new file mode 100644 index 0000000..b080824 --- /dev/null +++ b/packages/flow-approval-presenter/tests/action-form-validation.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, rs } from "@rstest/core"; +import { FlowActionPresenter } from "@/presenters/action"; +import { ApprovalState, FlowApprovalApi } from "@/typings"; +import { FlowAction, FormActionContext } from "@coding-flow/flow-types"; + +/** 构造一个最小可用的流程操作按钮对象 */ +const buildAction = ( + id: string, + type: 'PASS' | 'REJECT' | 'CUSTOM' = 'PASS', + triggerType?: string, +): FlowAction => ({ + id, + title: id, + type, + display: 'BUTTON', + enable: true, + triggerType, +} as unknown as FlowAction); + +/** 构造审批状态 */ +const buildState = (actions: FlowAction[]): ApprovalState => ({ + flow: { + recordId: 1, + actions, + mergeable: false, + }, +} as unknown as ApprovalState); + +/** 构造可观测调用情况的 API mock */ +const buildApi = (): FlowApprovalApi => ({ + create: rs.fn(async () => 100), + processNodes: rs.fn(async () => []), + action: rs.fn(async () => ({ success: true })), + revoke: rs.fn(async () => ({ success: true })), + urge: rs.fn(async () => ({ success: true })), +}); + +/** 注册一个业务表单,key 与审批表单视图保持一致 */ +const registerForm = (context: FormActionContext, validate: () => Promise) => { + context.addAction({ + save: () => ({ amount: 100 }), + key: () => 'view-form', + validate, + }); +}; + +const buildPresenter = ( + actions: FlowAction[], + formActionContext: FormActionContext, + api: FlowApprovalApi = buildApi(), +) => new FlowActionPresenter( + buildState(actions), + api, + formActionContext, + 'mockKey', + () => {}, +); + +describe.sequential('FlowActionPresenter 表单预校验(issue-246)', () => { + + it('通过动作校验通过时返回 true', async () => { + const formActionContext = new FormActionContext(); + const validate = rs.fn(async () => ({ amount: 100 })); + registerForm(formActionContext, validate); + const presenter = buildPresenter([buildAction('pass-1')], formActionContext); + + const result = await presenter.validateActionForm('pass-1'); + + expect(result).toBe(true); + expect(validate).toHaveBeenCalledTimes(1); + }); + + it('通过动作校验失败时返回 false,且不会触发提交', async () => { + const formActionContext = new FormActionContext(); + registerForm(formActionContext, async () => { + throw new Error('field required'); + }); + const api = buildApi(); + const presenter = buildPresenter([buildAction('pass-1')], formActionContext, api); + + const result = await presenter.validateActionForm('pass-1'); + + expect(result).toBe(false); + expect(api.action).not.toHaveBeenCalled(); + }); + + it('非通过动作不校验业务表单,直接放行', async () => { + const formActionContext = new FormActionContext(); + const validate = rs.fn(async () => { + throw new Error('field required'); + }); + registerForm(formActionContext, validate); + const presenter = buildPresenter([buildAction('reject-1', 'REJECT')], formActionContext); + + const result = await presenter.validateActionForm('reject-1'); + + expect(result).toBe(true); + expect(validate).not.toHaveBeenCalled(); + }); + + it('triggerType 为 PASS 的自定义动作同样参与校验', async () => { + const formActionContext = new FormActionContext(); + registerForm(formActionContext, async () => { + throw new Error('field required'); + }); + const presenter = buildPresenter([buildAction('custom-1', 'CUSTOM', 'PASS')], formActionContext); + + const result = await presenter.validateActionForm('custom-1'); + + expect(result).toBe(false); + }); + + it('未知动作无需校验,直接放行', async () => { + const formActionContext = new FormActionContext(); + const validate = rs.fn(async () => ({ amount: 100 })); + registerForm(formActionContext, validate); + const presenter = buildPresenter([buildAction('pass-1')], formActionContext); + + const result = await presenter.validateActionForm('not-exists'); + + expect(result).toBe(true); + expect(validate).not.toHaveBeenCalled(); + }); + + it('预校验通过后提交仍保留提交阶段校验', async () => { + const formActionContext = new FormActionContext(); + const validate = rs.fn(async () => ({ amount: 100 })); + registerForm(formActionContext, validate); + const api = buildApi(); + const presenter = buildPresenter([buildAction('pass-1')], formActionContext, api); + + expect(await presenter.validateActionForm('pass-1')).toBe(true); + await presenter.action('pass-1'); + + expect(validate).toHaveBeenCalledTimes(2); + expect(api.action).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/flow-mobile/flow-mobile-approval/src/components/flow-approval/components/action/pass.tsx b/packages/flow-mobile/flow-mobile-approval/src/components/flow-approval/components/action/pass.tsx index 32a6a5e..4fe5ea5 100644 --- a/packages/flow-mobile/flow-mobile-approval/src/components/flow-approval/components/action/pass.tsx +++ b/packages/flow-mobile/flow-mobile-approval/src/components/flow-approval/components/action/pass.tsx @@ -67,19 +67,25 @@ export const PassAction: React.FC = (props) => { * * 发起节点默认不弹窗直接提交;但节点策略或宿主策略要求意见必填时必须弹窗, * 否则用户没有输入入口,服务端会抛 advice required。 + * + * 弹框前先校验业务表单(issue-246):校验失败时由表单直接展示错误并终止, + * 避免用户填完审批意见后才被提示表单必填项缺失。 */ - const handleActionClick = () => { + const handleActionClick = async () => { + const formValid = await actionPresenter.validateActionForm(action.id); + if (!formValid) { + return; + } if (!isStartNode) { openModal(); return; } - advice.resolvePolicy().then((policy) => { - if (policy.required) { - openModal(); - } else { - handleSubmit(); - } - }); + const policy = await advice.resolvePolicy(); + if (policy.required) { + openModal(); + } else { + handleSubmit(); + } } React.useEffect(() => { diff --git a/packages/flow-pc/flow-pc-approval/src/components/flow-approval/components/action/pass.tsx b/packages/flow-pc/flow-pc-approval/src/components/flow-approval/components/action/pass.tsx index a169be6..cbbf1a9 100644 --- a/packages/flow-pc/flow-pc-approval/src/components/flow-approval/components/action/pass.tsx +++ b/packages/flow-pc/flow-pc-approval/src/components/flow-approval/components/action/pass.tsx @@ -95,22 +95,28 @@ export const PassAction: React.FC = (props) => { * * 发起节点默认不弹窗直接提交;但节点策略或宿主策略要求意见必填时必须弹窗, * 否则用户没有输入入口,服务端会抛 advice required。 + * + * 弹框前先校验业务表单(issue-246):校验失败时由表单直接展示错误并终止, + * 避免用户填完审批意见后才被提示表单必填项缺失。 */ - const handleActionClick = () => { + const handleActionClick = async () => { if (!props.onClickCheck?.(action.id)) { return; } + const formValid = await actionPresenter.validateActionForm(action.id); + if (!formValid) { + return; + } if (!isStartNode) { openModal(); return; } - advice.resolvePolicy().then((policy) => { - if (policy.required) { - openModal(); - } else { - handleSubmit(); - } - }); + const policy = await advice.resolvePolicy(); + if (policy.required) { + openModal(); + } else { + handleSubmit(); + } } React.useEffect(() => { From 956acc204664f9562f7b71febe37306af0722b75 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Sat, 7 Nov 2026 17:09:56 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BA=BA=E5=B7=A5?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E9=80=89=E6=8B=A9=E5=BC=B9=E7=AA=97=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E5=90=8E=E6=97=A0=E6=B3=95=E5=86=8D=E6=AC=A1=E5=BC=B9?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PC 与移动端 ManualView 取消关闭时回调空值,通知父组件 PassAction 清空可选节点并卸载组件,避免组件常驻导致 visible 停留在 false、再次点击通过时弹窗不再出现。 Closes #247 --- .../src/plugins/view/manual-view.tsx | 5 +- .../tests/manual-view.test.tsx | 41 +++++++++++++ .../src/plugins/view/manual-view.tsx | 7 ++- .../tests/manual-view.test.tsx | 57 +++++++++++++++++++ 4 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 packages/flow-mobile/flow-mobile-approval/tests/manual-view.test.tsx create mode 100644 packages/flow-pc/flow-pc-approval/tests/manual-view.test.tsx diff --git a/packages/flow-mobile/flow-mobile-approval/src/plugins/view/manual-view.tsx b/packages/flow-mobile/flow-mobile-approval/src/plugins/view/manual-view.tsx index a1249f4..257b46a 100644 --- a/packages/flow-mobile/flow-mobile-approval/src/plugins/view/manual-view.tsx +++ b/packages/flow-mobile/flow-mobile-approval/src/plugins/view/manual-view.tsx @@ -43,7 +43,10 @@ export const ManualView: React.FC = (props) => { title={"请选择下一节点"} open={visible} onClose={() => { - setVisible(false) + setVisible(false); + // 取消时回调空值,通知父组件清空可选节点并卸载本组件; + // 否则组件常驻、visible 停留在 false,再次点击「通过」时弹窗无法重新弹出(issue #247) + props.onChange(''); }} onOk={() => { handlerOK(); diff --git a/packages/flow-mobile/flow-mobile-approval/tests/manual-view.test.tsx b/packages/flow-mobile/flow-mobile-approval/tests/manual-view.test.tsx new file mode 100644 index 0000000..55657f3 --- /dev/null +++ b/packages/flow-mobile/flow-mobile-approval/tests/manual-view.test.tsx @@ -0,0 +1,41 @@ +import {afterEach, describe, expect, test} from "@rstest/core"; +import {cleanup, fireEvent, render, screen, waitFor} from "@testing-library/react"; +import {ManualView} from "@/plugins/view/manual-view"; +import {NodeOption} from "@coding-flow/flow-types"; + +const options = [ + {id: 'node-1', name: '分支A'}, + {id: 'node-2', name: '分支B'}, +] as NodeOption[]; + +describe.sequential('移动端人工节点选择弹窗(issue #247)', () => { + + afterEach(() => { + // 清理每一次测试产生的数据 + cleanup(); + }); + + test('首次展示可选下级节点', () => { + render( undefined}/>); + + expect(screen.getByText('请选择下一节点')).toBeTruthy(); + expect(screen.getByText('下级节点')).toBeTruthy(); + }); + + test('取消关闭弹窗时回调空值,父组件据此清空可选项,再次点击可通过时弹窗可重新打开', async () => { + const changes: string[] = []; + const {unmount} = render( + changes.push(value)}/> + ); + + fireEvent.click(screen.getByText('取消')); + + // 取消必须回调空值,父组件 PassAction 依赖该回调执行 setOptions([]) 卸载组件 + await waitFor(() => expect(changes).toEqual([''])); + + // 父组件清空后重新挂载(模拟再次提交后返回可选项),弹窗应重新可见 + unmount(); + render( undefined}/>); + expect(screen.getByText('请选择下一节点')).toBeTruthy(); + }); +}); diff --git a/packages/flow-pc/flow-pc-approval/src/plugins/view/manual-view.tsx b/packages/flow-pc/flow-pc-approval/src/plugins/view/manual-view.tsx index 7e46afd..d09297a 100644 --- a/packages/flow-pc/flow-pc-approval/src/plugins/view/manual-view.tsx +++ b/packages/flow-pc/flow-pc-approval/src/plugins/view/manual-view.tsx @@ -44,7 +44,12 @@ export const ManualView: React.FC = (props) => { title={"请选择下级节点"} open={visible} destroyOnHidden - onCancel={() => setVisible(false)} + onCancel={() => { + setVisible(false); + // 取消时回调空值,通知父组件清空可选节点并卸载本组件; + // 否则组件常驻、visible 停留在 false,再次点击「通过」时弹窗无法重新弹出(issue #247) + props.onChange(''); + }} onOk={() => { handlerOK(); }} diff --git a/packages/flow-pc/flow-pc-approval/tests/manual-view.test.tsx b/packages/flow-pc/flow-pc-approval/tests/manual-view.test.tsx new file mode 100644 index 0000000..9376601 --- /dev/null +++ b/packages/flow-pc/flow-pc-approval/tests/manual-view.test.tsx @@ -0,0 +1,57 @@ +import {afterEach, describe, expect, test} from "@rstest/core"; +import {cleanup, fireEvent, render, screen, waitFor} from "@testing-library/react"; +import {ManualView} from "@/plugins/view/manual-view"; +import {NodeOption} from "@coding-flow/flow-types"; + +// antd 的响应式监听在 jsdom 下缺少 matchMedia,这里补齐以免渲染 Select 时报错 +if (!window.matchMedia) { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +} + +const options = [ + {id: 'node-1', name: '分支A'}, + {id: 'node-2', name: '分支B'}, +] as NodeOption[]; + +describe.sequential('PC 人工节点选择弹窗(issue #247)', () => { + + afterEach(() => { + // 清理每一次测试产生的数据 + cleanup(); + }); + + test('首次展示可选下级节点', () => { + render( undefined}/>); + + expect(screen.getByText('请选择下级节点')).toBeTruthy(); + expect(screen.getByText('下级节点')).toBeTruthy(); + }); + + test('取消关闭弹窗时回调空值,父组件据此清空可选项,再次点击可通过时弹窗可重新打开', async () => { + const changes: string[] = []; + const {unmount} = render( + changes.push(value)}/> + ); + + const closeButton = document.querySelector('.ant-modal-close') as HTMLElement; + expect(closeButton).toBeTruthy(); + fireEvent.click(closeButton); + + // 取消必须回调空值,父组件 PassAction 依赖该回调执行 setOptions([]) 卸载组件 + await waitFor(() => expect(changes).toEqual([''])); + + // 父组件清空后重新挂载(模拟再次提交后返回可选项),弹窗应重新可见 + unmount(); + render( undefined}/>); + expect(screen.getByText('请选择下级节点')).toBeTruthy(); + }); +});