This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FormModel } from '@flowgram.ai/form';
|
||||
import { FlowDocumentContainerModule } from '@flowgram.ai/document';
|
||||
import { loadPlugins, Playground, PlaygroundMockTools } from '@flowgram.ai/core';
|
||||
import { createHistoryPlugin, HistoryService } from '@flowgram.ai/history';
|
||||
|
||||
import { attachFormValuesChange } from '../src/utils';
|
||||
import { createHistoryNodePlugin } from '../src';
|
||||
|
||||
export const createContainer = () => {
|
||||
const container = PlaygroundMockTools.createContainer([FlowDocumentContainerModule]);
|
||||
|
||||
const formModel = new FormModel();
|
||||
|
||||
const playground = container.get(Playground);
|
||||
|
||||
loadPlugins([createHistoryPlugin({ enable: true }), createHistoryNodePlugin({})], container);
|
||||
playground.init();
|
||||
|
||||
const historyService = container.get(HistoryService);
|
||||
historyService.context.source = container;
|
||||
|
||||
attachFormValuesChange(formModel as any, { id: 1 } as any, historyService);
|
||||
|
||||
return {
|
||||
formModel,
|
||||
container,
|
||||
historyService,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest';
|
||||
import { FieldArrayModel, FormModel } from '@flowgram.ai/form';
|
||||
import { HistoryService } from '@flowgram.ai/history';
|
||||
|
||||
import * as utils from '../src/utils';
|
||||
import { createContainer } from './create-container';
|
||||
|
||||
function delay(timeout: number) {}
|
||||
|
||||
describe('form', () => {
|
||||
let formModel: FormModel;
|
||||
let historyService: HistoryService;
|
||||
|
||||
beforeEach(() => {
|
||||
const container = createContainer();
|
||||
formModel = container.formModel;
|
||||
historyService = container.historyService;
|
||||
vi.spyOn(utils, 'getFormModelV2').mockImplementation(() => formModel as any);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('object set', async () => {
|
||||
const obj = formModel.createField('obj');
|
||||
const fieldA = formModel.createField('obj.a');
|
||||
fieldA.value = 1;
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
fieldA.value = 2;
|
||||
obj.value = { a: 3 };
|
||||
await historyService.undo();
|
||||
expect(obj.value).toEqual({ a: 1 });
|
||||
await historyService.redo();
|
||||
expect(obj.value).toEqual({ a: 3 });
|
||||
});
|
||||
|
||||
it('array delete', async () => {
|
||||
formModel.createFieldArray('arr');
|
||||
const arrField = formModel.getField<FieldArrayModel>('arr')!;
|
||||
expect(arrField.value).toEqual(undefined);
|
||||
arrField.value = ['a', 'b', 'c'];
|
||||
expect(arrField.value).toEqual(['a', 'b', 'c']);
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
arrField.delete(1);
|
||||
expect(arrField.value).toEqual(['a', 'c']);
|
||||
await historyService.undo();
|
||||
expect(arrField.value).toEqual(['a', 'b', 'c']);
|
||||
await historyService.redo();
|
||||
expect(arrField.value).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('array append', async () => {
|
||||
formModel.createFieldArray('arr');
|
||||
const arrField = formModel.getField<FieldArrayModel>('arr')!;
|
||||
arrField.value = ['a'];
|
||||
expect(arrField.value).toEqual(['a']);
|
||||
await historyService.undo();
|
||||
expect(arrField.value).toEqual(undefined);
|
||||
await historyService.redo();
|
||||
expect(arrField.value).toEqual(['a']);
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
arrField.append('b');
|
||||
expect(arrField.value).toEqual(['a', 'b']);
|
||||
await historyService.undo();
|
||||
expect(arrField.value).toEqual(['a']);
|
||||
await historyService.redo();
|
||||
expect(arrField.value).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('array set', async () => {
|
||||
formModel.createFieldArray('arr');
|
||||
const arrField = formModel.getField<FieldArrayModel>('arr')!;
|
||||
arrField.value = ['a'];
|
||||
expect(arrField.value).toEqual(['a']);
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
formModel.setValueIn('arr.0', undefined);
|
||||
expect(arrField.value).toEqual([undefined]);
|
||||
await historyService.undo();
|
||||
expect(arrField.value).toEqual(['a']);
|
||||
await historyService.redo();
|
||||
expect(arrField.value).toEqual([undefined]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
|
||||
|
||||
module.exports = defineFlatConfig({
|
||||
preset: 'web',
|
||||
packageRoot: __dirname,
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@flowgram.ai/history-node-plugin",
|
||||
"version": "0.1.8",
|
||||
"homepage": "https://flowgram.ai/",
|
||||
"repository": "https://github.com/bytedance/flowgram.ai",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/esm/index.js",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/esm/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build:fast -- --dts-resolve",
|
||||
"build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
|
||||
"build:watch": "npm run build:fast -- --dts-resolve",
|
||||
"clean": "rimraf dist",
|
||||
"test": "vitest run",
|
||||
"test:cov": "vitest run --coverage",
|
||||
"ts-check": "tsc --noEmit",
|
||||
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@flowgram.ai/core": "workspace:*",
|
||||
"@flowgram.ai/document": "workspace:*",
|
||||
"@flowgram.ai/form": "workspace:*",
|
||||
"@flowgram.ai/form-core": "workspace:*",
|
||||
"@flowgram.ai/history": "workspace:*",
|
||||
"@flowgram.ai/node": "workspace:*",
|
||||
"inversify": "^6.0.1",
|
||||
"reflect-metadata": "~0.2.2",
|
||||
"lodash-es": "^4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@flowgram.ai/eslint-config": "workspace:*",
|
||||
"@flowgram.ai/ts-config": "workspace:*",
|
||||
"@types/bezier-js": "4.1.3",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^9.0.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"tsup": "^8.0.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FlowDocument } from '@flowgram.ai/document';
|
||||
import { bindContributions, definePluginCreator } from '@flowgram.ai/core';
|
||||
import { HistoryContainerModule, HistoryService, OperationContribution } from '@flowgram.ai/history';
|
||||
|
||||
import { attachFormValuesChange, getFormModelV2 } from './utils';
|
||||
import { HistoryNodeRegisters } from './history-node-registers';
|
||||
|
||||
/**
|
||||
* 表单历史插件
|
||||
*/
|
||||
export const createHistoryNodePlugin = definePluginCreator({
|
||||
onBind: ({ bind }) => {
|
||||
bindContributions(bind, HistoryNodeRegisters, [OperationContribution]);
|
||||
},
|
||||
onInit: (ctx, _opts) => {
|
||||
const document = ctx.get<FlowDocument>(FlowDocument);
|
||||
const historyService = ctx.get<HistoryService>(HistoryService);
|
||||
|
||||
document.onNodeCreate(({ node }) => {
|
||||
const formModel = getFormModelV2(node);
|
||||
|
||||
if (!formModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
attachFormValuesChange(formModel, node, historyService);
|
||||
});
|
||||
},
|
||||
containerModules: [HistoryContainerModule],
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { injectable } from 'inversify';
|
||||
import { type OperationContribution, type OperationRegistry } from '@flowgram.ai/history';
|
||||
|
||||
import { operationMetas } from './operation-metas';
|
||||
|
||||
/**
|
||||
* 表单历史操作
|
||||
*/
|
||||
@injectable()
|
||||
export class HistoryNodeRegisters implements OperationContribution {
|
||||
registerOperationMeta(operationRegistry: OperationRegistry): void {
|
||||
operationMetas.forEach(operationMeta => {
|
||||
operationRegistry.registerOperationMeta(operationMeta);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './create-history-node-plugin';
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { type OperationMeta } from '@flowgram.ai/history';
|
||||
import { FlowDocument } from '@flowgram.ai/document';
|
||||
import { type PluginContext } from '@flowgram.ai/core';
|
||||
|
||||
import { getFormModelV2, shouldChangeFormValuesMerge } from '../utils';
|
||||
import { ChangeFormValuesOperationValue, NodeOperationType } from '../types';
|
||||
|
||||
/**
|
||||
* 表单修改操作
|
||||
*/
|
||||
export const changeFormValueOperationMeta: OperationMeta<
|
||||
ChangeFormValuesOperationValue,
|
||||
PluginContext,
|
||||
void
|
||||
> = {
|
||||
type: NodeOperationType.changeFormValues,
|
||||
inverse: (op) => ({
|
||||
...op,
|
||||
value: {
|
||||
...op.value,
|
||||
value: op.value.oldValue,
|
||||
oldValue: op.value.value,
|
||||
},
|
||||
}),
|
||||
apply: ({ value: { value, path, id } }, ctx: PluginContext) => {
|
||||
const document = ctx.get<FlowDocument>(FlowDocument);
|
||||
const formModel = getFormModelV2(document.getNode(id));
|
||||
|
||||
if (!formModel) {
|
||||
return;
|
||||
}
|
||||
if (!path) {
|
||||
formModel.updateFormValues(value);
|
||||
} else {
|
||||
formModel.setValueIn(path, value);
|
||||
}
|
||||
},
|
||||
shouldMerge: shouldChangeFormValuesMerge as OperationMeta['shouldMerge'],
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { OperationMeta } from '@flowgram.ai/history';
|
||||
|
||||
import { changeFormValueOperationMeta } from './change-form-values';
|
||||
|
||||
export const operationMetas: OperationMeta[] = [changeFormValueOperationMeta];
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export enum NodeOperationType {
|
||||
changeFormValues = 'changeFormValues',
|
||||
}
|
||||
|
||||
export interface ChangeFormValuesOperationValue {
|
||||
id: string;
|
||||
path: string;
|
||||
value: unknown;
|
||||
oldValue: unknown;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { get } from 'lodash-es';
|
||||
import { FormModelV2, isFormModelV2 } from '@flowgram.ai/node';
|
||||
import { HistoryService, Operation } from '@flowgram.ai/history';
|
||||
import { StackOperation } from '@flowgram.ai/history';
|
||||
import { FlowNodeFormData } from '@flowgram.ai/form-core';
|
||||
import { FlowNodeEntity } from '@flowgram.ai/document';
|
||||
|
||||
import { ChangeFormValuesOperationValue, NodeOperationType } from '../types';
|
||||
|
||||
/**
|
||||
* 获取v2版本的formModel
|
||||
* @param node 节点
|
||||
* @returns
|
||||
*/
|
||||
export function getFormModelV2(node: FlowNodeEntity | undefined): FormModelV2 | undefined {
|
||||
if (!node) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const formModel = node?.getData(FlowNodeFormData)?.getFormModel<FormModelV2>();
|
||||
|
||||
if (!formModel || !isFormModelV2(formModel)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return formModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单合并策略
|
||||
* @param op 操作
|
||||
* @param prev 上一个操作
|
||||
* @param element 操作栈元素
|
||||
* @returns
|
||||
*/
|
||||
export function shouldChangeFormValuesMerge(
|
||||
op: Operation<ChangeFormValuesOperationValue | undefined>,
|
||||
prev: Operation<ChangeFormValuesOperationValue | undefined>,
|
||||
element: StackOperation
|
||||
) {
|
||||
if (!prev) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Date.now() - element.getTimestamp() < 500) {
|
||||
if (
|
||||
op.type === prev.type && // 相同类型
|
||||
op.value?.id === prev.value?.id && // 相同节点
|
||||
op.value?.path === prev.value?.path // 相同路径
|
||||
) {
|
||||
return {
|
||||
type: op.type,
|
||||
value: {
|
||||
...op.value,
|
||||
value: op.value?.value,
|
||||
oldValue: prev.value?.oldValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听表单值变化
|
||||
* @param formModel 表单模型
|
||||
* @param node 节点
|
||||
* @param historyService 历史服务
|
||||
*/
|
||||
export function attachFormValuesChange(
|
||||
formModel: FormModelV2,
|
||||
node: FlowNodeEntity,
|
||||
historyService: HistoryService
|
||||
) {
|
||||
formModel.onFormValuesChange((event) => {
|
||||
historyService.pushOperation(
|
||||
{
|
||||
type: NodeOperationType.changeFormValues,
|
||||
value: {
|
||||
id: node.id,
|
||||
path: event.name,
|
||||
value: event.name ? get(event.values, event.name) : event.values,
|
||||
oldValue: event.name ? get(event.prevValues, event.name) : event.prevValues,
|
||||
},
|
||||
},
|
||||
{ noApply: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
|
||||
"compilerOptions": {
|
||||
},
|
||||
"include": ["./src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
mockReset: false,
|
||||
environment: 'jsdom',
|
||||
setupFiles: [path.resolve(__dirname, './vitest.setup.ts')],
|
||||
include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'],
|
||||
exclude: [
|
||||
'**/__mocks__**',
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/lib/**', // lib 编译结果忽略掉
|
||||
'**/cypress/**',
|
||||
'**/.{idea,git,cache,output,temp}/**',
|
||||
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
Reference in New Issue
Block a user