Skip to content
Merged

Dev #70

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/integration/plugin-customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>; // 手动触发拦截
public async validateActionForm(actionId: string): Promise<boolean>; // 手动触发通过动作的业务表单校验
public async action(actionId: string, params?: any): Promise<any>; // 提交(自动先拦截)
```

- `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` 以保持「点击即拦截」语义。

**使用方式**:
Expand Down Expand Up @@ -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 模式

Expand Down
25 changes: 25 additions & 0 deletions packages/flow-approval-presenter/src/presenters/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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)) {
Expand Down
138 changes: 138 additions & 0 deletions packages/flow-approval-presenter/tests/action-form-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>) => {
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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -67,19 +67,25 @@ export const PassAction: React.FC<FlowActionProps> = (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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ export const ManualView: React.FC<ManualViewPlugin> = (props) => {
title={"请选择下一节点"}
open={visible}
onClose={() => {
setVisible(false)
setVisible(false);
// 取消时回调空值,通知父组件清空可选节点并卸载本组件;
// 否则组件常驻、visible 停留在 false,再次点击「通过」时弹窗无法重新弹出(issue #247)
props.onChange('');
}}
onOk={() => {
handlerOK();
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<ManualView options={options} onChange={() => undefined}/>);

expect(screen.getByText('请选择下一节点')).toBeTruthy();
expect(screen.getByText('下级节点')).toBeTruthy();
});

test('取消关闭弹窗时回调空值,父组件据此清空可选项,再次点击可通过时弹窗可重新打开', async () => {
const changes: string[] = [];
const {unmount} = render(
<ManualView options={options} onChange={(value) => changes.push(value)}/>
);

fireEvent.click(screen.getByText('取消'));

// 取消必须回调空值,父组件 PassAction 依赖该回调执行 setOptions([]) 卸载组件
await waitFor(() => expect(changes).toEqual(['']));

// 父组件清空后重新挂载(模拟再次提交后返回可选项),弹窗应重新可见
unmount();
render(<ManualView options={options} onChange={() => undefined}/>);
expect(screen.getByText('请选择下一节点')).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -95,22 +95,28 @@ export const PassAction: React.FC<FlowActionProps> = (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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ export const ManualView: React.FC<ManualViewPlugin> = (props) => {
title={"请选择下级节点"}
open={visible}
destroyOnHidden
onCancel={() => setVisible(false)}
onCancel={() => {
setVisible(false);
// 取消时回调空值,通知父组件清空可选节点并卸载本组件;
// 否则组件常驻、visible 停留在 false,再次点击「通过」时弹窗无法重新弹出(issue #247)
props.onChange('');
}}
onOk={() => {
handlerOK();
}}
Expand Down
57 changes: 57 additions & 0 deletions packages/flow-pc/flow-pc-approval/tests/manual-view.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ManualView options={options} onChange={() => undefined}/>);

expect(screen.getByText('请选择下级节点')).toBeTruthy();
expect(screen.getByText('下级节点')).toBeTruthy();
});

test('取消关闭弹窗时回调空值,父组件据此清空可选项,再次点击可通过时弹窗可重新打开', async () => {
const changes: string[] = [];
const {unmount} = render(
<ManualView options={options} onChange={(value) => 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(<ManualView options={options} onChange={() => undefined}/>);
expect(screen.getByText('请选择下级节点')).toBeTruthy();
});
});
Loading