This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { FormPathService } from '../src/form/services/form-path-service';
|
||||
|
||||
describe('FormPathService', () => {
|
||||
it('should parse array item path correctly', () => {
|
||||
const path = 'a/b/2/description';
|
||||
const result = FormPathService.parseArrayItemPath(path);
|
||||
|
||||
expect(result).toEqual({
|
||||
itemIndex: 2,
|
||||
arrayPath: 'a/b/[]',
|
||||
itemMetaPath: 'a/b/[]/description',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-numeric item index correctly', () => {
|
||||
const path = 'a/b';
|
||||
const result = FormPathService.parseArrayItemPath(path);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty path correctly', () => {
|
||||
const path = '';
|
||||
const result = FormPathService.parseArrayItemPath(path);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('form test', () => {
|
||||
it('form test', () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('form test');
|
||||
});
|
||||
});
|
||||
@@ -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,61 @@
|
||||
{
|
||||
"name": "@flowgram.ai/form-core",
|
||||
"version": "0.1.8",
|
||||
"description": "automation form core",
|
||||
"keywords": [
|
||||
"flow",
|
||||
"engine"
|
||||
],
|
||||
"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/utils": "workspace:*",
|
||||
"inversify": "^6.0.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"reflect-metadata": "~0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@flowgram.ai/eslint-config": "workspace:*",
|
||||
"@flowgram.ai/ts-config": "workspace:*",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^9.0.0",
|
||||
"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,12 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { NodeContainerModule } from '../node/node-container-module';
|
||||
import { FormCoreContainerModule } from '../form';
|
||||
import { ErrorContainerModule } from '../error';
|
||||
|
||||
export function createNodeContainerModules() {
|
||||
return [NodeContainerModule, FormCoreContainerModule, ErrorContainerModule];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { EntityDataRegistry } from '@flowgram.ai/core';
|
||||
|
||||
import { FlowNodeFormData } from '../form';
|
||||
import { FlowNodeErrorData } from '../error';
|
||||
|
||||
export function createNodeEntityDatas(): EntityDataRegistry[] {
|
||||
return [FlowNodeFormData, FlowNodeErrorData];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './create-node-container-modules';
|
||||
export * from './node-render';
|
||||
export * from './node-material-client';
|
||||
export * from './create-node-entity-datas';
|
||||
export * from '../form/client';
|
||||
export * from '../error/client';
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { MATERIAL_KEY } from '../node/core-materials';
|
||||
import { NodeManager, NodePlaceholderRender, Render } from '../node';
|
||||
|
||||
export function registerNodeErrorRender(nodeManager: NodeManager, render: Render): void {
|
||||
nodeManager.registerMaterialRender(MATERIAL_KEY.NODE_ERROR_RENDER, render);
|
||||
}
|
||||
|
||||
export function registerNodePlaceholderRender(
|
||||
nodeManager: NodeManager,
|
||||
render: NodePlaceholderRender,
|
||||
): void {
|
||||
nodeManager.registerMaterialRender(MATERIAL_KEY.NODE_PLACEHOLDER_RENDER, render);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useEffect } from 'react';
|
||||
|
||||
import { PlaygroundContext, useRefresh, useService, PluginContext } from '@flowgram.ai/core';
|
||||
|
||||
import { NodeEngineReactContext } from '../node-react/context/node-engine-react-context';
|
||||
import { useNodeEngineContext } from '../node-react';
|
||||
import { NodeManager } from '../node/node-manager';
|
||||
import { PLUGIN_KEY } from '../node/core-plugins';
|
||||
import { MATERIAL_KEY, type NodeRenderProps } from '../node';
|
||||
import { getFormModel, isNodeFormReady } from '../form';
|
||||
import { FlowNodeErrorData } from '../error/flow-node-error-data';
|
||||
import { getNodeError } from '../error';
|
||||
|
||||
const PureNodeRender = ({ node }: NodeRenderProps) => {
|
||||
const refresh = useRefresh();
|
||||
const nodeErrorData = node.getData<FlowNodeErrorData>(FlowNodeErrorData);
|
||||
const formModel = getFormModel(node);
|
||||
const isNodeError = !!getNodeError(node);
|
||||
const isFormReady = isNodeFormReady(node);
|
||||
const playgroundContext = useService<PlaygroundContext>(PlaygroundContext);
|
||||
const clientContext = useService<PluginContext>(PluginContext);
|
||||
const nodeManager = useService<NodeManager>(NodeManager);
|
||||
const nodeFormRender = nodeManager.getPluginRender(PLUGIN_KEY.FORM);
|
||||
const nodeErrorRender = nodeManager.getPluginRender(PLUGIN_KEY.ERROR);
|
||||
const nodePlaceholderRender = nodeManager.getMaterialRender(MATERIAL_KEY.NODE_PLACEHOLDER_RENDER);
|
||||
|
||||
const nodeEngineContext = useNodeEngineContext();
|
||||
|
||||
useEffect(() => {
|
||||
const errorDisposable = nodeErrorData.onDataChange(() => {
|
||||
refresh();
|
||||
});
|
||||
const formDisposable = formModel.onInitialized(() => {
|
||||
refresh();
|
||||
});
|
||||
return () => {
|
||||
errorDisposable.dispose();
|
||||
formDisposable.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const renderContent = useCallback(() => {
|
||||
if (isNodeError) {
|
||||
return nodeErrorRender!({ node, playgroundContext, clientContext });
|
||||
}
|
||||
if (!formModel.formMeta) {
|
||||
return null;
|
||||
}
|
||||
if (isFormReady) {
|
||||
return nodeFormRender!({ node, playgroundContext, clientContext });
|
||||
}
|
||||
return nodePlaceholderRender?.({ node, playgroundContext }) || null;
|
||||
}, [
|
||||
isNodeError,
|
||||
isFormReady,
|
||||
nodeErrorRender,
|
||||
nodeFormRender,
|
||||
nodePlaceholderRender,
|
||||
node,
|
||||
playgroundContext,
|
||||
]);
|
||||
|
||||
return (
|
||||
<NodeEngineReactContext.Provider value={nodeEngineContext.json}>
|
||||
{nodeManager.nodeRenderHoc(renderContent)()}
|
||||
</NodeEngineReactContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const NodeRender = memo(PureNodeRender);
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FlowNodeEntity } from '@flowgram.ai/document';
|
||||
|
||||
import { FlowNodeErrorData } from './flow-node-error-data';
|
||||
|
||||
export function getNodeError(node: FlowNodeEntity) {
|
||||
return node.getData<FlowNodeErrorData>(FlowNodeErrorData).getError();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { ContainerModule } from 'inversify';
|
||||
import { bindContributions } from '@flowgram.ai/utils';
|
||||
|
||||
import { NodeContribution } from '../node';
|
||||
import { ErrorNodeContribution } from './error-node-contribution';
|
||||
|
||||
export const ErrorContainerModule = new ContainerModule(bind => {
|
||||
bindContributions(bind, ErrorNodeContribution, [NodeContribution]);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { injectable } from 'inversify';
|
||||
|
||||
import { NodeContribution } from '../node';
|
||||
import { NodeManager, PLUGIN_KEY } from '../node';
|
||||
import { errorPluginRender } from './renders';
|
||||
|
||||
@injectable()
|
||||
export class ErrorNodeContribution implements NodeContribution {
|
||||
onRegister(nodeManager: NodeManager) {
|
||||
nodeManager.registerPluginRender(PLUGIN_KEY.ERROR, errorPluginRender);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { EntityData } from '@flowgram.ai/core';
|
||||
|
||||
export interface ErrorData {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class FlowNodeErrorData extends EntityData {
|
||||
static type = 'FlowNodeErrorData';
|
||||
|
||||
getDefaultData(): ErrorData {
|
||||
return { error: null };
|
||||
}
|
||||
|
||||
setError(e: ErrorData['error']) {
|
||||
this.update({ error: e });
|
||||
}
|
||||
|
||||
getError(): Error {
|
||||
return this.data.error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './flow-node-error-data';
|
||||
export * from './types';
|
||||
export * from './error-container-module';
|
||||
export * from './client';
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { NodeErrorRenderProps } from '../types';
|
||||
|
||||
const ERROR_STYLE = {
|
||||
color: '#f54a45',
|
||||
};
|
||||
|
||||
export const defaultErrorRender = ({ error }: NodeErrorRenderProps) => (
|
||||
<div style={ERROR_STYLE}>{error.message}</div>
|
||||
);
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
|
||||
import { FlowNodeEntity } from '@flowgram.ai/document';
|
||||
import { PlaygroundContext, useRefresh, useService, PluginContext } from '@flowgram.ai/core';
|
||||
|
||||
import { FlowNodeErrorData } from '../flow-node-error-data';
|
||||
import { MATERIAL_KEY, NodeManager, NodePluginRender } from '../../node';
|
||||
import { defaultErrorRender } from './default-error-render';
|
||||
|
||||
interface NodeRenderProps {
|
||||
node: FlowNodeEntity;
|
||||
playgroundContext: PlaygroundContext;
|
||||
clientContext: PluginContext;
|
||||
}
|
||||
|
||||
export const ErrorRender = ({ node, playgroundContext, clientContext }: NodeRenderProps) => {
|
||||
const refresh = useRefresh();
|
||||
const nodeErrorData = node.getData<FlowNodeErrorData>(FlowNodeErrorData);
|
||||
const nodeError = nodeErrorData.getError();
|
||||
const nodeManager = useService<NodeManager>(NodeManager);
|
||||
const nodeErrorRender = nodeManager.getMaterialRender(MATERIAL_KEY.NODE_ERROR_RENDER);
|
||||
|
||||
const renderError = useCallback(() => {
|
||||
if (!nodeErrorRender) {
|
||||
return defaultErrorRender({
|
||||
error: nodeError,
|
||||
context: { node, playgroundContext, clientContext },
|
||||
});
|
||||
}
|
||||
return nodeErrorRender({
|
||||
error: nodeError,
|
||||
context: { node, playgroundContext, clientContext },
|
||||
});
|
||||
}, [nodeError, node, playgroundContext, clientContext]);
|
||||
|
||||
useEffect(() => {
|
||||
const disposable = nodeErrorData.onDataChange(() => {
|
||||
refresh();
|
||||
});
|
||||
return () => {
|
||||
disposable.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return nodeError ? renderError() : null;
|
||||
};
|
||||
|
||||
export const errorPluginRender: NodePluginRender = (props) => <ErrorRender {...props} />;
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './error-render';
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { NodeContext, Render } from '../node';
|
||||
|
||||
export interface NodeErrorRenderProps {
|
||||
error: Error;
|
||||
context: NodeContext;
|
||||
}
|
||||
|
||||
export type NodeErrorRender = Render<NodeErrorRenderProps>;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FormItemAbility } from '../../models/form-item-ability';
|
||||
|
||||
export class DecoratorAbility implements FormItemAbility {
|
||||
static readonly type = 'decorator';
|
||||
|
||||
get type(): string {
|
||||
return DecoratorAbility.type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './decorator-ability';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FormItemContext, FormItemFeedback } from '../../types';
|
||||
import { SetterOrDecoratorContext } from '../../abilities/setter-ability';
|
||||
|
||||
export interface DecoratorAbilityOptions {
|
||||
/**
|
||||
* 已注册的decorator的唯一标识
|
||||
*/
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface DecoratorComponentProps<CustomOptions = any>
|
||||
extends FormItemFeedback,
|
||||
FormItemContext {
|
||||
readonly: boolean;
|
||||
children?: any;
|
||||
options: DecoratorAbilityOptions & CustomOptions;
|
||||
context: SetterOrDecoratorContext;
|
||||
}
|
||||
|
||||
export interface DecoratorExtension {
|
||||
key: string;
|
||||
component: (props: DecoratorComponentProps) => any;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FormItemAbility } from '../../models/form-item-ability';
|
||||
|
||||
export class DefaultAbility implements FormItemAbility {
|
||||
static readonly type = 'default';
|
||||
|
||||
get type(): string {
|
||||
return DefaultAbility.type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './default-ability';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FormItemContext, FormItemMaterialContext } from '../..';
|
||||
|
||||
export interface GetDefaultValueProps extends FormItemContext {
|
||||
options: DefaultAbilityOptions;
|
||||
context: FormItemMaterialContext;
|
||||
}
|
||||
|
||||
export interface DefaultAbilityOptions<T = any> {
|
||||
getDefaultValue: (params: GetDefaultValueProps) => T;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FormItemAbility } from '../../models/form-item-ability';
|
||||
|
||||
export class EffectAbility implements FormItemAbility {
|
||||
static readonly type = 'effect';
|
||||
|
||||
get type(): string {
|
||||
return EffectAbility.type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './effect-ability';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FormItemContext, FormItemEventName } from '../../types';
|
||||
import { type FormItemMaterialContext } from '../../models/form-item-material-context';
|
||||
|
||||
export interface EffectAbilityOptions {
|
||||
/**
|
||||
* 已注册的effect 唯一标识
|
||||
*/
|
||||
key?: string;
|
||||
/**
|
||||
* 触发 effect 的事件
|
||||
*/
|
||||
event?: FormItemEventName;
|
||||
/**
|
||||
* 如果不使用已经注册的effect, 也支持直接写effect函数
|
||||
*/
|
||||
effect?: EffectFunction;
|
||||
}
|
||||
|
||||
export interface EffectEvent {
|
||||
target: any & { value: any };
|
||||
currentTarget: any;
|
||||
type: FormItemEventName;
|
||||
}
|
||||
|
||||
export interface EffectProps<CustomOptions = any, Event = EffectEvent> extends FormItemContext {
|
||||
event: Event;
|
||||
options: EffectAbilityOptions & CustomOptions;
|
||||
context: FormItemMaterialContext;
|
||||
}
|
||||
|
||||
export type EffectFunction = (props: EffectProps) => void;
|
||||
|
||||
export interface EffectExtension {
|
||||
key: string;
|
||||
effect: EffectFunction;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './setter-ability';
|
||||
export * from './decorator-ability';
|
||||
export * from './visibility-ability';
|
||||
export * from './effect-ability';
|
||||
export * from './default-ability';
|
||||
export * from './validation-ability';
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './setter-ability';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FormItemAbility } from '../../models/form-item-ability';
|
||||
|
||||
export class SetterAbility implements FormItemAbility {
|
||||
static readonly type = 'setter';
|
||||
|
||||
get type(): string {
|
||||
return SetterAbility.type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { FormItemContext, FormItemFeedback } from '../../types';
|
||||
import { type FormItemMaterialContext } from '../../models/form-item-material-context';
|
||||
import { ValidatorFunction } from '../../abilities/validation-ability';
|
||||
|
||||
export interface SetterAbilityOptions {
|
||||
/**
|
||||
* 已注册的setter的唯一标识
|
||||
*/
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter context 是 FormItemMaterialContext 的外观
|
||||
* 基于外观设计模式设计,屏蔽了FormItemMaterialContext中一些setter不可见的接口
|
||||
* readonly: 对于setter 已经放在props 根级别,所以在这里屏蔽,防止干扰
|
||||
* getFormItemValueByPath: setter需通过表单联动方式获取其他表单项的值,不推荐是用这个方法,所以屏蔽
|
||||
*/
|
||||
export type SetterOrDecoratorContext = Omit<
|
||||
FormItemMaterialContext,
|
||||
'getFormItemValueByPath' | 'readonly'
|
||||
>;
|
||||
|
||||
export interface SetterComponentProps<T = any, CustomOptions = any>
|
||||
extends FormItemFeedback,
|
||||
FormItemContext {
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
/**
|
||||
* 节点引擎全局readonly
|
||||
*/
|
||||
readonly: boolean;
|
||||
children?: any;
|
||||
options: SetterAbilityOptions & CustomOptions;
|
||||
context: SetterOrDecoratorContext;
|
||||
}
|
||||
|
||||
export interface SetterExtension {
|
||||
key: string;
|
||||
component: (props: SetterComponentProps) => any;
|
||||
validator?: ValidatorFunction;
|
||||
}
|
||||
|
||||
export type SetterHoc = (
|
||||
Component: React.JSXElementConstructor<SetterComponentProps>
|
||||
) => React.JSXElementConstructor<SetterComponentProps>;
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
// export type ValidatorFormats =
|
||||
// | 'url'
|
||||
// | 'email'
|
||||
// | 'ipv6'
|
||||
// | 'ipv4'
|
||||
// | 'number'
|
||||
// | 'integer'
|
||||
// | 'idcard'
|
||||
// | 'qq'
|
||||
// | 'phone'
|
||||
// | 'money'
|
||||
// | 'zh'
|
||||
// | 'date'
|
||||
// | 'zip'
|
||||
// | (string & {});
|
||||
//
|
||||
// export interface IValidatorRules<Context = any> {
|
||||
// format?: ValidatorFormats;
|
||||
// validator?: ValidatorFunction<Context>;
|
||||
// required?: boolean;
|
||||
// pattern?: RegExp | string;
|
||||
// max?: number;
|
||||
// maximum?: number;
|
||||
// maxItems?: number;
|
||||
// minItems?: number;
|
||||
// maxLength?: number;
|
||||
// minLength?: number;
|
||||
// exclusiveMaximum?: number;
|
||||
// exclusiveMinimum?: number;
|
||||
// minimum?: number;
|
||||
// min?: number;
|
||||
// len?: number;
|
||||
// whitespace?: boolean;
|
||||
// enum?: any[];
|
||||
// const?: any;
|
||||
// multipleOf?: number;
|
||||
// uniqueItems?: boolean;
|
||||
// maxProperties?: number;
|
||||
// minProperties?: number;
|
||||
// message?: string;
|
||||
//
|
||||
// [key: string]: any;
|
||||
// }
|
||||
//
|
||||
// export interface IValidateResult {
|
||||
// type: 'error' | 'warning';
|
||||
// message: string;
|
||||
// }
|
||||
//
|
||||
// export const isValidateResult = (obj: any): obj is IValidateResult =>
|
||||
// Boolean(obj.type) && Boolean(obj.message);
|
||||
//
|
||||
// export type ValidatorFunctionResponse =
|
||||
// | null
|
||||
// | void
|
||||
// | undefined
|
||||
// | string
|
||||
// | boolean
|
||||
// | IValidateResult;
|
||||
//
|
||||
// export type ValidatorFunction<Context = any> = (
|
||||
// value: any,
|
||||
// ctx: Context,
|
||||
// ) => ValidatorFunctionResponse | Promise<ValidatorFunctionResponse>;
|
||||
//
|
||||
// export type Validator<Context = any> =
|
||||
// | ValidatorFormats
|
||||
// | ValidatorFunction<Context>
|
||||
// | IValidatorRules;
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './validation-ability';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FormItemMaterialContext } from '../../models';
|
||||
|
||||
// 这里参照了rehaje 的validator function 返回格式
|
||||
export interface IValidateResult {
|
||||
type: 'error' | 'warning';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type ValidatorFunctionResponse =
|
||||
| null
|
||||
| void
|
||||
| undefined
|
||||
| string
|
||||
| boolean
|
||||
| IValidateResult;
|
||||
|
||||
export interface ValidationAbilityOptions {
|
||||
/**
|
||||
* 已注册的validator唯一标识
|
||||
*/
|
||||
key?: string;
|
||||
/**
|
||||
* 不使用已注册的validator 也支持在options中直接写validator
|
||||
*/
|
||||
validator?: ValidatorFunction;
|
||||
}
|
||||
|
||||
export interface ValidatorProps<T = any, CustomOptions = any> {
|
||||
value: T;
|
||||
options: ValidationAbilityOptions & CustomOptions;
|
||||
context: FormItemMaterialContext;
|
||||
}
|
||||
|
||||
export type ValidatorFunction = (props: ValidatorProps) => ValidatorFunctionResponse;
|
||||
|
||||
export interface ValidationExtension {
|
||||
key: string;
|
||||
validator: ValidatorFunction;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FormItemAbility } from '../../models/form-item-ability';
|
||||
|
||||
export class ValidationAbility implements FormItemAbility {
|
||||
static readonly type = 'validation';
|
||||
|
||||
get type(): string {
|
||||
return ValidationAbility.type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './visibility-ability';
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FormItemAbility } from '../../models/form-item-ability';
|
||||
|
||||
export interface VisibilityAbilityOptions {
|
||||
/**
|
||||
* 是否隐藏
|
||||
*/
|
||||
hidden: string | boolean;
|
||||
/**
|
||||
* 隐藏是否要清空表单值, 默认为false
|
||||
*/
|
||||
clearWhenHidden?: boolean;
|
||||
}
|
||||
|
||||
export class VisibilityAbility implements FormItemAbility {
|
||||
static readonly type = 'visibility';
|
||||
|
||||
get type(): string {
|
||||
return VisibilityAbility.type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FlowNodeEntity } from '@flowgram.ai/document';
|
||||
|
||||
import { FlowNodeFormData } from '../flow-node-form-data';
|
||||
import { FormModel } from '..';
|
||||
|
||||
export function isNodeFormReady(node: FlowNodeEntity) {
|
||||
return node.getData<FlowNodeFormData>(FlowNodeFormData).getFormModel<FormModel>().initialized;
|
||||
}
|
||||
|
||||
export function getFormModel(node: FlowNodeEntity) {
|
||||
return node.getData<FlowNodeFormData>(FlowNodeFormData).formModel;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { Disposable, Emitter } from '@flowgram.ai/utils';
|
||||
import { FlowNodeEntity } from '@flowgram.ai/document';
|
||||
import { EntityData } from '@flowgram.ai/core';
|
||||
|
||||
import { FlowNodeErrorData } from '../error';
|
||||
import { FormMetaOrFormMetaGenerator } from './types';
|
||||
import { FormModel, type FormModelFactory } from './models';
|
||||
|
||||
interface Options {
|
||||
formModelFactory: FormModelFactory;
|
||||
}
|
||||
|
||||
export interface DetailChangeEvent {
|
||||
path: string;
|
||||
oldValue: any;
|
||||
value: any;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
export interface OnFormValuesChangePayload {
|
||||
values: any;
|
||||
prevValues: any;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class FlowNodeFormData extends EntityData {
|
||||
static type = 'FlowNodeEntityFormData';
|
||||
|
||||
readonly formModel: FormModel;
|
||||
|
||||
protected flowNodeEntity: FlowNodeEntity;
|
||||
|
||||
/**
|
||||
* @deprecated rehaje 版表单form Values change 事件
|
||||
* @protected
|
||||
*/
|
||||
protected onDetailChangeEmitter = new Emitter<DetailChangeEvent>();
|
||||
|
||||
/**
|
||||
* @deprecated 该方法为旧版引擎(rehaje)表单数据变更事件, 新版节点引擎请使用
|
||||
* this.getFormModel<FormModelV2>().onFormValuesChange.
|
||||
* @protected
|
||||
*/
|
||||
readonly onDetailChange = this.onDetailChangeEmitter.event;
|
||||
|
||||
constructor(entity: FlowNodeEntity, opts: Options) {
|
||||
super(entity);
|
||||
|
||||
this.flowNodeEntity = entity;
|
||||
this.formModel = opts.formModelFactory(entity);
|
||||
|
||||
this.toDispose.push(this.onDetailChangeEmitter);
|
||||
|
||||
this.toDispose.push(
|
||||
Disposable.create(() => {
|
||||
this.formModel.dispose();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getFormModel<TFormModel>(): TFormModel {
|
||||
// @ts-ignore
|
||||
return this.formModel as TFormModel;
|
||||
}
|
||||
|
||||
getDefaultData(): any {
|
||||
return {};
|
||||
}
|
||||
|
||||
createForm(formMetaOrFormMetaGenerator: any, initialValue?: any): void {
|
||||
const errorData = this.flowNodeEntity.getData<FlowNodeErrorData>(FlowNodeErrorData);
|
||||
|
||||
errorData.setError(null);
|
||||
try {
|
||||
this.formModel.init(formMetaOrFormMetaGenerator, initialValue);
|
||||
} catch (e) {
|
||||
errorData.setError(e as Error);
|
||||
}
|
||||
}
|
||||
|
||||
updateFormValues(value: any) {
|
||||
this.formModel.updateFormValues(value);
|
||||
}
|
||||
|
||||
recreateForm(formMetaOrFormMetaGenerator: FormMetaOrFormMetaGenerator, initialValue?: any): void {
|
||||
this.createForm(formMetaOrFormMetaGenerator, initialValue);
|
||||
}
|
||||
|
||||
toJSON(): any {
|
||||
return this.formModel.toJSON();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated rehaje 版表单form Values change 事件触发函数
|
||||
* @protected
|
||||
*/
|
||||
fireDetaiChange(detailChangeEvent: DetailChangeEvent) {
|
||||
this.onDetailChangeEmitter.fire(detailChangeEvent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { type FormManager } from './services/form-manager';
|
||||
|
||||
export const FormContribution = Symbol('FormContribution');
|
||||
|
||||
export interface FormContribution {
|
||||
onRegister?(formManager: FormManager): void;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { ContainerModule } from 'inversify';
|
||||
import { bindContributions } from '@flowgram.ai/utils';
|
||||
|
||||
import { FormContextMaker } from './services/form-context-maker';
|
||||
import { NodeContribution } from '../node';
|
||||
import { FormManager, FormPathService } from './services';
|
||||
import { FormNodeContribution } from './form-node-contribution';
|
||||
|
||||
export const FormCoreContainerModule = new ContainerModule((bind) => {
|
||||
bind(FormManager).toSelf().inSingletonScope();
|
||||
bind(FormPathService).toSelf().inSingletonScope();
|
||||
bind(FormContextMaker).toSelf().inSingletonScope();
|
||||
bindContributions(bind, FormNodeContribution, [NodeContribution]);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { injectable } from 'inversify';
|
||||
|
||||
import { NodeContribution, NodeManager, PLUGIN_KEY } from '../node';
|
||||
import { formPluginRender } from './form-render';
|
||||
|
||||
@injectable()
|
||||
export class FormNodeContribution implements NodeContribution {
|
||||
onRegister(nodeManager: NodeManager) {
|
||||
nodeManager.registerPluginRender(PLUGIN_KEY.FORM, formPluginRender);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { useRefresh } from '@flowgram.ai/utils';
|
||||
import { FlowNodeEntity } from '@flowgram.ai/document';
|
||||
import { PlaygroundContext } from '@flowgram.ai/core';
|
||||
|
||||
import { NodeContext } from '../node';
|
||||
import { FormModel } from './models';
|
||||
import { FlowNodeFormData } from './flow-node-form-data';
|
||||
|
||||
interface FormRenderProps {
|
||||
node: FlowNodeEntity;
|
||||
playgroundContext?: PlaygroundContext;
|
||||
}
|
||||
|
||||
function getFormModelFromNode(node: FlowNodeEntity) {
|
||||
return node.getData(FlowNodeFormData)?.getFormModel<FormModel>();
|
||||
}
|
||||
|
||||
export function FormRender({ node }: FormRenderProps): any {
|
||||
const refresh = useRefresh();
|
||||
|
||||
const formModel = getFormModelFromNode(node);
|
||||
|
||||
useEffect(() => {
|
||||
const disposable = formModel?.onInitialized(() => {
|
||||
refresh();
|
||||
});
|
||||
return () => {
|
||||
disposable.dispose();
|
||||
};
|
||||
}, [formModel]);
|
||||
|
||||
return formModel?.initialized ? formModel.render() : null;
|
||||
}
|
||||
|
||||
export const formPluginRender = (props: NodeContext) => <FormRender {...props} />;
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './form-core-container-module';
|
||||
export * from './form-contribution';
|
||||
export * from './services';
|
||||
export * from './models';
|
||||
export * from './types';
|
||||
export * from './abilities';
|
||||
export * from './flow-node-form-data';
|
||||
export * from './client';
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { injectable } from 'inversify';
|
||||
|
||||
export interface Extension {
|
||||
key: string;
|
||||
}
|
||||
@injectable()
|
||||
export class FormAbilityExtensionRegistry {
|
||||
protected registry = new Map<string, Extension>();
|
||||
|
||||
register(extension: Extension): void {
|
||||
this.registry.set(extension.key, extension);
|
||||
}
|
||||
|
||||
get<T extends Extension>(key: string): T | undefined {
|
||||
return this.registry.get(key) as T | undefined;
|
||||
}
|
||||
|
||||
get objectMap(): Record<string, Extension> {
|
||||
return Object.fromEntries(this.registry);
|
||||
}
|
||||
|
||||
get collection(): Extension[] {
|
||||
return Array.from(this.registry.values());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export interface FormItemAbility {
|
||||
type: string;
|
||||
/**
|
||||
* 注册到formManager时钩子时调用
|
||||
*/
|
||||
onAbilityRegister?: () => void;
|
||||
}
|
||||
|
||||
export interface AbilityClass {
|
||||
type: string;
|
||||
|
||||
new (): FormItemAbility;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { type FlowNodeEntity } from '@flowgram.ai/document';
|
||||
import { PlaygroundContext } from '@flowgram.ai/core';
|
||||
|
||||
import { type FormModel, IFormItemMeta } from '..';
|
||||
|
||||
export interface FormItemMaterialContext {
|
||||
/**
|
||||
* 当前表单项的meta
|
||||
*/
|
||||
meta: IFormItemMeta;
|
||||
/**
|
||||
* 当前表单项的路径
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* 节点引擎全局readonly
|
||||
*/
|
||||
readonly: boolean;
|
||||
/**
|
||||
* 通过路径获取表单项的值
|
||||
* @param path 表单项在当前表单中的绝对路径,路径协议遵循glob
|
||||
*/
|
||||
getFormItemValueByPath: <T>(path: string) => T;
|
||||
/**
|
||||
* 节点表单校验回调函数注册
|
||||
*/
|
||||
onFormValidate: FormModel['onValidate'];
|
||||
/**
|
||||
* 获取Node模型
|
||||
*/
|
||||
node: FlowNodeEntity;
|
||||
/**
|
||||
* 获取FormModel原始模型
|
||||
*/
|
||||
form: FormModel;
|
||||
/**
|
||||
* 业务注入的全局context
|
||||
*/
|
||||
playgroundContext: PlaygroundContext;
|
||||
/**
|
||||
* 数组场景下当前项的index
|
||||
*/
|
||||
index?: number | undefined;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { DisposableCollection, Emitter } from '@flowgram.ai/utils';
|
||||
|
||||
import { FormItemDomRef, type IFormItemMeta } from '..';
|
||||
import { type FormModel } from '.';
|
||||
|
||||
export abstract class FormItem {
|
||||
readonly meta: IFormItemMeta;
|
||||
|
||||
readonly path: string;
|
||||
|
||||
readonly formModel: FormModel;
|
||||
|
||||
readonly onInitEventEmitter = new Emitter<FormItem>();
|
||||
|
||||
readonly onInit = this.onInitEventEmitter.event;
|
||||
|
||||
protected toDispose: DisposableCollection = new DisposableCollection();
|
||||
|
||||
readonly onDispose = this.toDispose.onDispose;
|
||||
|
||||
// todo(heyuan): 将dom 相关逻辑拆到form item插件里
|
||||
private _domRef: FormItemDomRef;
|
||||
|
||||
protected constructor(meta: IFormItemMeta, path: string, formModel: FormModel) {
|
||||
this.meta = meta;
|
||||
this.path = path;
|
||||
this.formModel = formModel;
|
||||
this.toDispose.push(this.onInitEventEmitter);
|
||||
}
|
||||
|
||||
abstract get value(): any;
|
||||
|
||||
abstract set value(value: any);
|
||||
|
||||
abstract validate(): void;
|
||||
|
||||
set domRef(domRef: FormItemDomRef) {
|
||||
this._domRef = domRef;
|
||||
}
|
||||
|
||||
get domRef() {
|
||||
return this._domRef;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.toDispose.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { type IFormItemMeta, type IFormMeta, type IFormMetaOptions } from '../types';
|
||||
import { FormPathService } from '../services';
|
||||
|
||||
export interface FormMetaTraverseParams {
|
||||
formItemMeta: IFormItemMeta;
|
||||
parentPath?: string;
|
||||
handle: (params: { formItemMeta: IFormItemMeta; path: string }) => any;
|
||||
}
|
||||
|
||||
export class FormMeta implements IFormMeta {
|
||||
constructor(root: IFormItemMeta, options: IFormMetaOptions) {
|
||||
this._root = root;
|
||||
this._options = options;
|
||||
}
|
||||
|
||||
protected _root: IFormItemMeta;
|
||||
|
||||
get root(): IFormItemMeta {
|
||||
return this._root;
|
||||
}
|
||||
|
||||
protected _options: IFormMetaOptions;
|
||||
|
||||
get options(): IFormMetaOptions {
|
||||
return this._options;
|
||||
}
|
||||
|
||||
static traverse({ formItemMeta, parentPath = '', handle }: FormMetaTraverseParams): void {
|
||||
if (!formItemMeta) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isRoot = !parentPath;
|
||||
|
||||
const path = isRoot
|
||||
? FormPathService.ROOT
|
||||
: formItemMeta.name
|
||||
? FormPathService.join([parentPath, formItemMeta.name])
|
||||
: parentPath;
|
||||
|
||||
handle({ formItemMeta, path });
|
||||
|
||||
if (formItemMeta.items) {
|
||||
this.traverse({
|
||||
formItemMeta: formItemMeta.items,
|
||||
handle,
|
||||
parentPath: FormPathService.toArrayPath(path),
|
||||
});
|
||||
}
|
||||
|
||||
if (formItemMeta.children && formItemMeta.children.length) {
|
||||
formItemMeta.children.forEach((child) => {
|
||||
this.traverse({ formItemMeta: child, handle, parentPath: path });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { injectable } from 'inversify';
|
||||
import { DisposableCollection, Event, MaybePromise } from '@flowgram.ai/utils';
|
||||
import { type FlowNodeEntity } from '@flowgram.ai/document';
|
||||
|
||||
import { FormFeedback, FormModelValid, IFormItem } from '../types';
|
||||
import { FormManager } from '../services/form-manager';
|
||||
import { type FormItem } from '.';
|
||||
|
||||
export type FormModelFactory = (entity: FlowNodeEntity) => FormModel;
|
||||
export const FormModelFactory = Symbol('FormModelFactory');
|
||||
export const FormModelEntity = Symbol('FormModelEntity');
|
||||
|
||||
@injectable()
|
||||
export abstract class FormModel {
|
||||
readonly onValidate: Event<FormModel>;
|
||||
|
||||
readonly onValidChange: Event<FormModelValid>;
|
||||
|
||||
readonly onFeedbacksChange: Event<FormFeedback[]>;
|
||||
|
||||
readonly onInitialized: Event<FormModel>;
|
||||
|
||||
protected toDispose: DisposableCollection = new DisposableCollection();
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* use `formModel.node` instead in FormModelV2
|
||||
*/
|
||||
abstract get flowNodeEntity(): FlowNodeEntity;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
abstract get formManager(): FormManager;
|
||||
|
||||
abstract get formMeta(): any;
|
||||
|
||||
abstract get initialized(): boolean;
|
||||
|
||||
abstract get valid(): FormModelValid;
|
||||
|
||||
abstract updateFormValues(value: any): void;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* use `formModel.getFieldIn` instead in FormModelV2 to get the model of a form field
|
||||
* do not use this in FormModelV2 since it only return an empty Map.
|
||||
*/
|
||||
abstract get formItemPathMap(): Map<string, IFormItem>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
abstract clearValid(): void;
|
||||
|
||||
abstract validate(): Promise<boolean>;
|
||||
|
||||
abstract validateWithFeedbacks(): Promise<FormFeedback[]>;
|
||||
|
||||
abstract init(formMetaOrFormMetaGenerator: any, initialValue?: any): MaybePromise<void>;
|
||||
|
||||
abstract toJSON(): any;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* use `formModel.getField` instead in FormModelV2
|
||||
*/
|
||||
abstract getFormItemByPath(path: string): FormItem | undefined;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* use `formModel.getFieldValue` instead in FormModelV2 to get the model of a form field by path
|
||||
*/
|
||||
abstract getFormItemValueByPath<T = any>(path: string): any | undefined;
|
||||
|
||||
abstract render(): any;
|
||||
|
||||
abstract dispose(): void;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './form-model';
|
||||
export * from './form-item';
|
||||
export * from './form-meta';
|
||||
export * from './form-ability-extension-registry';
|
||||
export * from './form-item-ability';
|
||||
export * from './form-item-material-context';
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { injectPlaygroundContext, PlaygroundContext } from '@flowgram.ai/core';
|
||||
|
||||
import { NodeEngineContext } from '../../node';
|
||||
import { FormItem, FormItemMaterialContext } from '..';
|
||||
|
||||
@injectable()
|
||||
export class FormContextMaker {
|
||||
@inject(NodeEngineContext) readonly nodeEngineContext: NodeEngineContext;
|
||||
|
||||
@injectPlaygroundContext() readonly playgroundContext: PlaygroundContext;
|
||||
|
||||
makeFormItemMaterialContext(
|
||||
formItem: FormItem,
|
||||
options?: { getIndex: () => number | undefined }
|
||||
): FormItemMaterialContext {
|
||||
return {
|
||||
meta: formItem.meta,
|
||||
path: formItem.path,
|
||||
readonly: this.nodeEngineContext.readonly,
|
||||
getFormItemValueByPath: formItem.formModel.getFormItemValueByPath.bind(formItem.formModel),
|
||||
onFormValidate: formItem.formModel.onValidate.bind(formItem.formModel),
|
||||
form: formItem.formModel,
|
||||
node: formItem.formModel.flowNodeEntity,
|
||||
playgroundContext: this.playgroundContext,
|
||||
index: options?.getIndex(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { mapValues } from 'lodash-es';
|
||||
import { inject, injectable, multiInject, optional, postConstruct } from 'inversify';
|
||||
import { Emitter } from '@flowgram.ai/utils';
|
||||
import { injectPlaygroundContext, PlaygroundContext } from '@flowgram.ai/core';
|
||||
|
||||
import { AbilityClass, FormItemAbility } from '../models/form-item-ability';
|
||||
import { FormAbilityExtensionRegistry, FormModel } from '../models';
|
||||
import { FormContribution } from '../form-contribution';
|
||||
import {
|
||||
DecoratorAbility,
|
||||
DecoratorExtension,
|
||||
SetterAbility,
|
||||
SetterExtension,
|
||||
SetterHoc,
|
||||
} from '../abilities';
|
||||
import { FormContextMaker, FormPathService } from './index';
|
||||
|
||||
@injectable()
|
||||
export class FormManager {
|
||||
readonly abilityRegistry: Map<string, FormItemAbility> = new Map();
|
||||
|
||||
readonly setterHocs: SetterHoc[] = [];
|
||||
|
||||
readonly extensionRegistryMap: Map<string, FormAbilityExtensionRegistry> = new Map();
|
||||
|
||||
@inject(FormPathService) readonly pathManager: FormPathService;
|
||||
|
||||
@inject(FormContextMaker) readonly formContextMaker: FormContextMaker;
|
||||
|
||||
@injectPlaygroundContext() readonly playgroundContext: PlaygroundContext;
|
||||
|
||||
@multiInject(FormContribution) @optional() protected formContributions: FormContribution[] = [];
|
||||
|
||||
private readonly onFormModelWillInitEmitter = new Emitter<{
|
||||
model: FormModel;
|
||||
data: any;
|
||||
}>();
|
||||
|
||||
readonly onFormModelWillInit = this.onFormModelWillInitEmitter.event;
|
||||
|
||||
get components(): Record<string, any> {
|
||||
return mapValues(
|
||||
this.extensionRegistryMap.get(SetterAbility.type)?.objectMap || {},
|
||||
(setter: SetterExtension) => setter.component
|
||||
);
|
||||
}
|
||||
|
||||
get decorators(): Record<string, any> {
|
||||
return mapValues(
|
||||
this.extensionRegistryMap.get(DecoratorAbility.type)?.objectMap || {},
|
||||
(decorator: DecoratorExtension) => decorator.component
|
||||
);
|
||||
}
|
||||
|
||||
registerAbilityExtension(type: string, extension: any): void {
|
||||
if (!this.extensionRegistryMap.get(type)) {
|
||||
this.extensionRegistryMap.set(type, new FormAbilityExtensionRegistry());
|
||||
}
|
||||
const registry = this.extensionRegistryMap.get(type);
|
||||
if (!registry) {
|
||||
return;
|
||||
}
|
||||
registry.register(extension);
|
||||
}
|
||||
|
||||
getAbilityExtension(abilityType: string, extensionKey: string): any {
|
||||
return this.extensionRegistryMap.get(abilityType)?.get(extensionKey);
|
||||
}
|
||||
|
||||
registerAbility(Ability: AbilityClass): void {
|
||||
const ability = new Ability();
|
||||
this.abilityRegistry.set(ability.type, ability);
|
||||
}
|
||||
|
||||
registerAbilities(Abilities: AbilityClass[]): void {
|
||||
Abilities.forEach(this.registerAbility.bind(this));
|
||||
}
|
||||
|
||||
getAbility<ExtendAbility>(type: string): (FormItemAbility & ExtendAbility) | undefined {
|
||||
return this.abilityRegistry.get(type) as FormItemAbility & ExtendAbility;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* Setter Hoc and setter are no longer supported in NodeEngineV2
|
||||
* @param hoc
|
||||
*/
|
||||
registerSetterHoc(hoc: SetterHoc): void {
|
||||
this.setterHocs.push(hoc);
|
||||
}
|
||||
|
||||
fireFormModelWillInit(model: FormModel, data: any) {
|
||||
this.onFormModelWillInitEmitter.fire({
|
||||
model,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.onFormModelWillInitEmitter.dispose();
|
||||
}
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
this.formContributions.forEach((contrib) => contrib.onRegister?.(this));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { injectable } from 'inversify';
|
||||
|
||||
@injectable()
|
||||
export class FormPathService {
|
||||
static readonly ROOT = '/';
|
||||
|
||||
static readonly DIVIDER = '/';
|
||||
|
||||
static readonly RELATIVE_PARENT = '..';
|
||||
|
||||
static readonly RELATIVE_CURRENT = '.';
|
||||
|
||||
static readonly ARRAY = '[]';
|
||||
|
||||
static normalize(path: string) {
|
||||
if (path === FormPathService.ROOT) {
|
||||
return path;
|
||||
}
|
||||
// 去掉末尾的斜杠
|
||||
if (path.endsWith(FormPathService.DIVIDER)) {
|
||||
path = path.slice(0, -1);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
static join(paths: string[]): string {
|
||||
if (paths[1].startsWith(FormPathService.ROOT)) {
|
||||
throw new Error(
|
||||
`FormPathService Error: join failed, invalid paths[1], paths[1]= ${paths[1]}`,
|
||||
);
|
||||
}
|
||||
if (paths[0].endsWith(FormPathService.DIVIDER)) {
|
||||
return `${paths[0]}${paths[1]}`;
|
||||
}
|
||||
return paths.join(FormPathService.DIVIDER);
|
||||
}
|
||||
|
||||
static toArrayPath(path: string): string {
|
||||
return FormPathService.join([path, FormPathService.ARRAY]);
|
||||
}
|
||||
|
||||
static parseArrayItemPath(path: string) {
|
||||
const names = path.split('/');
|
||||
|
||||
let i = 0;
|
||||
while (i < names.length) {
|
||||
const itemIndex = parseInt(names[i]);
|
||||
|
||||
if (!isNaN(itemIndex)) {
|
||||
const arrayPath = FormPathService.toArrayPath(
|
||||
names.slice(0, i).join(FormPathService.DIVIDER),
|
||||
);
|
||||
const restPath = names.slice(i + 1).join(FormPathService.DIVIDER);
|
||||
const itemMetaPath = FormPathService.join([arrayPath, restPath]);
|
||||
return { itemIndex, arrayPath, itemMetaPath };
|
||||
}
|
||||
i = i + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
simplify(path: string) {
|
||||
const segments = path.split(FormPathService.DIVIDER);
|
||||
const resSegments: string[] = [];
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
if (!segments[i]) {
|
||||
throw new Error('FormPathService: join failed');
|
||||
}
|
||||
|
||||
if (segments[i] === FormPathService.RELATIVE_CURRENT) {
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
if (segments[i] === FormPathService.RELATIVE_PARENT) {
|
||||
resSegments.pop();
|
||||
}
|
||||
resSegments.push(segments[i]);
|
||||
}
|
||||
return resSegments.join(FormPathService.DIVIDER);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './form-path-service';
|
||||
export * from './form-manager';
|
||||
export * from './form-context-maker';
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FlowNodeEntity } from '@flowgram.ai/document';
|
||||
import { PlaygroundContext } from '@flowgram.ai/core';
|
||||
import { MaybePromise } from '@flowgram.ai/utils';
|
||||
|
||||
import { type IFormItem } from './form-model.types';
|
||||
import { IFormItemMeta } from './form-meta.types';
|
||||
|
||||
export interface FormItemAbilityMeta<Options = any> {
|
||||
type: string;
|
||||
options: Options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export interface FormItemContext {
|
||||
/**
|
||||
* @deprecated Use context.node instead
|
||||
*/
|
||||
formItemMeta: IFormItemMeta;
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
formItem: IFormItem;
|
||||
/**
|
||||
* @deprecated Use context.node instead
|
||||
*/
|
||||
flowNodeEntity: FlowNodeEntity;
|
||||
/**
|
||||
* @deprecated Use context.playgroundContext instead
|
||||
*/
|
||||
playgroundContext: PlaygroundContext;
|
||||
}
|
||||
|
||||
export interface FormItemHookParams extends FormItemContext {
|
||||
formItem: IFormItem;
|
||||
}
|
||||
|
||||
export interface FormItemHooks<T> {
|
||||
/**
|
||||
* FormItem初始化钩子
|
||||
*/
|
||||
onInit?: (params: FormItemHookParams & T) => void;
|
||||
/**
|
||||
* FormItem提交时钩子
|
||||
*/
|
||||
onSubmit?: (params: FormItemHookParams & T) => void;
|
||||
/**
|
||||
* FormItem克隆时钩子
|
||||
*/
|
||||
onClone?: (params: FormItemHookParams & T) => MaybePromise<void>;
|
||||
/**
|
||||
* 克隆后执行的逻辑
|
||||
*/
|
||||
afterClone?: (params: FormItemHookParams & T) => void;
|
||||
/**
|
||||
* FormItem全局校验时钩子
|
||||
*/
|
||||
onValidate?: (params: FormItemHookParams & T) => void;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { MaybePromise } from '@flowgram.ai/utils';
|
||||
import { FlowNodeEntity } from '@flowgram.ai/document';
|
||||
import { PlaygroundContext, PluginContext } from '@flowgram.ai/core';
|
||||
|
||||
import { type FormItemAbilityMeta } from './form-ability.types';
|
||||
|
||||
export type FormDataTypeName =
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'integer'
|
||||
| 'boolean'
|
||||
| 'object'
|
||||
| 'array'
|
||||
| 'null';
|
||||
|
||||
export type FormDataType =
|
||||
| string //
|
||||
| number
|
||||
| boolean
|
||||
| FormDataObject
|
||||
| DataArray
|
||||
| null;
|
||||
|
||||
export interface FormDataObject {
|
||||
[key: string]: FormDataType;
|
||||
}
|
||||
|
||||
export type DataArray = Array<FormDataType>;
|
||||
|
||||
export const FORM_VOID = 'form-void' as const;
|
||||
|
||||
export interface TreeNode<T> {
|
||||
name: string;
|
||||
children?: TreeNode<T>[];
|
||||
}
|
||||
|
||||
export interface IFormItemMeta extends TreeNode<IFormItemMeta> {
|
||||
/**
|
||||
* 表单项名称
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* 数据类型
|
||||
*/
|
||||
type: FormDataTypeName | typeof FORM_VOID;
|
||||
/**
|
||||
* 枚举值
|
||||
*/
|
||||
enum?: FormDataType[];
|
||||
/**
|
||||
* 数组类型item的数据类型描述
|
||||
*/
|
||||
items?: IFormItemMeta;
|
||||
/**
|
||||
* 表单项标题
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* 表单项描述
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* 表单项默认值
|
||||
*/
|
||||
default?: FormDataType;
|
||||
/**
|
||||
* 是否必填
|
||||
*/
|
||||
required?: boolean;
|
||||
/**
|
||||
* 扩展能力
|
||||
*/
|
||||
abilities?: FormItemAbilityMeta[];
|
||||
|
||||
/**
|
||||
* 子表单项
|
||||
*/
|
||||
children?: IFormItemMeta[];
|
||||
}
|
||||
|
||||
export interface IFormMeta {
|
||||
/**
|
||||
* 表单树结构root
|
||||
*/
|
||||
root?: IFormItemMeta;
|
||||
/**
|
||||
* 表单全局配置
|
||||
*/
|
||||
options?: IFormMetaOptions;
|
||||
}
|
||||
|
||||
export interface NodeFormContext {
|
||||
node: FlowNodeEntity;
|
||||
playgroundContext: PlaygroundContext;
|
||||
clientContext: PluginContext & Record<string, any>;
|
||||
}
|
||||
|
||||
export interface IFormMetaOptions {
|
||||
formatOnInit?: (value: any, context: NodeFormContext) => any;
|
||||
|
||||
formatOnSubmit?: (value: any, context: NodeFormContext) => any;
|
||||
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface FormMetaGeneratorParams<PlaygroundContext, FormValue = any> {
|
||||
node: FlowNodeEntity;
|
||||
playgroundContext: PlaygroundContext;
|
||||
initialValue?: FormValue;
|
||||
}
|
||||
|
||||
export type FormMetaGenerator<PlaygroundContext = any, FormValue = any> = (
|
||||
params: FormMetaGeneratorParams<FormValue, FormValue>
|
||||
) => MaybePromise<IFormMeta>;
|
||||
|
||||
export type FormMetaOrFormMetaGenerator = FormMetaGenerator | IFormMeta;
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export interface IFormItem<T = any> {
|
||||
value: T;
|
||||
}
|
||||
|
||||
export enum FormItemEventName {
|
||||
onFormValueChange = 'onFormValueChange',
|
||||
onFormItemInit = 'onFormItemInit',
|
||||
}
|
||||
|
||||
export type FormModelValid = boolean | null;
|
||||
|
||||
export type FeedbackStatus = 'error' | 'warning' | 'pending';
|
||||
export type FeedbackText = string;
|
||||
|
||||
export interface FormItemFeedback {
|
||||
feedbackStatus?: FeedbackStatus;
|
||||
feedbackText?: FeedbackText;
|
||||
}
|
||||
|
||||
export interface FormFeedback {
|
||||
feedbackStatus?: FeedbackStatus;
|
||||
feedbackText?: FeedbackText;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface FormItemDomRef {
|
||||
current: HTMLElement | null;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './form-model.types';
|
||||
export * from './form-meta.types';
|
||||
export * from './form-ability.types';
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './node';
|
||||
export * from './error';
|
||||
export * from './form';
|
||||
export * from './client';
|
||||
export * from './node-react';
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './node-engine-react-context';
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { INodeEngineContext, NodeEngineContext } from '../../node';
|
||||
|
||||
export const NodeEngineReactContext = React.createContext<INodeEngineContext>(
|
||||
NodeEngineContext.DEFAULT_JSON,
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './use-node-engine-context';
|
||||
export * from './use-form-Item';
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { FlowNodeEntity } from '@flowgram.ai/document';
|
||||
import { useEntityFromContext, useRefresh } from '@flowgram.ai/core';
|
||||
|
||||
import { FlowNodeFormData, FormModel, IFormItem } from '../../form';
|
||||
|
||||
export function useFormItem(path: string): IFormItem | undefined {
|
||||
const refresh = useRefresh();
|
||||
const node = useEntityFromContext<FlowNodeEntity>();
|
||||
const formData = node.getData<FlowNodeFormData>(FlowNodeFormData);
|
||||
const formItem = formData.getFormModel<FormModel>().getFormItemByPath(path);
|
||||
|
||||
useEffect(() => {
|
||||
const disposable = formData.onDataChange(() => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposable.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return formItem;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useService, useRefresh } from '@flowgram.ai/core';
|
||||
|
||||
import { NodeEngineContext } from '../../node';
|
||||
|
||||
export function useNodeEngineContext(): NodeEngineContext {
|
||||
const refresh = useRefresh();
|
||||
const nodeEngineContext = useService<NodeEngineContext>(NodeEngineContext);
|
||||
|
||||
useEffect(() => {
|
||||
const disposable = nodeEngineContext.onChange(() => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposable.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return nodeEngineContext;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './hooks';
|
||||
export * from './context';
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export const MATERIAL_KEY = {
|
||||
NODE_ERROR_RENDER: 'node_error_render',
|
||||
NODE_PLACEHOLDER_RENDER: 'node_placeholder_render',
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export const PLUGIN_KEY = {
|
||||
FORM: 'Plugin_Form',
|
||||
ERROR: 'Plugin_Error',
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { NodeContribution } from './node-contribution';
|
||||
export * from './node-container-module';
|
||||
export * from './node-manager';
|
||||
export * from './types';
|
||||
export * from './core-plugins';
|
||||
export * from './core-materials';
|
||||
export * from './node-engine';
|
||||
export * from './node-engine-context';
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { ContainerModule } from 'inversify';
|
||||
|
||||
import { NodeManager } from './node-manager';
|
||||
import { NodeEngineContext } from './node-engine-context';
|
||||
import { NodeEngine } from './node-engine';
|
||||
|
||||
export const NodeContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => {
|
||||
bind(NodeEngine).toSelf().inSingletonScope();
|
||||
bind(NodeManager).toSelf().inSingletonScope();
|
||||
bind(NodeEngineContext).toSelf().inSingletonScope();
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { type NodeManager } from './node-manager';
|
||||
|
||||
export const NodeContribution = Symbol('NodeContribution');
|
||||
|
||||
export interface NodeContribution {
|
||||
onRegister?(nodeManager: NodeManager): void;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { injectable } from 'inversify';
|
||||
import { Emitter } from '@flowgram.ai/utils';
|
||||
|
||||
export interface INodeEngineContext {
|
||||
readonly: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* NodeEngineContext 在 Node Engine 中为全局单例, 它的作用是让Node之间共享数据。
|
||||
* context 分为内置context(如 readonly) 和 自定义context(业务可以按需注入)
|
||||
*/
|
||||
@injectable()
|
||||
export class NodeEngineContext {
|
||||
static DEFAULT_READONLY = false;
|
||||
|
||||
static DEFAULT_JSON = { readonly: NodeEngineContext.DEFAULT_READONLY };
|
||||
|
||||
readonly onChangeEmitter = new Emitter<NodeEngineContext>();
|
||||
|
||||
readonly onChange = this.onChangeEmitter.event;
|
||||
|
||||
private _readonly: boolean = NodeEngineContext.DEFAULT_READONLY;
|
||||
|
||||
private _json: INodeEngineContext = NodeEngineContext.DEFAULT_JSON;
|
||||
|
||||
get json(): INodeEngineContext {
|
||||
return this._json;
|
||||
}
|
||||
|
||||
get readonly(): boolean {
|
||||
return this._readonly;
|
||||
}
|
||||
|
||||
set readonly(value: boolean) {
|
||||
this._readonly = value;
|
||||
this.fireChange();
|
||||
}
|
||||
|
||||
private fireChange(): void {
|
||||
this.updateJSON();
|
||||
this.onChangeEmitter.fire(this);
|
||||
}
|
||||
|
||||
private updateJSON(): void {
|
||||
this._json = {
|
||||
readonly: this._readonly,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { injectable, inject } from 'inversify';
|
||||
|
||||
import { NodeManager } from './node-manager';
|
||||
import { NodeEngineContext } from './node-engine-context';
|
||||
|
||||
@injectable()
|
||||
export class NodeEngine {
|
||||
@inject(NodeManager) nodeManager: NodeManager;
|
||||
|
||||
@inject(NodeEngineContext) context: NodeEngineContext;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { flow } from 'lodash-es';
|
||||
import { injectable, multiInject, optional, postConstruct } from 'inversify';
|
||||
|
||||
import { NodeErrorRenderProps } from '../error';
|
||||
import { NodePluginRender, NodeRenderHoc, Render } from './types';
|
||||
import { NodeContribution } from './node-contribution';
|
||||
|
||||
export enum MaterialRenderKey {
|
||||
CustomNodeError = 'Material_CustomNodeError',
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class NodeManager {
|
||||
readonly materialRenderRegistry: Map<string, Render> = new Map();
|
||||
|
||||
readonly pluginRenderRegistry: Map<string, Render> = new Map();
|
||||
|
||||
readonly nodeRenderHocs: NodeRenderHoc[] = [];
|
||||
|
||||
@multiInject(NodeContribution) @optional() protected nodeContributions: NodeContribution[] = [];
|
||||
|
||||
registerMaterialRender(key: string, render: Render) {
|
||||
this.materialRenderRegistry.set(key, render);
|
||||
}
|
||||
|
||||
getMaterialRender(key: string): Render | undefined {
|
||||
return this.materialRenderRegistry.get(key);
|
||||
}
|
||||
|
||||
registerPluginRender(key: string, render: NodePluginRender): void {
|
||||
this.pluginRenderRegistry.set(key, render);
|
||||
}
|
||||
|
||||
getPluginRender(key: string): NodePluginRender | undefined {
|
||||
return this.pluginRenderRegistry.get(key);
|
||||
}
|
||||
|
||||
registerNodeErrorRender(render: Render<NodeErrorRenderProps>) {
|
||||
this.registerMaterialRender(MaterialRenderKey.CustomNodeError, render);
|
||||
}
|
||||
|
||||
get nodeRenderHoc() {
|
||||
return flow(this.nodeRenderHocs);
|
||||
}
|
||||
|
||||
registerNodeRenderHoc(hoc: NodeRenderHoc) {
|
||||
this.nodeRenderHocs.push(hoc);
|
||||
}
|
||||
|
||||
get nodeErrorRender() {
|
||||
return this.materialRenderRegistry.get(MaterialRenderKey.CustomNodeError);
|
||||
}
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
this.nodeContributions.forEach((contrib) => contrib.onRegister?.(this));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { FlowNodeEntity } from '@flowgram.ai/document';
|
||||
|
||||
import { NodeFormContext } from '../form';
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* use `NodeFormContext` instead
|
||||
*/
|
||||
export type NodeContext = NodeFormContext;
|
||||
|
||||
export type Render<T = any> = (props: T) => any;
|
||||
|
||||
export type NodePluginRender = Render<NodeFormContext>;
|
||||
|
||||
export type NodePlaceholderRender = Render<NodeFormContext>;
|
||||
|
||||
export interface NodeRenderProps {
|
||||
node: FlowNodeEntity;
|
||||
}
|
||||
|
||||
export type NodeRenderHoc = (
|
||||
Component: React.JSXElementConstructor<NodeRenderProps>
|
||||
) => React.JSXElementConstructor<NodeRenderProps>;
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"baseUrl": "./",
|
||||
},
|
||||
"include": ["./src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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: [
|
||||
'**/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';
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { FieldModel } from '@/core/field-model';
|
||||
import { FieldArrayModel } from '@/core/field-array-model';
|
||||
import { createForm } from '@/core/create-form';
|
||||
|
||||
describe('createForm', () => {
|
||||
it('should create form with auto initialization by default', () => {
|
||||
const { form, control } = createForm();
|
||||
|
||||
expect(form).toBeDefined();
|
||||
expect(control).toBeDefined();
|
||||
expect(control._formModel.initialized).toBe(true);
|
||||
});
|
||||
|
||||
it('should disableAutoInit work', async () => {
|
||||
const { control } = createForm({ disableAutoInit: true });
|
||||
|
||||
expect(control._formModel.initialized).toBe(false);
|
||||
|
||||
control.init();
|
||||
expect(control._formModel.initialized).toBe(true);
|
||||
});
|
||||
|
||||
it('should create form with initial values', () => {
|
||||
const initialValues = {
|
||||
username: 'John',
|
||||
email: 'john@example.com',
|
||||
age: 30,
|
||||
};
|
||||
const { form } = createForm({ initialValues });
|
||||
|
||||
expect(form.initialValues).toEqual(initialValues);
|
||||
expect(form.values).toEqual(initialValues);
|
||||
});
|
||||
|
||||
it('should create form with validation', async () => {
|
||||
const { form } = createForm({
|
||||
initialValues: { username: '' },
|
||||
});
|
||||
|
||||
// Validation should be callable
|
||||
const errors = await form.validate();
|
||||
|
||||
// Without explicit validators, errors should be empty or undefined
|
||||
expect(errors === undefined || Object.keys(errors).length === 0).toBe(true);
|
||||
});
|
||||
|
||||
it('should create form with empty options', () => {
|
||||
const { form, control } = createForm({});
|
||||
|
||||
expect(form).toBeDefined();
|
||||
expect(control).toBeDefined();
|
||||
expect(control._formModel.initialized).toBe(true);
|
||||
});
|
||||
|
||||
it('should create form without options', () => {
|
||||
const { form, control } = createForm();
|
||||
|
||||
expect(form).toBeDefined();
|
||||
expect(control).toBeDefined();
|
||||
expect(control._formModel.initialized).toBe(true);
|
||||
});
|
||||
|
||||
describe('control.getField', () => {
|
||||
it('should get field by name', () => {
|
||||
const { form, control } = createForm({
|
||||
initialValues: {
|
||||
username: 'John',
|
||||
},
|
||||
});
|
||||
|
||||
// Create field first
|
||||
control._formModel.createField('username');
|
||||
const field = control.getField('username');
|
||||
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.name).toBe('username');
|
||||
expect(field!.value).toBe('John');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent field', () => {
|
||||
const { control } = createForm();
|
||||
|
||||
const field = control.getField('nonexistent');
|
||||
|
||||
expect(field).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should get FieldArray when field is array', () => {
|
||||
const { control } = createForm({
|
||||
initialValues: {
|
||||
users: [{ name: 'Alice' }, { name: 'Bob' }],
|
||||
},
|
||||
});
|
||||
|
||||
// Create field array
|
||||
control._formModel.createFieldArray('users');
|
||||
const fieldArray = control.getField('users');
|
||||
|
||||
expect(fieldArray).toBeDefined();
|
||||
expect(fieldArray!.name).toBe('users');
|
||||
expect(Array.isArray(fieldArray!.value)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return Field for regular field', () => {
|
||||
const { control } = createForm({
|
||||
initialValues: {
|
||||
username: 'John',
|
||||
},
|
||||
});
|
||||
|
||||
control._formModel.createField('username');
|
||||
const field = control.getField('username');
|
||||
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.name).toBe('username');
|
||||
expect((field as any).onChange).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return FieldArray for array field with array methods', () => {
|
||||
const { control } = createForm({
|
||||
initialValues: {
|
||||
users: [{ name: 'Alice' }],
|
||||
},
|
||||
});
|
||||
|
||||
control._formModel.createFieldArray('users');
|
||||
const fieldArrayModel = control._formModel.getField('users');
|
||||
expect(fieldArrayModel).toBeInstanceOf(FieldArrayModel);
|
||||
|
||||
const fieldArray = control.getField('users');
|
||||
|
||||
expect(fieldArray).toBeDefined();
|
||||
expect((fieldArray as any).append).toBeDefined();
|
||||
expect((fieldArray as any).remove).toBeDefined();
|
||||
expect((fieldArray as any).swap).toBeDefined();
|
||||
expect((fieldArray as any).move).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle nested field names', () => {
|
||||
const { control } = createForm({
|
||||
initialValues: {
|
||||
user: {
|
||||
profile: {
|
||||
name: 'Alice',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
control._formModel.createField('user.profile.name');
|
||||
const field = control.getField('user.profile.name');
|
||||
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.name).toBe('user.profile.name');
|
||||
expect(field!.value).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should handle array index in field names', () => {
|
||||
const { control } = createForm({
|
||||
initialValues: {
|
||||
users: [{ name: 'Alice' }, { name: 'Bob' }],
|
||||
},
|
||||
});
|
||||
|
||||
control._formModel.createField('users.0.name');
|
||||
const field = control.getField('users.0.name');
|
||||
|
||||
expect(field).toBeDefined();
|
||||
expect(field!.name).toBe('users.0.name');
|
||||
expect(field!.value).toBe('Alice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('control.init', () => {
|
||||
it('should initialize form with new options', () => {
|
||||
const { control } = createForm({ disableAutoInit: true });
|
||||
|
||||
expect(control._formModel.initialized).toBe(false);
|
||||
|
||||
control.init();
|
||||
|
||||
expect(control._formModel.initialized).toBe(true);
|
||||
});
|
||||
|
||||
it('should reinitialize form', () => {
|
||||
const { control } = createForm({
|
||||
initialValues: { username: 'John' },
|
||||
});
|
||||
|
||||
expect(control._formModel.initialized).toBe(true);
|
||||
expect(control._formModel.initialValues).toEqual({ username: 'John' });
|
||||
|
||||
control._formModel.dispose();
|
||||
control.init();
|
||||
|
||||
expect(control._formModel.initialized).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should expose _formModel on control', () => {
|
||||
const { control } = createForm();
|
||||
|
||||
expect(control._formModel).toBeDefined();
|
||||
expect(control._formModel.init).toBeDefined();
|
||||
expect(control._formModel.createField).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,931 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Errors, ValidateTrigger, Warnings } from '@/types';
|
||||
import { FormModel } from '@/core/form-model';
|
||||
import { type FieldArrayModel } from '@/core/field-array-model';
|
||||
|
||||
import { FeedbackLevel } from '../src/types';
|
||||
|
||||
describe('FormArrayModel', () => {
|
||||
let formModel = new FormModel();
|
||||
describe('children', () => {
|
||||
let arrayField: FieldArrayModel;
|
||||
beforeEach(() => {
|
||||
formModel.dispose();
|
||||
formModel = new FormModel();
|
||||
// 创建数组
|
||||
formModel.createFieldArray('arr');
|
||||
const field = formModel.getField<FieldArrayModel>('arr');
|
||||
field!.append('a');
|
||||
field!.append('b');
|
||||
field!.append('c');
|
||||
arrayField = field!;
|
||||
});
|
||||
|
||||
it('can get children', () => {
|
||||
expect(arrayField.children.length).toBe(3);
|
||||
});
|
||||
});
|
||||
describe('append & delete', () => {
|
||||
let arrayField: FieldArrayModel;
|
||||
let arrEffect = vi.fn();
|
||||
let aEffect = vi.fn();
|
||||
let bEffect = vi.fn();
|
||||
let cEffect = vi.fn();
|
||||
let appendEffect = vi.fn();
|
||||
let deleteEffect = vi.fn();
|
||||
let aValidate = vi.fn();
|
||||
let bValidate = vi.fn();
|
||||
let cValidate = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
formModel.dispose();
|
||||
formModel = new FormModel();
|
||||
arrEffect = vi.fn();
|
||||
aEffect = vi.fn();
|
||||
bEffect = vi.fn();
|
||||
cEffect = vi.fn();
|
||||
appendEffect = vi.fn();
|
||||
deleteEffect = vi.fn();
|
||||
aValidate = vi.fn();
|
||||
bValidate = vi.fn();
|
||||
cValidate = vi.fn();
|
||||
formModel.init({
|
||||
validateTrigger: ValidateTrigger.onChange,
|
||||
validate: {
|
||||
['arr.0']: aValidate,
|
||||
['arr.1']: bValidate,
|
||||
['arr.2']: cValidate,
|
||||
},
|
||||
});
|
||||
// 创建其他field, 用于测试其他元素不会被影响
|
||||
formModel.createField('other');
|
||||
// 创建数组
|
||||
formModel.createFieldArray('arr');
|
||||
const field = formModel.getField<FieldArrayModel>('arr');
|
||||
const a = field!.append('a');
|
||||
const b = field!.append('b');
|
||||
const c = field!.append('c');
|
||||
arrayField = field!;
|
||||
arrayField.onValueChange(arrEffect);
|
||||
arrayField.onAppend(appendEffect);
|
||||
arrayField.onDelete(deleteEffect);
|
||||
|
||||
a.onValueChange(aEffect);
|
||||
b.onValueChange(bEffect);
|
||||
c.onValueChange(cEffect);
|
||||
});
|
||||
|
||||
it('append', async () => {
|
||||
vi.spyOn(arrayField, 'validate');
|
||||
|
||||
arrayField.append('d');
|
||||
|
||||
expect(arrayField.children.length).toBe(4);
|
||||
expect(formModel.getField('other')).toBeDefined();
|
||||
expect(arrEffect).toHaveBeenCalledTimes(1);
|
||||
expect(appendEffect).toHaveBeenCalledTimes(1);
|
||||
expect(arrayField.validate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('should fire OnFormValueChange event for arr when append', () => {
|
||||
vi.spyOn(formModel.onFormValuesInitEmitter, 'fire');
|
||||
vi.spyOn(formModel.onFormValuesChangeEmitter, 'fire');
|
||||
|
||||
arrayField.append('d');
|
||||
|
||||
expect(formModel.onFormValuesChangeEmitter.fire).toHaveBeenCalledWith({
|
||||
values: {
|
||||
arr: ['a', 'b', 'c', 'd'],
|
||||
},
|
||||
prevValues: {
|
||||
arr: ['a', 'b', 'c'],
|
||||
},
|
||||
name: 'arr',
|
||||
options: {
|
||||
action: 'array-append',
|
||||
indexes: [3],
|
||||
},
|
||||
});
|
||||
expect(formModel.onFormValuesInitEmitter.fire).toHaveBeenCalledWith({
|
||||
values: {
|
||||
arr: ['a', 'b', 'c', 'd'],
|
||||
},
|
||||
prevValues: {
|
||||
arr: ['a', 'b', 'c'],
|
||||
},
|
||||
name: 'arr.3',
|
||||
});
|
||||
});
|
||||
it('delete first element', () => {
|
||||
vi.spyOn(formModel.onFormValuesChangeEmitter, 'fire');
|
||||
vi.spyOn(arrayField, 'validate');
|
||||
vi.spyOn(arrayField.onValueChangeEmitter, 'fire');
|
||||
|
||||
arrayField.delete(0);
|
||||
|
||||
// assert value
|
||||
expect(arrayField.children.length).toBe(2);
|
||||
expect(arrayField.children[0].value).toBe('b');
|
||||
expect(arrayField.children[1].value).toBe('c');
|
||||
expect(formModel.getField('other')).toBeDefined();
|
||||
|
||||
// assert change events
|
||||
expect(arrayField.onValueChangeEmitter.fire).toHaveBeenCalledTimes(1);
|
||||
expect(aEffect).toHaveBeenCalledTimes(1);
|
||||
expect(bEffect).toHaveBeenCalledTimes(1);
|
||||
expect(cEffect).toHaveBeenCalledTimes(1);
|
||||
expect(deleteEffect).toHaveBeenCalledTimes(1);
|
||||
expect(formModel.onFormValuesChangeEmitter.fire).toHaveBeenCalledWith({
|
||||
values: {
|
||||
arr: ['b', 'c'],
|
||||
},
|
||||
prevValues: {
|
||||
arr: ['a', 'b', 'c'],
|
||||
},
|
||||
name: 'arr',
|
||||
options: {
|
||||
action: 'array-splice',
|
||||
indexes: [0],
|
||||
},
|
||||
});
|
||||
expect(formModel.onFormValuesChangeEmitter.fire).toHaveBeenCalledTimes(1);
|
||||
|
||||
// assert validate trigger
|
||||
expect(arrayField.validate).toHaveBeenCalledTimes(1);
|
||||
expect(aValidate).not.toHaveBeenCalled();
|
||||
expect(bValidate).not.toHaveBeenCalled();
|
||||
expect(cValidate).not.toHaveBeenCalled();
|
||||
});
|
||||
it('delete middle element', () => {
|
||||
vi.spyOn(arrayField, 'validate');
|
||||
vi.spyOn(arrayField.onValueChangeEmitter, 'fire');
|
||||
|
||||
arrayField.delete(1);
|
||||
|
||||
// assert values
|
||||
expect(arrayField.children.length).toBe(2);
|
||||
expect(arrayField.children[0].value).toBe('a');
|
||||
expect(arrayField.children[1].value).toBe('c');
|
||||
expect(formModel.getField('other')).toBeDefined();
|
||||
|
||||
// assert change events
|
||||
expect(arrayField.onValueChangeEmitter.fire).toHaveBeenCalledTimes(1);
|
||||
expect(aEffect).not.toHaveBeenCalled();
|
||||
expect(bEffect).toHaveBeenCalledTimes(1);
|
||||
expect(cEffect).toHaveBeenCalledTimes(1);
|
||||
|
||||
// assert validate trigger
|
||||
expect(bValidate).not.toHaveBeenCalled();
|
||||
expect(cValidate).not.toHaveBeenCalled();
|
||||
expect(arrayField.validate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('delete last element', () => {
|
||||
arrayField.delete(2);
|
||||
expect(arrayField.children.length).toBe(2);
|
||||
expect(arrayField.children[0].value).toBe('a');
|
||||
expect(arrayField.children[1].value).toBe('b');
|
||||
expect(formModel.getField('other')).toBeDefined();
|
||||
expect(arrEffect).toHaveBeenCalled();
|
||||
expect(cEffect).toHaveBeenCalled();
|
||||
});
|
||||
it('delete element which has nested field', () => {
|
||||
vi.spyOn(arrayField, 'validate');
|
||||
const axField = formModel.createField('arr.0.x');
|
||||
const bxField = formModel.createField('arr.1.x');
|
||||
|
||||
vi.spyOn(axField, 'validate');
|
||||
vi.spyOn(bxField, 'validate');
|
||||
|
||||
formModel.setValueIn('arr.0', { x: 1 });
|
||||
formModel.setValueIn('arr.1', { x: 2 });
|
||||
|
||||
expect(arrayField.value).toEqual([{ x: 1 }, { x: 2 }, 'c']);
|
||||
|
||||
arrayField.delete(0);
|
||||
|
||||
expect(arrayField.value).toEqual([{ x: 2 }, 'c']);
|
||||
|
||||
// assert change events
|
||||
expect(aEffect).toHaveBeenCalledTimes(2); // setValueIn 触发一次, delete 触发一次
|
||||
expect(bEffect).toHaveBeenCalledTimes(2); // setValueIn 触发一次, delete 触发一次
|
||||
expect(cEffect).toHaveBeenCalledTimes(1);
|
||||
|
||||
// assert validate trigger
|
||||
expect(aValidate).toHaveBeenCalledTimes(1); // setValueIn 触发一次, delete 不会触发
|
||||
expect(bValidate).toHaveBeenCalledTimes(1); // setValueIn 触发一次, delete 不会触发
|
||||
expect(cValidate).not.toHaveBeenCalled();
|
||||
expect(axField.validate).toHaveBeenCalledTimes(1); // setValueIn 触发一次, delete 不会触发
|
||||
expect(bxField.validate).toHaveBeenCalledTimes(1); // setValueIn 触发一次, delete 不会触发
|
||||
});
|
||||
it('more elements delete', () => {
|
||||
/**
|
||||
* 数组为 [a,b,c,d]
|
||||
* 删除 b
|
||||
* 希望数组值为 [a,c,d]
|
||||
* 希望formModel中的field也正确对应
|
||||
*/
|
||||
arrayField.append('d');
|
||||
|
||||
vi.spyOn(arrayField, 'validate');
|
||||
|
||||
arrayField.delete(1);
|
||||
|
||||
// assert values
|
||||
expect(arrayField.children.length).toBe(3);
|
||||
expect(arrayField.children[0].value).toBe('a');
|
||||
expect(arrayField.children[1].value).toBe('c');
|
||||
expect(arrayField.children[2].value).toBe('d');
|
||||
expect(formModel.getField('arr.2')?.value).toBe('d');
|
||||
expect(formModel.getField('other')).toBeDefined();
|
||||
|
||||
// assert value change events
|
||||
expect(arrEffect).toHaveBeenCalled();
|
||||
expect(bEffect).toHaveBeenCalled();
|
||||
expect(cEffect).toHaveBeenCalled();
|
||||
expect(arrayField.validate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
describe('_splice', () => {
|
||||
let arrayField: FieldArrayModel;
|
||||
let aEffect = vi.fn();
|
||||
let bEffect = vi.fn();
|
||||
let cEffect = vi.fn();
|
||||
let dEffect = vi.fn();
|
||||
let eEffect = vi.fn();
|
||||
let aValidate = vi.fn();
|
||||
let bValidate = vi.fn();
|
||||
let cValidate = vi.fn();
|
||||
let dValidate = vi.fn();
|
||||
let eValidate = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
formModel.dispose();
|
||||
formModel = new FormModel();
|
||||
aEffect = vi.fn();
|
||||
bEffect = vi.fn();
|
||||
cEffect = vi.fn();
|
||||
dEffect = vi.fn();
|
||||
eEffect = vi.fn();
|
||||
aValidate = vi.fn();
|
||||
bValidate = vi.fn();
|
||||
cValidate = vi.fn();
|
||||
dValidate = vi.fn();
|
||||
eValidate = vi.fn();
|
||||
formModel.createFieldArray('arr');
|
||||
formModel.init({
|
||||
validateTrigger: ValidateTrigger.onChange,
|
||||
validate: {
|
||||
['arr.0']: aValidate,
|
||||
['arr.1']: bValidate,
|
||||
['arr.2']: cValidate,
|
||||
['arr.3']: dValidate,
|
||||
['arr.4']: eValidate,
|
||||
},
|
||||
});
|
||||
const field = formModel.getField<FieldArrayModel>('arr');
|
||||
const aField = field!.append('a');
|
||||
const bField = field!.append('b');
|
||||
const cField = field!.append('c');
|
||||
const dField = field!.append('d');
|
||||
const eField = field!.append('e');
|
||||
|
||||
aField.onValueChange(aEffect);
|
||||
bField.onValueChange(bEffect);
|
||||
cField.onValueChange(cEffect);
|
||||
dField.onValueChange(dEffect);
|
||||
eField.onValueChange(eEffect);
|
||||
|
||||
arrayField = field!;
|
||||
|
||||
vi.spyOn(arrayField, 'validate');
|
||||
vi.spyOn(arrayField.onValueChangeEmitter, 'fire');
|
||||
});
|
||||
|
||||
it('should throw error when delete count exceeds array length', () => {
|
||||
expect(() => {
|
||||
arrayField._splice(0, 6);
|
||||
}).toThrowError();
|
||||
});
|
||||
|
||||
it('should throw error when delete in empty array', () => {
|
||||
arrayField._splice(0, 5);
|
||||
|
||||
expect(() => {
|
||||
arrayField._splice(0);
|
||||
}).toThrowError();
|
||||
});
|
||||
|
||||
it('splice first 2', () => {
|
||||
arrayField._splice(0, 2);
|
||||
|
||||
// assert values
|
||||
expect(arrayField.children.length).toBe(3);
|
||||
expect(arrayField.children[0].value).toBe('c');
|
||||
expect(arrayField.children[1].value).toBe('d');
|
||||
expect(arrayField.children[2].value).toBe('e');
|
||||
|
||||
// assert value change events
|
||||
expect(arrayField.onValueChangeEmitter.fire).toHaveBeenCalledTimes(1);
|
||||
expect(aEffect).toHaveBeenCalledTimes(1);
|
||||
expect(bEffect).toHaveBeenCalledTimes(1);
|
||||
expect(cEffect).toHaveBeenCalledTimes(1);
|
||||
expect(dEffect).toHaveBeenCalledTimes(1);
|
||||
expect(eEffect).toHaveBeenCalledTimes(1);
|
||||
|
||||
// assert validate trigger
|
||||
expect(arrayField.validate).toHaveBeenCalledTimes(1);
|
||||
expect(aValidate).not.toHaveBeenCalled();
|
||||
expect(bValidate).not.toHaveBeenCalled();
|
||||
expect(cValidate).not.toHaveBeenCalled();
|
||||
expect(dValidate).not.toHaveBeenCalled();
|
||||
expect(eValidate).not.toHaveBeenCalled();
|
||||
});
|
||||
it('splice last 2', () => {
|
||||
arrayField._splice(3, 2);
|
||||
|
||||
// assert values
|
||||
expect(arrayField.children.length).toBe(3);
|
||||
expect(arrayField.children[0].value).toBe('a');
|
||||
expect(arrayField.children[1].value).toBe('b');
|
||||
expect(arrayField.children[2].value).toBe('c');
|
||||
|
||||
// assert value change events
|
||||
expect(arrayField.onValueChangeEmitter.fire).toHaveBeenCalledTimes(1);
|
||||
expect(aEffect).not.toHaveBeenCalled();
|
||||
expect(bEffect).not.toHaveBeenCalled();
|
||||
expect(cEffect).not.toHaveBeenCalled();
|
||||
expect(dEffect).toHaveBeenCalledTimes(1);
|
||||
expect(eEffect).toHaveBeenCalledTimes(1);
|
||||
|
||||
// assert validate trigger
|
||||
expect(arrayField.validate).toHaveBeenCalledTimes(1);
|
||||
expect(aValidate).not.toHaveBeenCalled();
|
||||
expect(bValidate).not.toHaveBeenCalled();
|
||||
expect(cValidate).not.toHaveBeenCalled();
|
||||
expect(dValidate).not.toHaveBeenCalled();
|
||||
expect(eValidate).not.toHaveBeenCalled();
|
||||
});
|
||||
it('splice middle elements', () => {
|
||||
arrayField._splice(1, 2);
|
||||
|
||||
// assert values
|
||||
expect(arrayField.children.length).toBe(3);
|
||||
expect(arrayField.children[0].value).toBe('a');
|
||||
expect(arrayField.children[1].value).toBe('d');
|
||||
expect(arrayField.children[2].value).toBe('e');
|
||||
|
||||
// assert value change events
|
||||
expect(arrayField.onValueChangeEmitter.fire).toHaveBeenCalledTimes(1);
|
||||
expect(aEffect).not.toHaveBeenCalled();
|
||||
expect(bEffect).toHaveBeenCalledTimes(1);
|
||||
expect(cEffect).toHaveBeenCalledTimes(1);
|
||||
expect(dEffect).toHaveBeenCalledTimes(1);
|
||||
expect(eEffect).toHaveBeenCalledTimes(1);
|
||||
|
||||
// assert validate trigger
|
||||
expect(arrayField.validate).toHaveBeenCalledTimes(1);
|
||||
expect(aValidate).not.toHaveBeenCalled();
|
||||
expect(bValidate).not.toHaveBeenCalled();
|
||||
expect(cValidate).not.toHaveBeenCalled();
|
||||
expect(dValidate).not.toHaveBeenCalled();
|
||||
expect(eValidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('splice all elements', () => {
|
||||
arrayField._splice(0, 5);
|
||||
|
||||
expect(arrayField.children.length).toBe(0);
|
||||
expect(arrayField.value).toEqual([]);
|
||||
|
||||
// assert value change events
|
||||
expect(arrayField.onValueChangeEmitter.fire).toHaveBeenCalledTimes(1);
|
||||
expect(aEffect).toHaveBeenCalledTimes(1);
|
||||
expect(bEffect).toHaveBeenCalledTimes(1);
|
||||
expect(cEffect).toHaveBeenCalledTimes(1);
|
||||
expect(dEffect).toHaveBeenCalledTimes(1);
|
||||
expect(eEffect).toHaveBeenCalledTimes(1);
|
||||
|
||||
// assert validate trigger
|
||||
expect(arrayField.validate).toHaveBeenCalledTimes(1);
|
||||
expect(aValidate).not.toHaveBeenCalled();
|
||||
expect(bValidate).not.toHaveBeenCalled();
|
||||
expect(cValidate).not.toHaveBeenCalled();
|
||||
expect(dValidate).not.toHaveBeenCalled();
|
||||
expect(eValidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('State check when _splice', () => {
|
||||
beforeEach(() => {
|
||||
formModel.dispose();
|
||||
formModel = new FormModel();
|
||||
});
|
||||
it('should keep state of rest fields after delete a prev field', () => {
|
||||
const arrayField = formModel.createFieldArray('arr');
|
||||
formModel.init({
|
||||
validateTrigger: ValidateTrigger.onChange,
|
||||
});
|
||||
arrayField!.append('a');
|
||||
arrayField!.append('b');
|
||||
arrayField!.append('c');
|
||||
|
||||
// 设置第1项的state
|
||||
const aFieldModel = formModel.getField('arr.1');
|
||||
aFieldModel!.state.errors = {
|
||||
'arr.1': [{ name: 'arr.1', message: 'error' }],
|
||||
} as unknown as Errors;
|
||||
aFieldModel!.state.warnings = {
|
||||
'arr.1': [{ name: 'arr.1', message: 'warning' }],
|
||||
} as unknown as Warnings;
|
||||
|
||||
// 删除第0项
|
||||
arrayField._splice(0);
|
||||
|
||||
// 原第一项变为第0项且他的state 被保留了, 且errors 中的路径标识也更新了
|
||||
expect(formModel.getField('arr.0')!.state.errors).toEqual({
|
||||
'arr.0': [{ name: 'arr.0', message: 'error' }],
|
||||
});
|
||||
expect(formModel.getField('arr.0')!.state.warnings).toEqual({
|
||||
'arr.0': [{ name: 'arr.0', message: 'warning' }],
|
||||
});
|
||||
expect(formModel.getField('arr.2')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should keep state of rest fields after delete a prev field, when nested field', () => {
|
||||
const arrayField = formModel.createFieldArray('arr');
|
||||
formModel.init({
|
||||
validateTrigger: ValidateTrigger.onChange,
|
||||
initialValues: {
|
||||
arr: [
|
||||
{ x: 1, y: 2 },
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0, y: 0 },
|
||||
],
|
||||
},
|
||||
});
|
||||
formModel.createField('arr.0');
|
||||
formModel.createField('arr.0.x');
|
||||
formModel.createField('arr.0.y');
|
||||
formModel.createField('arr.1');
|
||||
formModel.createField('arr.1.x');
|
||||
formModel.createField('arr.1.y');
|
||||
formModel.createField('arr.2');
|
||||
formModel.createField('arr.2.x');
|
||||
formModel.createField('arr.2.y');
|
||||
|
||||
// 设置第1项的state
|
||||
const aFieldModel = formModel.getField('arr.1.x');
|
||||
aFieldModel!.state.errors = {
|
||||
'arr.1.x': [{ name: 'arr.1.x', message: 'error' }],
|
||||
} as unknown as Errors;
|
||||
|
||||
// 删除第0项
|
||||
arrayField._splice(0);
|
||||
|
||||
// 原第一项变为第0项且他的state errors 被保留了, 且errors 中的路径标识也更新了
|
||||
expect(formModel.getField('arr.0')!.state.errors).toEqual({
|
||||
'arr.0.x': [{ name: 'arr.0.x', message: 'error' }],
|
||||
});
|
||||
expect(formModel.getField('arr.0.x')!.state.errors).toEqual({
|
||||
'arr.0.x': [{ name: 'arr.0.x', message: 'error' }],
|
||||
});
|
||||
expect(formModel.getField('arr.2')).toBeUndefined();
|
||||
expect(formModel.getField('arr.2.x')).toBeUndefined();
|
||||
expect(formModel.getField('arr.2.y')).toBeUndefined();
|
||||
});
|
||||
it('should align errors and warnings state with existing field in fieldMap ', () => {
|
||||
const arrayField = formModel.createFieldArray('arr');
|
||||
formModel.init({
|
||||
validateTrigger: ValidateTrigger.onChange,
|
||||
initialValues: {
|
||||
arr: [
|
||||
{ x: 1, y: 2 },
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0, y: 0 },
|
||||
],
|
||||
},
|
||||
});
|
||||
const field0 = formModel.createField('arr.0');
|
||||
const field0x = formModel.createField('arr.0.x');
|
||||
const field0y = formModel.createField('arr.0.y');
|
||||
const field1 = formModel.createField('arr.1');
|
||||
const field1x = formModel.createField('arr.1.x');
|
||||
const field1y = formModel.createField('arr.1.y');
|
||||
const field2 = formModel.createField('arr.2');
|
||||
const field2x = formModel.createField('arr.2.x');
|
||||
const field2y = formModel.createField('arr.2.y');
|
||||
|
||||
field0x.state.errors = {
|
||||
'arr.0.x': [{ name: 'arr.0.x', message: 'error' }],
|
||||
} as unknown as Errors;
|
||||
field0x.bubbleState();
|
||||
|
||||
field1x.state.errors = {
|
||||
'arr.1.x': [{ name: 'arr.1.x', message: 'error' }],
|
||||
} as unknown as Errors;
|
||||
field1x.bubbleState();
|
||||
|
||||
field2x.state.errors = {
|
||||
'arr.2.x': [{ name: 'arr.2.x', message: 'error' }],
|
||||
} as unknown as Errors;
|
||||
|
||||
// 删除第0项
|
||||
arrayField._splice(0);
|
||||
|
||||
expect(formModel.state.errors['arr.0.x']).toEqual([{ name: 'arr.0.x', message: 'error' }]);
|
||||
expect(formModel.state.errors['arr.1.x']).toEqual([{ name: 'arr.1.x', message: 'error' }]);
|
||||
expect(formModel.state.errors['arr.2.x']).toBeUndefined();
|
||||
|
||||
expect(field0.state.errors['arr.0.x']).toEqual([{ name: 'arr.0.x', message: 'error' }]);
|
||||
expect(field1.state.errors['arr.1.x']).toEqual([{ name: 'arr.1.x', message: 'error' }]);
|
||||
|
||||
expect(arrayField.state.errors['arr.0.x']).toEqual([{ name: 'arr.0.x', message: 'error' }]);
|
||||
expect(arrayField.state.errors['arr.1.x']).toEqual([{ name: 'arr.1.x', message: 'error' }]);
|
||||
expect(arrayField.state.errors['arr.2.x']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not keep previous error state when delete first elem in array then add back ', () => {
|
||||
const arrayField = formModel.createFieldArray('arr');
|
||||
formModel.init({
|
||||
validateTrigger: ValidateTrigger.onChange,
|
||||
initialValues: {
|
||||
arr: [{ x: 1, y: 2 }],
|
||||
},
|
||||
});
|
||||
const field0 = formModel.createField('arr.0');
|
||||
const field0x = formModel.createField('arr.0.x');
|
||||
const field0y = formModel.createField('arr.0.y');
|
||||
|
||||
field0x.state.errors = {
|
||||
'arr.0.x': [{ name: 'arr.0.x', message: 'error' }],
|
||||
} as unknown as Errors;
|
||||
field0x.bubbleState();
|
||||
|
||||
// 删除第0项
|
||||
arrayField._splice(0);
|
||||
expect(formModel.state.errors['arr.0.x']).toBeUndefined();
|
||||
expect(arrayField.state.errors['arr.0.x']).toBeUndefined();
|
||||
expect(formModel._fieldMap.get('arr.0')).toBeUndefined();
|
||||
|
||||
arrayField.append({ x: 1, y: 2 });
|
||||
formModel.createField('arr.0.x');
|
||||
expect(formModel._fieldMap.get('arr.0')).toBeDefined();
|
||||
expect(formModel.state.errors['arr.0.x']).toBeUndefined();
|
||||
expect(formModel._fieldMap.get('arr.0.x').state.errors).toBeUndefined();
|
||||
});
|
||||
});
|
||||
describe('swap', () => {
|
||||
beforeEach(() => {
|
||||
formModel.dispose();
|
||||
formModel = new FormModel();
|
||||
});
|
||||
|
||||
it('can swap from 0 to middle index', () => {
|
||||
const arrayField = formModel.createFieldArray('arr');
|
||||
const a = arrayField!.append('a');
|
||||
const b = arrayField!.append('b');
|
||||
const c = arrayField!.append('c');
|
||||
|
||||
formModel.init({});
|
||||
|
||||
a.state.errors = {
|
||||
'arr.0': [{ name: 'arr.0', message: 'err0', level: FeedbackLevel.Error }],
|
||||
};
|
||||
b.state.errors = {
|
||||
'arr.1': [{ name: 'arr.1', message: 'err1', level: FeedbackLevel.Error }],
|
||||
};
|
||||
|
||||
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c'] });
|
||||
arrayField.swap(0, 1);
|
||||
expect(formModel.values).toEqual({ arr: ['b', 'a', 'c'] });
|
||||
expect(formModel.getField('arr.0').state.errors).toEqual({
|
||||
'arr.0': [{ name: 'arr.0', message: 'err1', level: FeedbackLevel.Error }],
|
||||
});
|
||||
expect(formModel.getField('arr.1').state.errors).toEqual({
|
||||
'arr.1': [{ name: 'arr.1', message: 'err0', level: FeedbackLevel.Error }],
|
||||
});
|
||||
});
|
||||
it('can chained swap', () => {
|
||||
const arrayField = formModel.createFieldArray('x.arr');
|
||||
const a = arrayField!.append('a');
|
||||
const b = arrayField!.append('b');
|
||||
arrayField!.append('c');
|
||||
|
||||
formModel.init({});
|
||||
|
||||
a.state.errors = {
|
||||
'arr.0': [{ name: 'arr.0', message: 'err0', level: FeedbackLevel.Error }],
|
||||
};
|
||||
b.state.errors = {
|
||||
'arr.1': [{ name: 'arr.1', message: 'err1', level: FeedbackLevel.Error }],
|
||||
};
|
||||
|
||||
expect(a.name).toBe('x.arr.0');
|
||||
expect(b.name).toBe('x.arr.1');
|
||||
expect(formModel.values.x).toEqual({ arr: ['a', 'b', 'c'] });
|
||||
|
||||
arrayField.swap(1, 0);
|
||||
expect(a.name).toBe('x.arr.1');
|
||||
expect(b.name).toBe('x.arr.0');
|
||||
expect(formModel.values.x).toEqual({ arr: ['b', 'a', 'c'] });
|
||||
|
||||
arrayField.swap(1, 0);
|
||||
expect(a.name).toBe('x.arr.0');
|
||||
expect(formModel.fieldMap.get('x.arr.0').name).toBe('x.arr.0');
|
||||
expect(b.name).toBe('x.arr.1');
|
||||
expect(formModel.fieldMap.get('x.arr.1').name).toBe('x.arr.1');
|
||||
expect(formModel.values.x).toEqual({ arr: ['a', 'b', 'c'] });
|
||||
|
||||
arrayField.swap(1, 0);
|
||||
expect(a.name).toBe('x.arr.1');
|
||||
expect(formModel.fieldMap.get('x.arr.1').name).toBe('x.arr.1');
|
||||
expect(b.name).toBe('x.arr.0');
|
||||
expect(formModel.fieldMap.get('x.arr.0').name).toBe('x.arr.0');
|
||||
|
||||
expect(formModel.values.x).toEqual({ arr: ['b', 'a', 'c'] });
|
||||
});
|
||||
|
||||
it('can swap from 0 to last index', () => {
|
||||
const arrayField = formModel.createFieldArray('arr');
|
||||
const a = arrayField!.append('a');
|
||||
const b = arrayField!.append('b');
|
||||
const c = arrayField!.append('c');
|
||||
|
||||
formModel.init({});
|
||||
|
||||
a.state.errors = {
|
||||
'arr.0': [{ name: 'arr.0', message: 'err0', level: FeedbackLevel.Error }],
|
||||
};
|
||||
c.state.errors = {
|
||||
'arr.2': [{ name: 'arr.2', message: 'err2', level: FeedbackLevel.Error }],
|
||||
};
|
||||
|
||||
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c'] });
|
||||
arrayField.swap(0, 2);
|
||||
expect(formModel.values).toEqual({ arr: ['c', 'b', 'a'] });
|
||||
expect(formModel.getField('arr.0').state.errors).toEqual({
|
||||
'arr.0': [{ name: 'arr.0', message: 'err2', level: FeedbackLevel.Error }],
|
||||
});
|
||||
expect(formModel.getField('arr.2').state.errors).toEqual({
|
||||
'arr.2': [{ name: 'arr.2', message: 'err0', level: FeedbackLevel.Error }],
|
||||
});
|
||||
});
|
||||
it('can swap from middle index to last index', () => {
|
||||
const arrayField = formModel.createFieldArray('arr');
|
||||
const a = arrayField!.append('a');
|
||||
const b = arrayField!.append('b');
|
||||
const c = arrayField!.append('c');
|
||||
|
||||
formModel.init({});
|
||||
|
||||
b.state.errors = {
|
||||
'arr.1': [{ name: 'arr.1', message: 'err1', level: FeedbackLevel.Error }],
|
||||
};
|
||||
c.state.errors = {
|
||||
'arr.2': [{ name: 'arr.2', message: 'err2', level: FeedbackLevel.Error }],
|
||||
};
|
||||
|
||||
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c'] });
|
||||
arrayField.swap(1, 2);
|
||||
expect(formModel.values).toEqual({ arr: ['a', 'c', 'b'] });
|
||||
expect(formModel.getField('arr.1').state.errors).toEqual({
|
||||
'arr.1': [{ name: 'arr.1', message: 'err2', level: FeedbackLevel.Error }],
|
||||
});
|
||||
expect(formModel.getField('arr.2').state.errors).toEqual({
|
||||
'arr.2': [{ name: 'arr.2', message: 'err1', level: FeedbackLevel.Error }],
|
||||
});
|
||||
});
|
||||
it('can swap from middle index to another middle index', () => {
|
||||
const arrayField = formModel.createFieldArray('arr');
|
||||
arrayField!.append('a');
|
||||
const b = arrayField!.append('b');
|
||||
const c = arrayField!.append('c');
|
||||
arrayField!.append('d');
|
||||
|
||||
formModel.init({});
|
||||
|
||||
b.state.errors = {
|
||||
'arr.1': [{ name: 'arr.1', message: 'err1', level: FeedbackLevel.Error }],
|
||||
};
|
||||
c.state.errors = {
|
||||
'arr.2': [{ name: 'arr.2', message: 'err2', level: FeedbackLevel.Error }],
|
||||
};
|
||||
|
||||
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c', 'd'] });
|
||||
arrayField.swap(1, 2);
|
||||
expect(formModel.values).toEqual({ arr: ['a', 'c', 'b', 'd'] });
|
||||
expect(formModel.getField('arr.1').state.errors).toEqual({
|
||||
'arr.1': [{ name: 'arr.1', message: 'err2', level: FeedbackLevel.Error }],
|
||||
});
|
||||
expect(formModel.getField('arr.2').state.errors).toEqual({
|
||||
'arr.2': [{ name: 'arr.2', message: 'err1', level: FeedbackLevel.Error }],
|
||||
});
|
||||
});
|
||||
|
||||
it('can swap for nested array', () => {
|
||||
const arrayField = formModel.createFieldArray('arr');
|
||||
const a = arrayField!.append({ x: 'x0', y: 'y0' });
|
||||
const b = arrayField!.append({ x: 'x1', y: 'y1' });
|
||||
const ax = formModel.createField('arr.0.x');
|
||||
const ay = formModel.createField('arr.0.y');
|
||||
const bx = formModel.createField('arr.1.x');
|
||||
const by = formModel.createField('arr.1.y');
|
||||
|
||||
formModel.init({});
|
||||
|
||||
ax.state.errors = {
|
||||
'arr.0.x': [{ name: 'arr.0.x', message: 'err0x', level: FeedbackLevel.Error }],
|
||||
};
|
||||
bx.state.errors = {
|
||||
'arr.1.x': [{ name: 'arr.1.x', message: 'err1x', level: FeedbackLevel.Error }],
|
||||
};
|
||||
|
||||
expect(formModel.values).toEqual({
|
||||
arr: [
|
||||
{ x: 'x0', y: 'y0' },
|
||||
{ x: 'x1', y: 'y1' },
|
||||
],
|
||||
});
|
||||
arrayField.swap(0, 1);
|
||||
expect(formModel.values).toEqual({
|
||||
arr: [
|
||||
{ x: 'x1', y: 'y1' },
|
||||
{ x: 'x0', y: 'y0' },
|
||||
],
|
||||
});
|
||||
expect(formModel.getField('arr.0.x').state.errors).toEqual({
|
||||
'arr.0.x': [{ name: 'arr.0.x', message: 'err1x', level: FeedbackLevel.Error }],
|
||||
});
|
||||
expect(formModel.getField('arr.1.x').state.errors).toEqual({
|
||||
'arr.1.x': [{ name: 'arr.1.x', message: 'err0x', level: FeedbackLevel.Error }],
|
||||
});
|
||||
|
||||
// assert form.state.errors
|
||||
expect(formModel.state.errors['arr.0.x']).toEqual([
|
||||
{ name: 'arr.0.x', message: 'err1x', level: FeedbackLevel.Error },
|
||||
]);
|
||||
expect(formModel.state.errors['arr.1.x']).toEqual([
|
||||
{ name: 'arr.1.x', message: 'err0x', level: FeedbackLevel.Error },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should have correct form.state.errors after swapping invalid field with valid field', () => {
|
||||
const arrayField = formModel.createFieldArray('arr');
|
||||
const a = arrayField!.append('a');
|
||||
const b = arrayField!.append('b');
|
||||
arrayField!.append('c');
|
||||
|
||||
formModel.init({});
|
||||
|
||||
b.state.errors = {
|
||||
'arr.1': [{ name: 'arr.1', message: 'err1', level: FeedbackLevel.Error }],
|
||||
};
|
||||
|
||||
arrayField.swap(0, 1);
|
||||
expect(formModel.getField('arr.0').state.errors).toEqual({
|
||||
'arr.0': [{ name: 'arr.0', message: 'err1', level: FeedbackLevel.Error }],
|
||||
});
|
||||
expect(formModel.getField('arr.1').state.errors).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('should trigger array effect and child effect', () => {
|
||||
const arrayField = formModel.createFieldArray('arr');
|
||||
const fieldA = arrayField!.append('a');
|
||||
arrayField!.append('b');
|
||||
arrayField!.append('c');
|
||||
|
||||
const arrayEffect = vi.fn();
|
||||
arrayField.onValueChange(arrayEffect);
|
||||
const fieldAEffect = vi.fn();
|
||||
fieldA.onValueChange(fieldAEffect);
|
||||
|
||||
formModel.init({});
|
||||
|
||||
arrayField.swap(1, 2);
|
||||
expect(arrayEffect).toHaveBeenCalledOnce();
|
||||
expect(fieldAEffect).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
describe('move', () => {
|
||||
beforeEach(() => {
|
||||
formModel.dispose();
|
||||
formModel = new FormModel();
|
||||
});
|
||||
|
||||
it('should throw error when from or to exceeds bound', () => {
|
||||
const arrayField = formModel.createFieldArray('arr');
|
||||
arrayField!.append('a');
|
||||
arrayField!.append('b');
|
||||
arrayField!.append('c');
|
||||
formModel.init({});
|
||||
expect(() => arrayField.move(-1, 1)).toThrowError();
|
||||
expect(() => arrayField.move(1, -1)).toThrowError();
|
||||
expect(() => arrayField.move(1, 3)).toThrowError();
|
||||
expect(() => arrayField.move(3, 1)).toThrowError();
|
||||
});
|
||||
|
||||
it('can move from 0 to middle index', () => {
|
||||
const arrayField = formModel.createFieldArray('arr');
|
||||
arrayField!.append('a');
|
||||
arrayField!.append('b');
|
||||
arrayField!.append('c');
|
||||
|
||||
formModel.init({});
|
||||
|
||||
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c'] });
|
||||
arrayField.move(0, 1);
|
||||
expect(formModel.values).toEqual({ arr: ['b', 'a', 'c'] });
|
||||
});
|
||||
|
||||
it('can move from 0 to last index', () => {
|
||||
const arrayField = formModel.createFieldArray('arr');
|
||||
arrayField!.append('a');
|
||||
arrayField!.append('b');
|
||||
arrayField!.append('c');
|
||||
|
||||
formModel.init({});
|
||||
|
||||
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c'] });
|
||||
arrayField.move(0, 2);
|
||||
expect(formModel.values).toEqual({ arr: ['b', 'c', 'a'] });
|
||||
});
|
||||
it('can move from middle index to last index', () => {
|
||||
const arrayField = formModel.createFieldArray('arr');
|
||||
arrayField!.append('a');
|
||||
arrayField!.append('b');
|
||||
arrayField!.append('c');
|
||||
formModel.init({});
|
||||
|
||||
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c'] });
|
||||
arrayField.move(1, 2);
|
||||
expect(formModel.values).toEqual({ arr: ['a', 'c', 'b'] });
|
||||
});
|
||||
it('can move from middle index to another middle index', () => {
|
||||
const arrayField = formModel.createFieldArray('arr');
|
||||
arrayField!.append('a');
|
||||
arrayField!.append('b');
|
||||
arrayField!.append('c');
|
||||
arrayField!.append('d');
|
||||
|
||||
formModel.init({});
|
||||
|
||||
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c', 'd'] });
|
||||
arrayField.move(1, 2);
|
||||
expect(formModel.values).toEqual({ arr: ['a', 'c', 'b', 'd'] });
|
||||
});
|
||||
|
||||
it('can move from middle index to another middle index with more elements', () => {
|
||||
const arrayField = formModel.createFieldArray('arr');
|
||||
arrayField!.append('a');
|
||||
arrayField!.append('b');
|
||||
arrayField!.append('c');
|
||||
arrayField!.append('d');
|
||||
arrayField!.append('e');
|
||||
arrayField!.append('f');
|
||||
|
||||
formModel.init({});
|
||||
|
||||
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c', 'd', 'e', 'f'] });
|
||||
arrayField.move(1, 4);
|
||||
expect(formModel.values).toEqual({ arr: ['a', 'c', 'd', 'e', 'b', 'f'] });
|
||||
});
|
||||
it('can move from middle index to another middle index with more elements when to is greater than from', () => {
|
||||
const arrayField = formModel.createFieldArray('arr');
|
||||
arrayField!.append('a');
|
||||
arrayField!.append('b');
|
||||
arrayField!.append('c');
|
||||
arrayField!.append('d');
|
||||
arrayField!.append('e');
|
||||
arrayField!.append('f');
|
||||
|
||||
formModel.init({});
|
||||
|
||||
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c', 'd', 'e', 'f'] });
|
||||
arrayField.move(4, 1);
|
||||
expect(formModel.values).toEqual({ arr: ['a', 'e', 'b', 'c', 'd', 'f'] });
|
||||
});
|
||||
|
||||
it('should trigger array effect and child effect', () => {
|
||||
const arrayField = formModel.createFieldArray('arr');
|
||||
const fieldA = arrayField!.append('a');
|
||||
arrayField!.append('b');
|
||||
arrayField!.append('c');
|
||||
|
||||
const arrayEffect = vi.fn();
|
||||
arrayField.onValueChange(arrayEffect);
|
||||
const fieldAEffect = vi.fn();
|
||||
fieldA.onValueChange(fieldAEffect);
|
||||
|
||||
formModel.init({});
|
||||
|
||||
arrayField.move(1, 2);
|
||||
expect(arrayEffect).toHaveBeenCalledOnce();
|
||||
expect(fieldAEffect).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Errors, FeedbackLevel, ValidateTrigger } from '@/types';
|
||||
import { FormModel } from '@/core/form-model';
|
||||
|
||||
describe('FieldModel', () => {
|
||||
let formModel = new FormModel();
|
||||
describe('state', () => {
|
||||
beforeEach(() => {
|
||||
formModel.dispose();
|
||||
formModel = new FormModel();
|
||||
});
|
||||
|
||||
it('can bubble', () => {
|
||||
formModel.createField('parent');
|
||||
formModel.createField('parent.child');
|
||||
const childField = formModel.getField('parent.child')!;
|
||||
const parentField = formModel.getField('parent')!;
|
||||
|
||||
childField.value = 1;
|
||||
expect(childField.state.isTouched).toBe(true);
|
||||
expect(parentField.state.isTouched).toBe(true);
|
||||
expect(formModel.state.isTouched).toBe(true);
|
||||
});
|
||||
|
||||
it('can bubble with array', () => {
|
||||
formModel.createField('parent');
|
||||
formModel.createField('parent.arr');
|
||||
formModel.createField('parent.arr.1');
|
||||
const arrChild = formModel.getField('parent.arr.1')!;
|
||||
const arrField = formModel.getField('parent.arr')!;
|
||||
const parentField = formModel.getField('parent')!;
|
||||
|
||||
arrChild.value = 1;
|
||||
expect(arrChild.state.isTouched).toBe(true);
|
||||
expect(arrField.state.isTouched).toBe(true);
|
||||
expect(parentField.state.isTouched).toBe(true);
|
||||
expect(formModel.state.isTouched).toBe(true);
|
||||
});
|
||||
|
||||
it('do not set isTouched for init value set', () => {
|
||||
formModel.createField('parent');
|
||||
formModel.createField('parent.child');
|
||||
const childField = formModel.getField('parent.child')!;
|
||||
const parentField = formModel.getField('parent')!;
|
||||
|
||||
expect(childField.state.isTouched).toBe(false);
|
||||
expect(parentField.state.isTouched).toBe(false);
|
||||
expect(formModel.state.isTouched).toBe(false);
|
||||
});
|
||||
});
|
||||
describe('validate', () => {
|
||||
beforeEach(() => {
|
||||
formModel.dispose();
|
||||
formModel = new FormModel();
|
||||
});
|
||||
|
||||
it('when validate func return only a message', async () => {
|
||||
formModel.init({ validate: { 'parent.*': () => 'some message' } });
|
||||
formModel.createField('parent');
|
||||
formModel.createField('parent.child');
|
||||
const childField = formModel.getField('parent.child')!;
|
||||
|
||||
expect(childField.state.errors).toBeUndefined();
|
||||
|
||||
await childField.validate();
|
||||
|
||||
expect(childField.state.errors?.['parent.child'][0].message).toBe('some message');
|
||||
expect(childField.state.errors?.['parent.child'][0].level).toBe(FeedbackLevel.Error);
|
||||
});
|
||||
|
||||
it('when validate func return a FieldWarning', async () => {
|
||||
formModel.init({
|
||||
validate: {
|
||||
'parent.*': () => ({
|
||||
level: FeedbackLevel.Warning,
|
||||
message: 'some message',
|
||||
}),
|
||||
},
|
||||
});
|
||||
formModel.createField('parent');
|
||||
formModel.createField('parent.child');
|
||||
const childField = formModel.getField('parent.child')!;
|
||||
|
||||
expect(childField.state.errors).toBeUndefined();
|
||||
|
||||
await childField.validate();
|
||||
|
||||
expect(childField.state.warnings?.['parent.child'][0].message).toBe('some message');
|
||||
expect(childField.state.warnings?.['parent.child'][0].level).toBe(FeedbackLevel.Warning);
|
||||
});
|
||||
it('when validate return a FormError', async () => {
|
||||
formModel.init({
|
||||
validate: {
|
||||
'parent.*': () => ({
|
||||
level: FeedbackLevel.Error,
|
||||
message: 'some message',
|
||||
}),
|
||||
},
|
||||
});
|
||||
formModel.createField('parent');
|
||||
formModel.createField('parent.child');
|
||||
const childField = formModel.getField('parent.child')!;
|
||||
|
||||
expect(childField.state.errors?.length).toBeUndefined();
|
||||
|
||||
await childField.validate();
|
||||
|
||||
expect(childField.state.errors?.['parent.child'][0].message).toBe('some message');
|
||||
expect(childField.state.errors?.['parent.child'][0].level).toBe(FeedbackLevel.Error);
|
||||
});
|
||||
it('should bubble errors to parent field', async () => {
|
||||
formModel.init({
|
||||
validate: {
|
||||
'parent.*': () => ({
|
||||
level: FeedbackLevel.Error,
|
||||
message: 'some message',
|
||||
}),
|
||||
},
|
||||
});
|
||||
formModel.createField('parent');
|
||||
formModel.createField('parent.child');
|
||||
const childField = formModel.getField('parent.child')!;
|
||||
const parentField = formModel.getField('parent')!;
|
||||
|
||||
await childField.validate();
|
||||
|
||||
expect(parentField.state.errors?.['parent.child'][0].message).toBe('some message');
|
||||
expect(parentField.state.errors?.['parent.child'][0].level).toBe(FeedbackLevel.Error);
|
||||
});
|
||||
|
||||
it('should bubble errors to form', async () => {
|
||||
formModel.init({
|
||||
validate: {
|
||||
'parent.*': () => ({
|
||||
level: FeedbackLevel.Error,
|
||||
message: 'some message',
|
||||
}),
|
||||
},
|
||||
});
|
||||
formModel.createField('parent');
|
||||
formModel.createField('parent.child');
|
||||
const childField = formModel.getField('parent.child')!;
|
||||
|
||||
await childField.validate();
|
||||
|
||||
expect(formModel.state.errors?.['parent.child'][0].message).toBe('some message');
|
||||
expect(formModel.state.errors?.['parent.child'][0].level).toBe(FeedbackLevel.Error);
|
||||
});
|
||||
|
||||
it('should correctly set and bubble invalid', async () => {
|
||||
formModel.init({
|
||||
validate: {
|
||||
'parent.*': () => ({
|
||||
level: FeedbackLevel.Error,
|
||||
message: 'some message',
|
||||
}),
|
||||
},
|
||||
});
|
||||
const parent = formModel.createField('parent');
|
||||
const child = formModel.createField('parent.child');
|
||||
|
||||
await child.validate();
|
||||
|
||||
expect(child.state.invalid).toBe(true);
|
||||
expect(parent.state.invalid).toBe(true);
|
||||
expect(formModel.state.invalid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate self ancestors and child', async () => {
|
||||
formModel.init({
|
||||
validateTrigger: ValidateTrigger.onChange,
|
||||
});
|
||||
const root = formModel.createField('root');
|
||||
const l1 = formModel.createField('root.l1');
|
||||
const l2 = formModel.createField('root.l1.l2');
|
||||
const l3 = formModel.createField('root.l1.l2.l3');
|
||||
const l4 = formModel.createField('root.l1.l2.l3.l4');
|
||||
const other = formModel.createField('root.other');
|
||||
|
||||
vi.spyOn(root, 'validate');
|
||||
vi.spyOn(l1, 'validate');
|
||||
vi.spyOn(l2, 'validate');
|
||||
vi.spyOn(l3, 'validate');
|
||||
vi.spyOn(l4, 'validate');
|
||||
vi.spyOn(other, 'validate');
|
||||
|
||||
formModel.setValueIn('root.l1.l2', 1);
|
||||
|
||||
expect(root.validate).toHaveBeenCalledTimes(1);
|
||||
expect(l1.validate).toHaveBeenCalledTimes(1);
|
||||
expect(l2.validate).toHaveBeenCalledTimes(1);
|
||||
expect(l3.validate).toHaveBeenCalledTimes(1);
|
||||
expect(l4.validate).toHaveBeenCalledTimes(1);
|
||||
expect(other.validate).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should validate when multiple pattern match ', async () => {
|
||||
const validate1 = vi.fn();
|
||||
const validate2 = vi.fn();
|
||||
|
||||
formModel.init({
|
||||
validateTrigger: ValidateTrigger.onChange,
|
||||
validate: {
|
||||
'a.*.input': validate1,
|
||||
'a.1.input': validate2,
|
||||
},
|
||||
initialValues: {
|
||||
a: [{ input: '0' }, { input: '1' }],
|
||||
},
|
||||
});
|
||||
const root = formModel.createField('a');
|
||||
const i0 = formModel.createField('a.0.input');
|
||||
const i1 = formModel.createField('a.1.input');
|
||||
|
||||
formModel.setValueIn('a.1.input', 'xxx');
|
||||
|
||||
expect(validate1).toHaveBeenCalledTimes(1);
|
||||
expect(validate2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// 暂时注释了从 parent 触发validate 的能力,所以注释这个单测
|
||||
// it('can trigger validate from parent', async () => {
|
||||
// formModel.init({
|
||||
// validate: {
|
||||
// 'parent.child1': () => ({
|
||||
// level: FeedbackLevel.Error,
|
||||
// message: 'error',
|
||||
// }),
|
||||
// 'parent.child2': () => ({
|
||||
// level: FeedbackLevel.Warning,
|
||||
// message: 'warning',
|
||||
// }),
|
||||
// },
|
||||
// });
|
||||
// const parent = formModel.createField('parent');
|
||||
// formModel.createField('parent.child1');
|
||||
// formModel.createField('parent.child2');
|
||||
//
|
||||
// await parent.validate();
|
||||
//
|
||||
// expect(formModel.state.errors?.['parent.child1'][0].message).toBe('error');
|
||||
// expect(formModel.state.warnings?.['parent.child2'][0].level).toBe('warning');
|
||||
// });
|
||||
});
|
||||
|
||||
describe('onValueChange', () => {
|
||||
let formEffect = vi.fn();
|
||||
beforeEach(() => {
|
||||
formModel.dispose();
|
||||
formModel = new FormModel();
|
||||
formEffect = vi.fn();
|
||||
formModel.onFormValuesChange(formEffect);
|
||||
});
|
||||
|
||||
it('should bubble value change', () => {
|
||||
const parent = formModel.createField('parent');
|
||||
const child1 = formModel.createField('parent.child1');
|
||||
|
||||
const childOnChange = vi.fn();
|
||||
const parentOnChange = vi.fn();
|
||||
|
||||
child1.onValueChange(childOnChange);
|
||||
parent.onValueChange(parentOnChange);
|
||||
|
||||
child1.value = 1;
|
||||
|
||||
expect(parentOnChange).toHaveBeenCalledTimes(1);
|
||||
expect(childOnChange).toHaveBeenCalledTimes(1);
|
||||
expect(formEffect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('should bubble value change in array when delete', () => {
|
||||
const parent = formModel.createField('parent');
|
||||
const arr = formModel.createFieldArray('parent.arr');
|
||||
const item1 = formModel.createField('parent.arr.0');
|
||||
|
||||
const parentOnChange = vi.fn();
|
||||
const arrOnChange = vi.fn();
|
||||
const item1OnChange = vi.fn();
|
||||
|
||||
parent.onValueChange(parentOnChange);
|
||||
arr.onValueChange(arrOnChange);
|
||||
item1.onValueChange(item1OnChange);
|
||||
|
||||
formModel.setValueIn('parent.arr.0', 1);
|
||||
arr.delete(0);
|
||||
|
||||
expect(item1OnChange).toHaveBeenCalledTimes(2);
|
||||
expect(arrOnChange).toHaveBeenCalledTimes(2);
|
||||
expect(parentOnChange).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('should bubble value change in array when append', () => {
|
||||
const parent = formModel.createField('parent');
|
||||
const arr = formModel.createFieldArray('parent.arr');
|
||||
|
||||
const parentOnChange = vi.fn();
|
||||
const arrOnChange = vi.fn();
|
||||
|
||||
parent.onValueChange(parentOnChange);
|
||||
arr.onValueChange(arrOnChange);
|
||||
|
||||
arr.append('1');
|
||||
|
||||
expect(arrOnChange).toHaveBeenCalledTimes(1);
|
||||
expect(parentOnChange).toHaveBeenCalledTimes(1);
|
||||
expect(formEffect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not trigger child field change when array append', () => {
|
||||
formModel.createField('parent');
|
||||
const arr = formModel.createFieldArray('parent.arr');
|
||||
const item0 = formModel.createField('parent.arr.0');
|
||||
const item0x = formModel.createField('parent.arr.0.x');
|
||||
|
||||
const item0OnChange = vi.fn();
|
||||
const item0xOnChange = vi.fn();
|
||||
|
||||
item0.onValueChange(item0OnChange);
|
||||
item0x.onValueChange(item0xOnChange);
|
||||
|
||||
arr.append('1');
|
||||
|
||||
expect(item0OnChange).toHaveBeenCalledTimes(0);
|
||||
expect(item0xOnChange).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should clear and fire change', () => {
|
||||
const parent = formModel.createField('parent');
|
||||
const child1 = formModel.createField('parent.child1');
|
||||
|
||||
const child1OnChange = vi.fn();
|
||||
const parentOnChange = vi.fn();
|
||||
child1.onValueChange(child1OnChange);
|
||||
parent.onValueChange(parentOnChange);
|
||||
|
||||
formModel.setValueIn('parent.child1', 1);
|
||||
child1.clear();
|
||||
|
||||
expect(child1OnChange).toHaveBeenCalledTimes(2);
|
||||
expect(parentOnChange).toHaveBeenCalledTimes(2);
|
||||
expect(formEffect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should bubble change in array delete', () => {
|
||||
const arr = formModel.createFieldArray('arr');
|
||||
const child1 = formModel.createField('arr.0');
|
||||
|
||||
const childOnChange = vi.fn();
|
||||
const arrOnChange = vi.fn();
|
||||
child1.onValueChange(childOnChange);
|
||||
arr.onValueChange(arrOnChange);
|
||||
|
||||
formModel.setValueIn('arr.0', 1);
|
||||
arr.delete(0);
|
||||
|
||||
expect(childOnChange).toHaveBeenCalledTimes(2);
|
||||
expect(arrOnChange).toHaveBeenCalledTimes(2);
|
||||
// formModel.setValueIn 一次,arr.delete 中 arr 本身触发一次
|
||||
expect(formEffect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should bubble change in array append', () => {
|
||||
const arr = formModel.createFieldArray('arr');
|
||||
const item0 = formModel.createField('arr.0');
|
||||
|
||||
const item0OnChange = vi.fn();
|
||||
const arrOnChange = vi.fn();
|
||||
|
||||
item0.onValueChange(item0OnChange);
|
||||
arr.onValueChange(arrOnChange);
|
||||
|
||||
formModel.setValueIn('arr.0', 'a');
|
||||
arr.append('b');
|
||||
|
||||
expect(item0OnChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('should ignore unchanged items when array delete', () => {
|
||||
const other = formModel.createField('other');
|
||||
const parent = formModel.createField('parent');
|
||||
const arr = formModel.createFieldArray('parent.arr');
|
||||
const item0 = formModel.createField('parent.arr.0');
|
||||
const item1 = formModel.createField('parent.arr.1');
|
||||
const item2 = formModel.createField('parent.arr.2');
|
||||
formModel.setValueIn('parent.arr', [1, 2, 3]);
|
||||
|
||||
const item0OnChange = vi.fn();
|
||||
const item1OnChange = vi.fn();
|
||||
const item2OnChange = vi.fn();
|
||||
const arrOnChange = vi.fn();
|
||||
const parentOnChange = vi.fn();
|
||||
const otherOnChange = vi.fn();
|
||||
|
||||
item0.onValueChange(item0OnChange);
|
||||
item1.onValueChange(item1OnChange);
|
||||
item2.onValueChange(item2OnChange);
|
||||
arr.onValueChange(arrOnChange);
|
||||
parent.onValueChange(parentOnChange);
|
||||
other.onValueChange(otherOnChange);
|
||||
|
||||
arr.delete(1);
|
||||
|
||||
expect(arrOnChange).toHaveBeenCalledTimes(1);
|
||||
expect(parentOnChange).toHaveBeenCalledTimes(1);
|
||||
expect(item0OnChange).not.toHaveBeenCalled();
|
||||
expect(item1OnChange).toHaveBeenCalledTimes(1);
|
||||
expect(item2OnChange).toHaveBeenCalledTimes(1);
|
||||
expect(otherOnChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('dispose', () => {
|
||||
beforeEach(() => {
|
||||
formModel.dispose();
|
||||
formModel = new FormModel();
|
||||
});
|
||||
|
||||
it('should correctly cleanup when field dispose', () => {
|
||||
const parent = formModel.createField('parent');
|
||||
const child1 = formModel.createField('parent.child1');
|
||||
|
||||
child1.state.errors = { 'parent.child1': 'errors' } as unknown as Errors;
|
||||
child1.bubbleState();
|
||||
|
||||
expect(formModel.state.errors?.['parent.child1']).toEqual('errors');
|
||||
expect(parent.state.errors?.['parent.child1']).toEqual('errors');
|
||||
|
||||
parent.dispose();
|
||||
|
||||
// Ref 'dispose' method in field-model.ts
|
||||
// 1. expect state has been cleared
|
||||
// expect(child1.state.errors).toBeUndefined();
|
||||
// expect(parent.state.errors?.['parent.child1']).toBeUndefined();
|
||||
|
||||
// 2. expect field model has been cleared
|
||||
expect(formModel.fieldMap.get('parent')).toBeUndefined();
|
||||
expect(formModel.fieldMap.get('parent.child1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mapValues } from 'lodash-es';
|
||||
|
||||
import { ValidateTrigger } from '@/types';
|
||||
import { FormModel } from '@/core/form-model';
|
||||
|
||||
describe('FormModel', () => {
|
||||
let formModel = new FormModel();
|
||||
describe('validate trigger', () => {
|
||||
beforeEach(() => {
|
||||
formModel.dispose();
|
||||
formModel = new FormModel();
|
||||
});
|
||||
|
||||
it('do not validate when value change if validateTrigger is onBlur', async () => {
|
||||
formModel.init({ validateTrigger: ValidateTrigger.onBlur });
|
||||
|
||||
const field = formModel.createField('x');
|
||||
field.originalValidate = vi.fn();
|
||||
vi.spyOn(field, 'originalValidate');
|
||||
|
||||
field.value = 'some value';
|
||||
|
||||
expect(field.originalValidate).not.toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe('delete field', () => {
|
||||
beforeEach(() => {
|
||||
formModel.dispose();
|
||||
formModel = new FormModel();
|
||||
});
|
||||
|
||||
it('validate onChange', async () => {
|
||||
formModel.init({ initialValues: { parent: { child1: 1 } } });
|
||||
|
||||
formModel.createField('parent');
|
||||
formModel.createField('parent.child1');
|
||||
expect(formModel.values.parent?.child1).toBe(1);
|
||||
|
||||
formModel.deleteField('parent');
|
||||
|
||||
expect(formModel.values.parent?.child1).toBeUndefined();
|
||||
expect(formModel.getField('parent')).toBeUndefined();
|
||||
expect(formModel.getField('parent.child1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('FormModel.validate', () => {
|
||||
beforeEach(() => {
|
||||
formModel.dispose();
|
||||
formModel = new FormModel();
|
||||
});
|
||||
|
||||
it('should run validate on all matched names', async () => {
|
||||
formModel.init({
|
||||
validate: {
|
||||
'a.b.*': () => 'error',
|
||||
},
|
||||
});
|
||||
|
||||
const bField = formModel.createField('a.b');
|
||||
const xField = formModel.createField('a.b.x');
|
||||
|
||||
formModel.setValueIn('a.b', { x: 1, y: 2 });
|
||||
|
||||
const results = await formModel.validate();
|
||||
|
||||
// 1. assert validate has been executed correctly
|
||||
expect(results.length).toEqual(2);
|
||||
expect(results[0].message).toEqual('error');
|
||||
expect(results[0].name).toEqual('a.b.x');
|
||||
expect(results[1].message).toEqual('error');
|
||||
expect(results[1].name).toEqual('a.b.y');
|
||||
// 2. assert form state has been set correctly
|
||||
expect(formModel.state?.errors?.['a.b.x']?.[0].message).toEqual('error');
|
||||
// 3. assert field state has been set correctly
|
||||
expect(xField.state?.errors?.['a.b.x']?.[0].message).toEqual('error');
|
||||
// 4. assert field state has been bubbled to its parent
|
||||
expect(bField.state?.errors?.['a.b.x']?.[0].message).toEqual('error');
|
||||
});
|
||||
it('should run validate if multiple patterns match', async () => {
|
||||
const mockValidate1 = vi.fn();
|
||||
const mockValidate2 = vi.fn();
|
||||
formModel.init({
|
||||
validate: {
|
||||
'a.b.*': mockValidate1,
|
||||
'a.b.x': mockValidate2,
|
||||
},
|
||||
});
|
||||
|
||||
const bField = formModel.createField('a.b');
|
||||
const xField = formModel.createField('a.b.x');
|
||||
|
||||
formModel.setValueIn('a.b', { x: 1, y: 2 });
|
||||
|
||||
formModel.validate();
|
||||
|
||||
expect(mockValidate1).toHaveBeenCalledTimes(2);
|
||||
expect(mockValidate2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('should run validate correctly if multiple patterns match but multiple layer empty value exist', async () => {
|
||||
const mockValidate1 = vi.fn();
|
||||
const mockValidate2 = vi.fn();
|
||||
formModel.init({
|
||||
validate: {
|
||||
'a.*.x': mockValidate1,
|
||||
'a.b.x': mockValidate2,
|
||||
},
|
||||
});
|
||||
|
||||
const bField = formModel.createField('a.b');
|
||||
const xField = formModel.createField('a.b.x');
|
||||
|
||||
formModel.setValueIn('a', {});
|
||||
|
||||
formModel.validate();
|
||||
|
||||
expect(mockValidate1).toHaveBeenCalledTimes(0);
|
||||
expect(mockValidate2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('should correctly set form errors state when field does not exist', async () => {
|
||||
formModel.init({
|
||||
validate: {
|
||||
'a.b.*': () => 'error',
|
||||
},
|
||||
});
|
||||
|
||||
formModel.setValueIn('a.b', { x: 1, y: 2 });
|
||||
await formModel.validate();
|
||||
|
||||
expect(formModel.state?.errors?.['a.b.x']?.[0].message).toEqual('error');
|
||||
});
|
||||
it('should set form and field state correctly when run validate twice', async () => {
|
||||
formModel.init({
|
||||
validate: {
|
||||
'a.b.*': ({ value }) => (typeof value === 'string' ? undefined : 'error'),
|
||||
},
|
||||
});
|
||||
|
||||
const bField = formModel.createField('a.b');
|
||||
const xField = formModel.createField('a.b.x');
|
||||
|
||||
formModel.setValueIn('a.b', { x: 1, y: 2 });
|
||||
|
||||
let results = await formModel.validate();
|
||||
// both x y is string, so 2 errors
|
||||
expect(results.length).toEqual(2);
|
||||
expect(formModel.state?.errors?.['a.b.x']?.[0].message).toEqual('error');
|
||||
expect(formModel.state?.errors?.['a.b.y']?.[0].message).toEqual('error');
|
||||
expect(xField.state?.errors?.['a.b.x']?.[0].message).toEqual('error');
|
||||
expect(bField.state?.errors?.['a.b.x']?.[0].message).toEqual('error');
|
||||
|
||||
formModel.setValueIn('a.b', { x: '1', y: '2' });
|
||||
|
||||
results = await formModel.validate();
|
||||
|
||||
expect(results.length).toEqual(0);
|
||||
expect(formModel.state?.errors?.['a.b.x']).toEqual([]);
|
||||
expect(formModel.state?.errors?.['a.b.y']).toEqual([]);
|
||||
});
|
||||
it('validate as dynamic function', async () => {
|
||||
formModel.init({
|
||||
initialValues: { a: 3, b: 'str' },
|
||||
validate: (v, ctx) => {
|
||||
expect(ctx).toEqual('context');
|
||||
return mapValues(v, (value) => {
|
||||
if (typeof value === 'string') {
|
||||
return () => 'string error';
|
||||
}
|
||||
return () => 'num error';
|
||||
});
|
||||
},
|
||||
context: 'context',
|
||||
});
|
||||
const fieldResult = await formModel.validateIn('a');
|
||||
expect(fieldResult).toEqual(['num error']);
|
||||
const results = await formModel.validate();
|
||||
expect(results).toEqual([
|
||||
{ name: 'a', message: 'num error', level: 'error' },
|
||||
{ name: 'b', message: 'string error', level: 'error' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe('FormModel set/get values', () => {
|
||||
beforeEach(() => {
|
||||
formModel.dispose();
|
||||
formModel = new FormModel();
|
||||
vi.spyOn(formModel.onFormValuesInitEmitter, 'fire');
|
||||
vi.spyOn(formModel.onFormValuesChangeEmitter, 'fire');
|
||||
vi.spyOn(formModel.onFormValuesUpdatedEmitter, 'fire');
|
||||
});
|
||||
it('should set value for root path', () => {
|
||||
formModel.init({
|
||||
initialValues: {
|
||||
a: 1,
|
||||
},
|
||||
});
|
||||
|
||||
formModel.values = { a: 2 };
|
||||
expect(formModel.values).toEqual({ a: 2 });
|
||||
});
|
||||
|
||||
it('should set initialValues and fire init and updated events', async () => {
|
||||
formModel.init({
|
||||
initialValues: {
|
||||
a: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(formModel.values).toEqual({ a: 1 });
|
||||
expect(formModel.onFormValuesInitEmitter.fire).toHaveBeenCalledWith({
|
||||
values: {
|
||||
a: 1,
|
||||
},
|
||||
name: '',
|
||||
});
|
||||
expect(formModel.onFormValuesUpdatedEmitter.fire).toHaveBeenCalledWith({
|
||||
values: {
|
||||
a: 1,
|
||||
},
|
||||
name: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should set initialValues in certain path and fire change', async () => {
|
||||
formModel.init({
|
||||
initialValues: {
|
||||
a: 1,
|
||||
},
|
||||
});
|
||||
|
||||
formModel.setInitValueIn('b', 2);
|
||||
|
||||
expect(formModel.values).toEqual({ a: 1, b: 2 });
|
||||
expect(formModel.onFormValuesInitEmitter.fire).toHaveBeenCalledWith({
|
||||
values: {
|
||||
a: 1,
|
||||
b: 2,
|
||||
},
|
||||
prevValues: {
|
||||
a: 1,
|
||||
},
|
||||
name: 'b',
|
||||
});
|
||||
expect(formModel.onFormValuesUpdatedEmitter.fire).toHaveBeenCalledWith({
|
||||
values: {
|
||||
a: 1,
|
||||
b: 2,
|
||||
},
|
||||
prevValues: {
|
||||
a: 1,
|
||||
},
|
||||
name: 'b',
|
||||
});
|
||||
});
|
||||
it('should not set initialValues in certain path if value exists', async () => {
|
||||
formModel.init({
|
||||
initialValues: {
|
||||
a: 1,
|
||||
},
|
||||
});
|
||||
|
||||
formModel.setInitValueIn('a', 2);
|
||||
|
||||
expect(formModel.values).toEqual({ a: 1 });
|
||||
// 仅在初始化时调用一次,setInitValueIn 没有调用
|
||||
expect(formModel.onFormValuesInitEmitter.fire).toHaveBeenCalledTimes(1);
|
||||
expect(formModel.onFormValuesUpdatedEmitter.fire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('should set values in certain path and fire change and updated events', async () => {
|
||||
formModel.init({
|
||||
initialValues: {
|
||||
a: 1,
|
||||
},
|
||||
});
|
||||
|
||||
formModel.setValueIn('a', 2);
|
||||
|
||||
expect(formModel.values).toEqual({ a: 2 });
|
||||
// 仅在初始化时调用一次,setInitValueIn 没有调用
|
||||
expect(formModel.onFormValuesChangeEmitter.fire).toHaveBeenCalledTimes(1);
|
||||
// 初始化一次,变更值一次,所以是两次
|
||||
expect(formModel.onFormValuesUpdatedEmitter.fire).toHaveBeenCalledTimes(2);
|
||||
expect(formModel.onFormValuesChangeEmitter.fire).toHaveBeenCalledWith({
|
||||
values: {
|
||||
a: 2,
|
||||
},
|
||||
prevValues: {
|
||||
a: 1,
|
||||
},
|
||||
name: 'a',
|
||||
});
|
||||
expect(formModel.onFormValuesUpdatedEmitter.fire).toHaveBeenCalledWith({
|
||||
values: {
|
||||
a: 2,
|
||||
},
|
||||
prevValues: {
|
||||
a: 1,
|
||||
},
|
||||
name: 'a',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
// test src/glob.ts
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Glob } from '../src/utils/glob';
|
||||
|
||||
describe('glob', () => {
|
||||
describe('isMatch', () => {
|
||||
it('* at the end', () => {
|
||||
expect(Glob.isMatch('a.b.*', 'a.b.c')).toBe(true);
|
||||
expect(Glob.isMatch('a.b.*', 'a.k.c')).toBe(false);
|
||||
});
|
||||
it('* at the start', () => {
|
||||
expect(Glob.isMatch('*.b.c', 'a.b.c')).toBe(true);
|
||||
expect(Glob.isMatch('*.b.c', 'a.b.x')).toBe(false);
|
||||
});
|
||||
it('multiple *', () => {
|
||||
expect(Glob.isMatch('*.b.*', 'a.b.c')).toBe(true);
|
||||
expect(Glob.isMatch('a.b.*', 'a.k.c')).toBe(false);
|
||||
});
|
||||
it('no *', () => {
|
||||
expect(Glob.isMatch('a.b.c', 'a.b.c')).toBe(true);
|
||||
});
|
||||
it('length not match', () => {
|
||||
expect(Glob.isMatch('a.b.*', 'a.b.c.c')).toBe(false);
|
||||
});
|
||||
});
|
||||
describe('isMatchOrParent', () => {
|
||||
it('* at the end', () => {
|
||||
expect(Glob.isMatchOrParent('a.b.*', 'a.b.c')).toBe(true);
|
||||
expect(Glob.isMatchOrParent('a.b.*', 'a.k.c')).toBe(false);
|
||||
});
|
||||
it('* at the start', () => {
|
||||
expect(Glob.isMatchOrParent('*.b.c', 'a.b.c')).toBe(true);
|
||||
expect(Glob.isMatchOrParent('*.b.c', 'a.b.x')).toBe(false);
|
||||
expect(Glob.isMatchOrParent('*.b', 'a.b.x')).toBe(true);
|
||||
});
|
||||
it('multiple *', () => {
|
||||
expect(Glob.isMatchOrParent('*.b.*', 'a.b.c')).toBe(true);
|
||||
expect(Glob.isMatchOrParent('*.b.*', 'a.k.c')).toBe(false);
|
||||
});
|
||||
it('no *', () => {
|
||||
expect(Glob.isMatchOrParent('a.b.c', 'a.b.c')).toBe(true);
|
||||
expect(Glob.isMatchOrParent('a.b', 'a.b.c')).toBe(true);
|
||||
expect(Glob.isMatchOrParent('a', 'a.b.c')).toBe(true);
|
||||
expect(Glob.isMatchOrParent('', 'a.b.c.')).toBe(true);
|
||||
});
|
||||
it('length not match', () => {
|
||||
expect(Glob.isMatchOrParent('a.b.*', 'a.b.c.c')).toBe(true);
|
||||
expect(Glob.isMatchOrParent('a.b.c.d', 'a.b.c.')).toBe(false);
|
||||
});
|
||||
});
|
||||
describe('getParentPathByPattern', () => {
|
||||
it('should get parent path correctly', () => {
|
||||
expect(Glob.getParentPathByPattern('a.b.*', 'a.b.c')).toBe('a.b.c');
|
||||
expect(Glob.getParentPathByPattern('a.b.*', 'a.b.c.d')).toBe('a.b.c');
|
||||
expect(Glob.getParentPathByPattern('a.b', 'a.b.c.d')).toBe('a.b');
|
||||
expect(Glob.getParentPathByPattern('a.*.c', 'a.b.c.d')).toBe('a.b.c');
|
||||
});
|
||||
});
|
||||
describe('findMatchPaths', () => {
|
||||
it('return original path array if no *', () => {
|
||||
const obj = { a: { b: { c: 1 } } };
|
||||
expect(Glob.findMatchPaths(obj, 'a.b.c')).toEqual(['a.b.c']);
|
||||
});
|
||||
it('object: when * is in middle of the path', () => {
|
||||
const obj = {
|
||||
a: { b: { c: 1 } },
|
||||
x: { y: { z: 2 } },
|
||||
};
|
||||
expect(Glob.findMatchPaths(obj, 'a.*.c')).toEqual(['a.b.c']);
|
||||
});
|
||||
it('object:when * is at the end of the path', () => {
|
||||
const obj = {
|
||||
a: { b: { c: 1 } },
|
||||
x: { y: { z: 2 } },
|
||||
};
|
||||
expect(Glob.findMatchPaths(obj, 'a.*')).toEqual(['a.b']);
|
||||
});
|
||||
// 暂时不支持该场景,见glob.ts 中 143行说明
|
||||
it('object: * 后面数据异构', () => {
|
||||
const obj = {
|
||||
a: { b: { c: 1 } },
|
||||
x: { y: { z: 2 } },
|
||||
};
|
||||
expect(Glob.findMatchPaths(obj, '*.y')).toEqual(['x.y']);
|
||||
});
|
||||
it('object:when * is at the start and end of the path', () => {
|
||||
const obj = {
|
||||
a: { b: { c: 1 } },
|
||||
x: { y: { z: 2 } },
|
||||
};
|
||||
expect(Glob.findMatchPaths(obj, '*.y.*')).toEqual(['x.y.z']);
|
||||
});
|
||||
it('array: when * is at the end of the path', () => {
|
||||
const obj = {
|
||||
other: 100,
|
||||
arr: [
|
||||
{
|
||||
x: 1,
|
||||
y: { a: 1, b: 2 },
|
||||
},
|
||||
{
|
||||
x: 10,
|
||||
y: {
|
||||
a: 10,
|
||||
b: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(Glob.findMatchPaths(obj, 'arr.*')).toEqual(['arr.0', 'arr.1']);
|
||||
});
|
||||
it('array: when * is at the start of the path', () => {
|
||||
const arr = [
|
||||
{
|
||||
x: 1,
|
||||
y: { a: 1, b: 2 },
|
||||
},
|
||||
{
|
||||
x: 10,
|
||||
y: {
|
||||
a: 10,
|
||||
b: 20,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(Glob.findMatchPaths(arr, '*')).toEqual(['0', '1']);
|
||||
});
|
||||
it('array: when * is in the middle of the path', () => {
|
||||
const obj = {
|
||||
other: 100,
|
||||
arr: [
|
||||
{
|
||||
x: 1,
|
||||
y: { a: 1, b: 2 },
|
||||
},
|
||||
{
|
||||
x: 10,
|
||||
y: {
|
||||
a: 10,
|
||||
b: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(Glob.findMatchPaths(obj, 'arr.*.y')).toEqual(['arr.0.y', 'arr.1.y']);
|
||||
});
|
||||
it('array in array: when double * ', () => {
|
||||
const obj = {
|
||||
other: 100,
|
||||
arr: [
|
||||
{
|
||||
x: 1,
|
||||
y: ['1', '2'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(Glob.findMatchPaths(obj, 'arr.*.y.*')).toEqual(['arr.0.y.0', 'arr.0.y.1']);
|
||||
});
|
||||
it('array in object: when double * ', () => {
|
||||
const obj = {
|
||||
x: 100,
|
||||
y: {
|
||||
arr: [1, 2],
|
||||
},
|
||||
};
|
||||
|
||||
expect(Glob.findMatchPaths(obj, 'y.*.*')).toEqual(['y.arr.0', 'y.arr.1']);
|
||||
});
|
||||
it('array in object: when double * start ', () => {
|
||||
const obj = {
|
||||
x: 100,
|
||||
y: {
|
||||
arr: [{ a: 1, b: 2 }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(Glob.findMatchPaths(obj, '*.arr.*')).toEqual(['y.arr.0']);
|
||||
});
|
||||
it('when value after * is empty string ', () => {
|
||||
const obj = {
|
||||
$$input_decorator$$: {
|
||||
inputParameters: [{ name: '', input: 2 }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(Glob.findMatchPaths(obj, '$$input_decorator$$.inputParameters.*.name')).toEqual([
|
||||
'$$input_decorator$$.inputParameters.0.name',
|
||||
]);
|
||||
});
|
||||
it('when value after * is undefined ', () => {
|
||||
const obj = {
|
||||
x: {
|
||||
arr: [{ name: undefined, input: 2 }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(Glob.findMatchPaths(obj, 'x.arr.*.name')).toEqual(['x.arr.0.name']);
|
||||
});
|
||||
it('when value not directly after * is undefined ', () => {
|
||||
const obj = {
|
||||
x: {
|
||||
arr: [{ name: { a: undefined }, input: 2 }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(Glob.findMatchPaths(obj, 'x.arr.*.name.a')).toEqual(['x.arr.0.name.a']);
|
||||
});
|
||||
});
|
||||
describe('splitPattern', () => {
|
||||
it('should splict pattern correctly', () => {
|
||||
expect(Glob.splitPattern('a.b.*.c.*.d')).toEqual(['a.b', '*', 'c', '*', 'd']);
|
||||
expect(Glob.splitPattern('a.b.*.c.*')).toEqual(['a.b', '*', 'c', '*']);
|
||||
expect(Glob.splitPattern('a.b.*.*.*.d')).toEqual(['a.b', '*', '*', '*', 'd']);
|
||||
expect(Glob.splitPattern('*.*.c.*.d')).toEqual(['*', '*', 'c', '*', 'd']);
|
||||
});
|
||||
});
|
||||
describe('getSubPaths', () => {
|
||||
it('should get sub paths for valid object', () => {
|
||||
const obj = {
|
||||
a: {
|
||||
b: {
|
||||
x1: {
|
||||
y1: 1,
|
||||
},
|
||||
x2: {
|
||||
y2: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(Glob.getSubPaths(['a.b'], obj)).toEqual(['a.b.x1', 'a.b.x2']);
|
||||
expect(Glob.getSubPaths(['a.b', 'a.b.x1'], obj)).toEqual(['a.b.x1', 'a.b.x2', 'a.b.x1.y1']);
|
||||
});
|
||||
it('should get sub paths for array', () => {
|
||||
const obj = {
|
||||
a: {
|
||||
b: {
|
||||
x1: [1, 2],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(Glob.getSubPaths(['a.b.x1'], obj)).toEqual(['a.b.x1.0', 'a.b.x1.1']);
|
||||
});
|
||||
it('should get sub paths when root obj is array', () => {
|
||||
const obj = [
|
||||
{
|
||||
x1: [1, 2],
|
||||
},
|
||||
{
|
||||
x2: [1, 2],
|
||||
},
|
||||
];
|
||||
expect(Glob.getSubPaths(['0.x1'], obj)).toEqual(['0.x1.0', '0.x1.1']);
|
||||
});
|
||||
it('should return empty array when obj is not object nor array', () => {
|
||||
expect(Glob.getSubPaths(['x.y'], 1)).toEqual([]);
|
||||
expect(Glob.getSubPaths(['x.y'], 'x')).toEqual([]);
|
||||
expect(Glob.getSubPaths(['x.y'], undefined)).toEqual([]);
|
||||
});
|
||||
it('should return empty array when obj has no value for given path', () => {
|
||||
const obj = {
|
||||
a: {
|
||||
b: {
|
||||
x1: [1, 2],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(Glob.getSubPaths(['a.b.c'], obj)).toEqual([]);
|
||||
});
|
||||
});
|
||||
describe('findMatchPathsWithEmptyValue', () => {
|
||||
it('return original path array if no *', () => {
|
||||
const obj = { a: { b: { c: 1 } } };
|
||||
expect(Glob.findMatchPathsWithEmptyValue(obj, 'a.b.c')).toEqual(['a.b.c']);
|
||||
});
|
||||
it('return original path array if no * and value is empty on multiple layers', () => {
|
||||
const obj = { a: {} };
|
||||
expect(Glob.findMatchPathsWithEmptyValue(obj, 'a.b.c')).toEqual(['a.b.c']);
|
||||
});
|
||||
it('return original path array if no * and value is empty on multiple layers', () => {
|
||||
const obj = { a: { b: {} } };
|
||||
expect(Glob.findMatchPathsWithEmptyValue(obj, 'a.x.y')).toEqual(['a.x.y']);
|
||||
});
|
||||
it('return array with original path even if path does not exists, but the original path does not contain * ', () => {
|
||||
const obj = { a: { b: { c: 1 } } };
|
||||
expect(Glob.findMatchPathsWithEmptyValue(obj, 'a.b.c.d')).toEqual(['a.b.c.d']);
|
||||
});
|
||||
it('return original path array if no * and path related value is undefined in object', () => {
|
||||
const obj = { a: { b: { c: {} } } };
|
||||
expect(Glob.findMatchPathsWithEmptyValue(obj, 'a.b.c.d')).toEqual(['a.b.c.d']);
|
||||
});
|
||||
it('object: when * is in middle of the path', () => {
|
||||
const obj = {
|
||||
a: { b: { c: 1 } },
|
||||
x: { y: { z: 2 } },
|
||||
};
|
||||
expect(Glob.findMatchPathsWithEmptyValue(obj, 'a.*.c')).toEqual(['a.b.c']);
|
||||
});
|
||||
it('object:when * is at the end of the path', () => {
|
||||
const obj = {
|
||||
a: { b: { c: 1 } },
|
||||
x: { y: { z: 2 } },
|
||||
};
|
||||
expect(Glob.findMatchPathsWithEmptyValue(obj, 'a.*')).toEqual(['a.b']);
|
||||
});
|
||||
// 暂时不支持该场景,见glob.ts 中 143行说明
|
||||
it('object: * 后面数据异构', () => {
|
||||
const obj = {
|
||||
a: { b: { c: 1 } },
|
||||
x: { y: { z: 2 } },
|
||||
};
|
||||
expect(Glob.findMatchPathsWithEmptyValue(obj, '*.y')).toEqual(['a.y', 'x.y']);
|
||||
});
|
||||
it('object:when * is at the start and end of the path', () => {
|
||||
const obj = {
|
||||
a: { b: { c: 1 } },
|
||||
x: { y: { z: 2 } },
|
||||
};
|
||||
expect(Glob.findMatchPathsWithEmptyValue(obj, '*.y.*')).toEqual(['x.y.z']);
|
||||
});
|
||||
it('array: when * is at the end of the path', () => {
|
||||
const obj = {
|
||||
other: 100,
|
||||
arr: [
|
||||
{
|
||||
x: 1,
|
||||
y: { a: 1, b: 2 },
|
||||
},
|
||||
{
|
||||
x: 10,
|
||||
y: {
|
||||
a: 10,
|
||||
b: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(Glob.findMatchPathsWithEmptyValue(obj, 'arr.*')).toEqual(['arr.0', 'arr.1']);
|
||||
});
|
||||
it('array: when * is at the start of the path', () => {
|
||||
const arr = [
|
||||
{
|
||||
x: 1,
|
||||
y: { a: 1, b: 2 },
|
||||
},
|
||||
{
|
||||
x: 10,
|
||||
y: {
|
||||
a: 10,
|
||||
b: 20,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(Glob.findMatchPathsWithEmptyValue(arr, '*')).toEqual(['0', '1']);
|
||||
});
|
||||
it('array: when * is in the middle of the path', () => {
|
||||
const obj = {
|
||||
other: 100,
|
||||
arr: [
|
||||
{
|
||||
x: 1,
|
||||
y: { a: 1, b: 2 },
|
||||
},
|
||||
{
|
||||
x: 10,
|
||||
y: {
|
||||
a: 10,
|
||||
b: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(Glob.findMatchPathsWithEmptyValue(obj, 'arr.*.y')).toEqual(['arr.0.y', 'arr.1.y']);
|
||||
});
|
||||
it('array: when data related to path is undefined', () => {
|
||||
const obj = [{ a: 1 }, { a: 2, b: 3 }];
|
||||
expect(Glob.findMatchPathsWithEmptyValue(obj, '*.b')).toEqual(['0.b', '1.b']);
|
||||
});
|
||||
it('array in array: when double * ', () => {
|
||||
const obj = {
|
||||
other: 100,
|
||||
arr: [
|
||||
{
|
||||
x: 1,
|
||||
y: ['1', '2'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(Glob.findMatchPathsWithEmptyValue(obj, 'arr.*.y.*')).toEqual([
|
||||
'arr.0.y.0',
|
||||
'arr.0.y.1',
|
||||
]);
|
||||
});
|
||||
it('array in object: when double * ', () => {
|
||||
const obj = {
|
||||
x: 100,
|
||||
y: {
|
||||
arr: [1, 2],
|
||||
},
|
||||
};
|
||||
|
||||
expect(Glob.findMatchPathsWithEmptyValue(obj, 'y.*.*')).toEqual(['y.arr.0', 'y.arr.1']);
|
||||
});
|
||||
it('array in object: when double * start ', () => {
|
||||
const obj = {
|
||||
x: 100,
|
||||
y: {
|
||||
arr: [{ a: 1, b: 2 }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(Glob.findMatchPathsWithEmptyValue(obj, '*.arr.*')).toEqual(['y.arr.0']);
|
||||
});
|
||||
it('when value after * is empty string ', () => {
|
||||
const obj = {
|
||||
$$input_decorator$$: {
|
||||
inputParameters: [{ name: '', input: 2 }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
Glob.findMatchPathsWithEmptyValue(obj, '$$input_decorator$$.inputParameters.*.name')
|
||||
).toEqual(['$$input_decorator$$.inputParameters.0.name']);
|
||||
});
|
||||
it('when value after * is undefined ', () => {
|
||||
const obj = {
|
||||
x: {
|
||||
arr: [{ name: undefined, input: 2 }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(Glob.findMatchPathsWithEmptyValue(obj, 'x.arr.*.name')).toEqual(['x.arr.0.name']);
|
||||
});
|
||||
it('when value not directly after * is undefined ', () => {
|
||||
const obj = {
|
||||
x: {
|
||||
arr: [{ name: { a: undefined }, input: 2 }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(Glob.findMatchPathsWithEmptyValue(obj, 'x.arr.*.name.a')).toEqual(['x.arr.0.name.a']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getIn, isEmptyArray, isNaN, isPromise, shallowSetIn } from '../src/utils';
|
||||
|
||||
describe('object', () => {
|
||||
describe('isEmptyArray', () => {
|
||||
it('returns true when an empty array is passed in', () => {
|
||||
expect(isEmptyArray([])).toBe(true);
|
||||
});
|
||||
it('returns false when anything other than empty array is passed in', () => {
|
||||
expect(isEmptyArray()).toBe(false);
|
||||
expect(isEmptyArray(null)).toBe(false);
|
||||
expect(isEmptyArray(123)).toBe(false);
|
||||
expect(isEmptyArray('abc')).toBe(false);
|
||||
expect(isEmptyArray({})).toBe(false);
|
||||
expect(isEmptyArray({ a: 1 })).toBe(false);
|
||||
expect(isEmptyArray(['abc'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIn', () => {
|
||||
const obj = {
|
||||
a: {
|
||||
b: 2,
|
||||
c: false,
|
||||
d: null,
|
||||
},
|
||||
t: true,
|
||||
s: 'a random string',
|
||||
};
|
||||
|
||||
it('gets a value by array path', () => {
|
||||
expect(getIn(obj, ['a', 'b'])).toBe(2);
|
||||
});
|
||||
|
||||
it('gets a value by string path', () => {
|
||||
expect(getIn(obj, 'a.b')).toBe(2);
|
||||
});
|
||||
|
||||
it('return "undefined" if value was not found using given path', () => {
|
||||
expect(getIn(obj, 'a.z')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('return "undefined" if value was not found using given path and an intermediate value is "false"', () => {
|
||||
expect(getIn(obj, 'a.c.z')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('return "undefined" if value was not found using given path and an intermediate value is "null"', () => {
|
||||
expect(getIn(obj, 'a.d.z')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('return "undefined" if value was not found using given path and an intermediate value is "true"', () => {
|
||||
expect(getIn(obj, 't.z')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('return "undefined" if value was not found using given path and an intermediate value is a string', () => {
|
||||
expect(getIn(obj, 's.z')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shallowSetIn', () => {
|
||||
it('sets flat value', () => {
|
||||
const obj = { x: 'y' };
|
||||
const newObj = shallowSetIn(obj, 'flat', 'value');
|
||||
expect(obj).toEqual({ x: 'y' });
|
||||
expect(newObj).toEqual({ x: 'y', flat: 'value' });
|
||||
});
|
||||
|
||||
it('keep the same object if nothing is changed', () => {
|
||||
const obj = { x: 'y' };
|
||||
const newObj = shallowSetIn(obj, 'x', 'y');
|
||||
expect(obj).toBe(newObj);
|
||||
});
|
||||
|
||||
it('keep key shen set undefined', () => {
|
||||
const obj = { x: 'y' };
|
||||
const newObj = shallowSetIn(obj, 'x', undefined);
|
||||
expect(obj).toEqual({ x: 'y' });
|
||||
expect(newObj).toEqual({ x: undefined });
|
||||
expect(Object.keys(newObj)).toEqual(['x']);
|
||||
});
|
||||
|
||||
it('sets nested value', () => {
|
||||
const obj = { x: 'y' };
|
||||
const newObj = shallowSetIn(obj, 'nested.value', 'nested value');
|
||||
expect(obj).toEqual({ x: 'y' });
|
||||
expect(newObj).toEqual({ x: 'y', nested: { value: 'nested value' } });
|
||||
});
|
||||
|
||||
it('updates nested value', () => {
|
||||
const obj = { x: 'y', nested: { value: 'a' } };
|
||||
const newObj = shallowSetIn(obj, 'nested.value', 'b');
|
||||
expect(obj).toEqual({ x: 'y', nested: { value: 'a' } });
|
||||
expect(newObj).toEqual({ x: 'y', nested: { value: 'b' } });
|
||||
});
|
||||
|
||||
it('updates deep nested value', () => {
|
||||
const obj = { x: 'y', twofoldly: { nested: { value: 'a' } } };
|
||||
const newObj = shallowSetIn(obj, 'twofoldly.nested.value', 'b');
|
||||
expect(obj.twofoldly.nested === newObj.twofoldly.nested).toEqual(false); // fails, same object still
|
||||
expect(obj).toEqual({ x: 'y', twofoldly: { nested: { value: 'a' } } }); // fails, it's b here, too
|
||||
expect(newObj).toEqual({ x: 'y', twofoldly: { nested: { value: 'b' } } }); // works ofc
|
||||
});
|
||||
|
||||
it('shallow clone data along the update path', () => {
|
||||
const obj = {
|
||||
x: 'y',
|
||||
twofoldly: { nested: ['a', { c: 'd' }] },
|
||||
other: { nestedOther: 'o' },
|
||||
};
|
||||
const newObj = shallowSetIn(obj, 'twofoldly.nested.0', 'b');
|
||||
// All new objects/arrays created along the update path.
|
||||
expect(obj).not.toBe(newObj);
|
||||
expect(obj.twofoldly).not.toBe(newObj.twofoldly);
|
||||
expect(obj.twofoldly.nested).not.toBe(newObj.twofoldly.nested);
|
||||
// All other objects/arrays copied, not cloned (retain same memory
|
||||
// location).
|
||||
expect(obj.other).toBe(newObj.other);
|
||||
expect(obj.twofoldly.nested[1]).toBe(newObj.twofoldly.nested[1]);
|
||||
});
|
||||
|
||||
it('sets new array', () => {
|
||||
const obj = { x: 'y' };
|
||||
const newObj = shallowSetIn(obj, 'nested.0', 'value');
|
||||
expect(obj).toEqual({ x: 'y' });
|
||||
expect(newObj).toEqual({ x: 'y', nested: ['value'] });
|
||||
});
|
||||
|
||||
it('sets new array when item is empty string', () => {
|
||||
const obj = { x: 'y' };
|
||||
const newObj = shallowSetIn(obj, 'nested.0', '');
|
||||
expect(obj).toEqual({ x: 'y' });
|
||||
expect(newObj).toEqual({ x: 'y', nested: [''] });
|
||||
});
|
||||
|
||||
it('sets new array when item is empty string', () => {
|
||||
const obj = {};
|
||||
const newObj = shallowSetIn(obj, 'nested.0', '');
|
||||
expect(obj).toEqual({});
|
||||
expect(newObj).toEqual({ nested: [''] });
|
||||
});
|
||||
|
||||
it('updates nested array value', () => {
|
||||
const obj = { x: 'y', nested: ['a'] };
|
||||
const newObj = shallowSetIn(obj, 'nested[0]', 'b');
|
||||
expect(obj).toEqual({ x: 'y', nested: ['a'] });
|
||||
expect(newObj).toEqual({ x: 'y', nested: ['b'] });
|
||||
});
|
||||
|
||||
it('adds new item to nested array', () => {
|
||||
const obj = { x: 'y', nested: ['a'] };
|
||||
const newObj = shallowSetIn(obj, 'nested.1', 'b');
|
||||
expect(obj).toEqual({ x: 'y', nested: ['a'] });
|
||||
expect(newObj).toEqual({ x: 'y', nested: ['a', 'b'] });
|
||||
});
|
||||
|
||||
it('sticks to object with int key when defined', () => {
|
||||
const obj = { x: 'y', nested: { 0: 'a' } };
|
||||
const newObj = shallowSetIn(obj, 'nested.0', 'b');
|
||||
expect(obj).toEqual({ x: 'y', nested: { 0: 'a' } });
|
||||
expect(newObj).toEqual({ x: 'y', nested: { 0: 'b' } });
|
||||
});
|
||||
|
||||
it('supports bracket path', () => {
|
||||
const obj = { x: 'y' };
|
||||
const newObj = shallowSetIn(obj, 'nested[0]', 'value');
|
||||
expect(obj).toEqual({ x: 'y' });
|
||||
expect(newObj).toEqual({ x: 'y', nested: ['value'] });
|
||||
});
|
||||
|
||||
it('supports path containing key of the object', () => {
|
||||
const obj = { x: 'y' };
|
||||
const newObj = shallowSetIn(obj, 'a.x.c', 'value');
|
||||
expect(obj).toEqual({ x: 'y' });
|
||||
expect(newObj).toEqual({ x: 'y', a: { x: { c: 'value' } } });
|
||||
});
|
||||
|
||||
// This case is not used in form sdk for now,so we comment it.
|
||||
// it('should keep class inheritance for the top level object', () => {
|
||||
// class TestClass {
|
||||
// constructor(public key: string, public setObj?: any) {}
|
||||
// }
|
||||
// const obj = new TestClass('value');
|
||||
// const newObj = shallowSetIn(obj, 'setObj.nested', 'shallowSetInValue');
|
||||
// expect(obj).toEqual(new TestClass('value'));
|
||||
// expect(newObj).toEqual({
|
||||
// key: 'value',
|
||||
// setObj: { nested: 'shallowSetInValue' },
|
||||
// });
|
||||
// expect(obj instanceof TestClass).toEqual(true);
|
||||
// expect(newObj instanceof TestClass).toEqual(true);
|
||||
// });
|
||||
|
||||
it('can convert primitives to objects before setting', () => {
|
||||
const obj = { x: [{ y: true }] };
|
||||
const newObj = shallowSetIn(obj, 'x.0.y.z', true);
|
||||
expect(obj).toEqual({ x: [{ y: true }] });
|
||||
expect(newObj).toEqual({ x: [{ y: { z: true } }] });
|
||||
});
|
||||
it('set undefined value with unknown key', () => {
|
||||
const obj = { a: '' };
|
||||
let newObj = shallowSetIn(obj, 'a', undefined);
|
||||
newObj = shallowSetIn(newObj, 'b', undefined);
|
||||
expect(obj).toEqual({ a: '' });
|
||||
expect(newObj).toEqual({ a: undefined, b: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPromise', () => {
|
||||
it('verifies that a value is a promise', () => {
|
||||
const alwaysResolve = (resolve: Function) => resolve();
|
||||
const promise = new Promise(alwaysResolve);
|
||||
expect(isPromise(promise)).toEqual(true);
|
||||
});
|
||||
|
||||
it('verifies that a value is not a promise', () => {
|
||||
const emptyObject = {};
|
||||
const identity = (i: any) => i;
|
||||
const foo = 'foo';
|
||||
const answerToLife = 42;
|
||||
|
||||
expect(isPromise(emptyObject)).toEqual(false);
|
||||
expect(isPromise(identity)).toEqual(false);
|
||||
expect(isPromise(foo)).toEqual(false);
|
||||
expect(isPromise(answerToLife)).toEqual(false);
|
||||
|
||||
expect(isPromise(undefined)).toEqual(false);
|
||||
expect(isPromise(null)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNaN', () => {
|
||||
it('correctly validate NaN', () => {
|
||||
expect(isNaN(NaN)).toBe(true);
|
||||
});
|
||||
|
||||
it('correctly validate not NaN', () => {
|
||||
expect(isNaN(undefined)).toBe(false);
|
||||
expect(isNaN(1)).toBe(false);
|
||||
expect(isNaN('')).toBe(false);
|
||||
expect(isNaN([])).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Path } from '../src/core/path';
|
||||
|
||||
describe('path', () => {
|
||||
it('toString', () => {
|
||||
expect(new Path('a.b.c').toString()).toEqual('a.b.c');
|
||||
expect(new Path('a.b[0].c').toString()).toEqual('a.b.0.c');
|
||||
expect(new Path(['a', 'b', 'c']).toString()).toEqual('a.b.c');
|
||||
});
|
||||
it('parent', () => {
|
||||
expect(new Path('a.b.c').parent!.toString()).toEqual('a.b');
|
||||
expect(new Path('a.b[0]').parent!.toString()).toEqual('a.b');
|
||||
expect(new Path('a').parent).toEqual(undefined);
|
||||
});
|
||||
it('isChild', () => {
|
||||
expect(new Path('a.b').isChild('a.b.c')).toEqual(true);
|
||||
expect(new Path('a.b').isChild('a.b[0]')).toEqual(true);
|
||||
});
|
||||
it('concat', () => {
|
||||
expect(new Path('a').concat('b').toString()).toEqual('a.b');
|
||||
expect(new Path('a').concat('b.c').toString()).toEqual('a.b.c');
|
||||
expect(new Path('a').concat(0).toString()).toEqual('a.0');
|
||||
expect(() => {
|
||||
new Path('a').concat({} as any);
|
||||
}).toThrowError(/invalid param type/);
|
||||
});
|
||||
it('compareArrayPath', () => {
|
||||
expect((Path.compareArrayPath(new Path('a.b.0'), new Path('a.b.1')) as number) < 0).toBe(true);
|
||||
expect((Path.compareArrayPath(new Path('a.b.0'), new Path('a.b.2')) as number) < 0).toBe(true);
|
||||
expect((Path.compareArrayPath(new Path('a.b.1'), new Path('a.b.0')) as number) < 0).toBe(false);
|
||||
expect((Path.compareArrayPath(new Path('a.b.0'), new Path('a.b.0')) as number) === 0).toBe(
|
||||
true
|
||||
);
|
||||
expect((Path.compareArrayPath(new Path('a.b.1'), new Path('a.b.0.x')) as number) < 0).toBe(
|
||||
false
|
||||
);
|
||||
expect((Path.compareArrayPath(new Path('a.b.1.y'), new Path('a.b.0.x')) as number) < 0).toBe(
|
||||
false
|
||||
);
|
||||
expect(() => Path.compareArrayPath(new Path('a.1'), new Path('a.b.0'))).toThrowError();
|
||||
expect(() => Path.compareArrayPath(new Path(''), new Path(''))).toThrowError();
|
||||
expect(() => Path.compareArrayPath(new Path('a.b.c'), new Path('a.b'))).toThrowError();
|
||||
});
|
||||
it('isChildOrGrandChild', () => {
|
||||
expect(new Path('a.b').isChildOrGrandChild('a.b.c')).toEqual(true);
|
||||
expect(new Path('a.b').isChildOrGrandChild('a.b[0]')).toEqual(true);
|
||||
expect(new Path('a.b').isChildOrGrandChild('a.b.1')).toEqual(true);
|
||||
expect(new Path('a.b').isChildOrGrandChild('a.b')).toEqual(false);
|
||||
expect(new Path('a.b').isChildOrGrandChild('a')).toEqual(false);
|
||||
expect(new Path('').isChildOrGrandChild('a')).toEqual(true);
|
||||
});
|
||||
|
||||
it('replaceParent', () => {
|
||||
expect(new Path('a.b.c.d').replaceParent(new Path('a.b'), new Path('x.y')).toString()).toEqual(
|
||||
'x.y.c.d'
|
||||
);
|
||||
expect(new Path('a.b.0.d').replaceParent(new Path('a.b'), new Path('x.y')).toString()).toEqual(
|
||||
'x.y.0.d'
|
||||
);
|
||||
expect(new Path('0.d').replaceParent(new Path(''), new Path('x.y')).toString()).toEqual(
|
||||
'x.y.0.d'
|
||||
);
|
||||
expect(
|
||||
new Path('a.b.c').replaceParent(new Path('a.b.c'), new Path('x.y.z')).toString()
|
||||
).toEqual('x.y.z');
|
||||
expect(() =>
|
||||
new Path('a.b.0.d').replaceParent(new Path('a1.b'), new Path('x.y')).toString()
|
||||
).toThrowError();
|
||||
|
||||
expect(() =>
|
||||
new Path('a.0.d').replaceParent(new Path('a.0.d.e'), new Path('x.y')).toString()
|
||||
).toThrowError();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { toFieldArray } from '@/core/to-field-array';
|
||||
import { FormModel } from '@/core/form-model';
|
||||
import { FieldArrayModel } from '@/core/field-array-model';
|
||||
|
||||
describe('toFieldArray', () => {
|
||||
let formModel: FormModel;
|
||||
let fieldArrayModel: FieldArrayModel;
|
||||
|
||||
beforeEach(() => {
|
||||
formModel = new FormModel();
|
||||
formModel.init({});
|
||||
formModel.createFieldArray('users');
|
||||
fieldArrayModel = formModel.getField<FieldArrayModel>('users')!;
|
||||
});
|
||||
|
||||
it('should convert FieldArrayModel to FieldArray', () => {
|
||||
const fieldArray = toFieldArray(fieldArrayModel);
|
||||
|
||||
expect(fieldArray).toBeDefined();
|
||||
expect(fieldArray.name).toBe('users');
|
||||
expect(fieldArray.value === undefined || Array.isArray(fieldArray.value)).toBe(true);
|
||||
});
|
||||
|
||||
it('should expose key property from model id', () => {
|
||||
const fieldArray = toFieldArray(fieldArrayModel);
|
||||
|
||||
expect(fieldArray.key).toBe(fieldArrayModel.id);
|
||||
});
|
||||
|
||||
it('should expose name property from model path', () => {
|
||||
const fieldArray = toFieldArray(fieldArrayModel);
|
||||
|
||||
expect(fieldArray.name).toBe('users');
|
||||
expect(fieldArray.name).toBe(fieldArrayModel.path.toString());
|
||||
});
|
||||
|
||||
it('should expose value property from model value', () => {
|
||||
fieldArrayModel.value = [{ name: 'Alice' }, { name: 'Bob' }];
|
||||
const fieldArray = toFieldArray(fieldArrayModel);
|
||||
|
||||
expect(fieldArray.value).toEqual([{ name: 'Alice' }, { name: 'Bob' }]);
|
||||
expect(fieldArray.value).toBe(fieldArrayModel.value);
|
||||
});
|
||||
|
||||
it('should update model value via onChange', () => {
|
||||
const fieldArray = toFieldArray(fieldArrayModel);
|
||||
const newValue = [{ name: 'Charlie' }];
|
||||
|
||||
fieldArray.onChange(newValue);
|
||||
|
||||
expect(fieldArrayModel.value).toEqual(newValue);
|
||||
expect(fieldArray.value).toEqual(newValue);
|
||||
});
|
||||
|
||||
it('should map over array elements correctly', () => {
|
||||
fieldArrayModel.value = [{ name: 'Alice' }, { name: 'Bob' }];
|
||||
const fieldArray = toFieldArray(fieldArrayModel);
|
||||
|
||||
const mapped = fieldArray.map((field, index) => {
|
||||
expect(field).toBeDefined();
|
||||
expect(field.name).toBe(`users.${index}`);
|
||||
return field.value;
|
||||
});
|
||||
|
||||
expect(mapped).toHaveLength(2);
|
||||
expect(mapped).toEqual([{ name: 'Alice' }, { name: 'Bob' }]);
|
||||
});
|
||||
|
||||
it('should append new item and return Field', () => {
|
||||
const fieldArray = toFieldArray(fieldArrayModel);
|
||||
const newItem = { name: 'Charlie' };
|
||||
|
||||
const newField = fieldArray.append(newItem);
|
||||
|
||||
expect(newField).toBeDefined();
|
||||
expect(newField.name).toBe('users.0');
|
||||
expect(newField.value).toEqual(newItem);
|
||||
expect(fieldArrayModel.value).toEqual([newItem]);
|
||||
});
|
||||
|
||||
it('should delete item by index', () => {
|
||||
fieldArrayModel.value = [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Charlie' }];
|
||||
const fieldArray = toFieldArray(fieldArrayModel);
|
||||
|
||||
fieldArray.delete(1);
|
||||
|
||||
expect(fieldArrayModel.value).toEqual([{ name: 'Alice' }, { name: 'Charlie' }]);
|
||||
expect(fieldArray.value).toEqual([{ name: 'Alice' }, { name: 'Charlie' }]);
|
||||
});
|
||||
|
||||
it('should remove item by index (same as delete)', () => {
|
||||
fieldArrayModel.value = [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Charlie' }];
|
||||
const fieldArray = toFieldArray(fieldArrayModel);
|
||||
|
||||
fieldArray.remove(1);
|
||||
|
||||
expect(fieldArrayModel.value).toEqual([{ name: 'Alice' }, { name: 'Charlie' }]);
|
||||
expect(fieldArray.value).toEqual([{ name: 'Alice' }, { name: 'Charlie' }]);
|
||||
});
|
||||
|
||||
it('should swap items at two indices', () => {
|
||||
fieldArrayModel.value = [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Charlie' }];
|
||||
const fieldArray = toFieldArray(fieldArrayModel);
|
||||
|
||||
fieldArray.swap(0, 2);
|
||||
|
||||
expect(fieldArrayModel.value).toEqual([
|
||||
{ name: 'Charlie' },
|
||||
{ name: 'Bob' },
|
||||
{ name: 'Alice' },
|
||||
]);
|
||||
expect(fieldArray.value).toEqual([{ name: 'Charlie' }, { name: 'Bob' }, { name: 'Alice' }]);
|
||||
});
|
||||
|
||||
it('should move item from one index to another', () => {
|
||||
fieldArrayModel.value = [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Charlie' }];
|
||||
const fieldArray = toFieldArray(fieldArrayModel);
|
||||
|
||||
fieldArray.move(0, 2);
|
||||
|
||||
expect(fieldArrayModel.value).toEqual([
|
||||
{ name: 'Bob' },
|
||||
{ name: 'Charlie' },
|
||||
{ name: 'Alice' },
|
||||
]);
|
||||
expect(fieldArray.value).toEqual([{ name: 'Bob' }, { name: 'Charlie' }, { name: 'Alice' }]);
|
||||
});
|
||||
|
||||
it('should hide _fieldModel property (non-enumerable)', () => {
|
||||
const fieldArray = toFieldArray(fieldArrayModel);
|
||||
|
||||
// _fieldModel should exist but not be enumerable
|
||||
expect((fieldArray as any)._fieldModel).toBe(fieldArrayModel);
|
||||
expect(Object.keys(fieldArray)).not.toContain('_fieldModel');
|
||||
});
|
||||
|
||||
it('should support complex nested operations', () => {
|
||||
const fieldArray = toFieldArray(fieldArrayModel);
|
||||
|
||||
// Append multiple items
|
||||
fieldArray.append({ name: 'Alice', age: 30 });
|
||||
fieldArray.append({ name: 'Bob', age: 25 });
|
||||
fieldArray.append({ name: 'Charlie', age: 35 });
|
||||
|
||||
expect(fieldArray.value).toHaveLength(3);
|
||||
|
||||
// Map and modify
|
||||
const names = fieldArray.map((field) => field.value.name);
|
||||
expect(names).toEqual(['Alice', 'Bob', 'Charlie']);
|
||||
|
||||
// Swap
|
||||
fieldArray.swap(0, 1);
|
||||
expect(fieldArray.value[0].name).toBe('Bob');
|
||||
expect(fieldArray.value[1].name).toBe('Alice');
|
||||
|
||||
// Remove
|
||||
fieldArray.remove(2);
|
||||
expect(fieldArray.value).toHaveLength(2);
|
||||
|
||||
// Move
|
||||
fieldArray.move(1, 0);
|
||||
expect(fieldArray.value[0].name).toBe('Alice');
|
||||
expect(fieldArray.value[1].name).toBe('Bob');
|
||||
});
|
||||
|
||||
it('should work with empty array', () => {
|
||||
const fieldArray = toFieldArray(fieldArrayModel);
|
||||
|
||||
// Value might be undefined or empty array initially
|
||||
expect(fieldArray.value === undefined || Array.isArray(fieldArray.value)).toBe(true);
|
||||
|
||||
const mapped = fieldArray.map((field) => field.value);
|
||||
expect(Array.isArray(mapped)).toBe(true);
|
||||
expect(mapped.length === 0 || mapped.length > 0).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve reactivity through getters', () => {
|
||||
const fieldArray = toFieldArray(fieldArrayModel);
|
||||
|
||||
// Initial value might be undefined or empty array
|
||||
expect(fieldArray.value === undefined || Array.isArray(fieldArray.value)).toBe(true);
|
||||
|
||||
// Modify through model
|
||||
fieldArrayModel.value = [{ name: 'Alice' }];
|
||||
|
||||
// Should reflect in fieldArray (getter)
|
||||
expect(fieldArray.value).toEqual([{ name: 'Alice' }]);
|
||||
|
||||
// Modify through fieldArray
|
||||
fieldArray.onChange([{ name: 'Bob' }]);
|
||||
|
||||
// Should reflect in model
|
||||
expect(fieldArrayModel.value).toEqual([{ name: 'Bob' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ValidateTrigger } from '@/types';
|
||||
import { toField, toFieldState } from '@/core/to-field';
|
||||
import { FormModel } from '@/core/form-model';
|
||||
import { FieldModel } from '@/core/field-model';
|
||||
|
||||
describe('toField', () => {
|
||||
let formModel: FormModel;
|
||||
let fieldModel: FieldModel;
|
||||
|
||||
beforeEach(() => {
|
||||
formModel = new FormModel();
|
||||
formModel.init({});
|
||||
fieldModel = formModel.createField('username') as FieldModel;
|
||||
});
|
||||
|
||||
it('should convert FieldModel to Field', () => {
|
||||
const field = toField(fieldModel);
|
||||
|
||||
expect(field).toBeDefined();
|
||||
expect(field.name).toBe('username');
|
||||
expect(field.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should expose name property from model', () => {
|
||||
const field = toField(fieldModel);
|
||||
|
||||
expect(field.name).toBe(fieldModel.name);
|
||||
});
|
||||
|
||||
it('should expose value property from model', () => {
|
||||
fieldModel.value = 'John';
|
||||
const field = toField(fieldModel);
|
||||
|
||||
expect(field.value).toBe('John');
|
||||
expect(field.value).toBe(fieldModel.value);
|
||||
});
|
||||
|
||||
describe('onChange', () => {
|
||||
it('should update model value with plain value', () => {
|
||||
const field = toField(fieldModel);
|
||||
|
||||
field.onChange('Alice');
|
||||
|
||||
expect(fieldModel.value).toBe('Alice');
|
||||
expect(field.value).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should handle React change event for input', () => {
|
||||
const field = toField(fieldModel);
|
||||
const mockEvent = {
|
||||
target: {
|
||||
value: 'Bob',
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
|
||||
field.onChange(mockEvent);
|
||||
|
||||
expect(fieldModel.value).toBe('Bob');
|
||||
});
|
||||
|
||||
it('should handle React change event for checkbox (checked)', () => {
|
||||
const field = toField(fieldModel);
|
||||
const mockEvent = {
|
||||
target: {
|
||||
type: 'checkbox',
|
||||
checked: true,
|
||||
value: 'on',
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
|
||||
field.onChange(mockEvent);
|
||||
|
||||
expect(fieldModel.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle React change event for checkbox (unchecked)', () => {
|
||||
const field = toField(fieldModel);
|
||||
const mockEvent = {
|
||||
target: {
|
||||
type: 'checkbox',
|
||||
checked: false,
|
||||
value: 'on',
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
|
||||
field.onChange(mockEvent);
|
||||
|
||||
expect(fieldModel.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle numeric value', () => {
|
||||
const field = toField(fieldModel);
|
||||
|
||||
field.onChange(42);
|
||||
|
||||
expect(fieldModel.value).toBe(42);
|
||||
});
|
||||
|
||||
it('should handle object value', () => {
|
||||
const field = toField(fieldModel);
|
||||
const objValue = { name: 'test', value: 123 };
|
||||
|
||||
field.onChange(objValue);
|
||||
|
||||
expect(fieldModel.value).toEqual(objValue);
|
||||
});
|
||||
|
||||
it('should handle array value', () => {
|
||||
const field = toField(fieldModel);
|
||||
const arrValue = ['a', 'b', 'c'];
|
||||
|
||||
field.onChange(arrValue);
|
||||
|
||||
expect(fieldModel.value).toEqual(arrValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onBlur', () => {
|
||||
it('should call validate when validateTrigger is onBlur', () => {
|
||||
formModel.dispose();
|
||||
formModel = new FormModel();
|
||||
formModel.init({ validateTrigger: ValidateTrigger.onBlur });
|
||||
fieldModel = formModel.createField('username') as FieldModel;
|
||||
|
||||
const validateSpy = vi.spyOn(fieldModel, 'validate');
|
||||
const field = toField(fieldModel);
|
||||
|
||||
field.onBlur?.();
|
||||
|
||||
expect(validateSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not trigger validation when validateTrigger is not onBlur', () => {
|
||||
formModel.dispose();
|
||||
formModel = new FormModel();
|
||||
formModel.init({ validateTrigger: ValidateTrigger.onChange });
|
||||
fieldModel = formModel.createField('username') as FieldModel;
|
||||
|
||||
const validateSpy = vi.spyOn(fieldModel, 'validate');
|
||||
const field = toField(fieldModel);
|
||||
|
||||
field.onBlur?.();
|
||||
|
||||
expect(validateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not trigger validation when validateTrigger is onSubmit', () => {
|
||||
formModel.dispose();
|
||||
formModel = new FormModel();
|
||||
formModel.init({ validateTrigger: ValidateTrigger.onSubmit });
|
||||
fieldModel = formModel.createField('username') as FieldModel;
|
||||
|
||||
const validateSpy = vi.spyOn(fieldModel, 'validate');
|
||||
const field = toField(fieldModel);
|
||||
|
||||
field.onBlur?.();
|
||||
|
||||
expect(validateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onFocus', () => {
|
||||
it('should set isTouched to true', () => {
|
||||
const field = toField(fieldModel);
|
||||
|
||||
expect(fieldModel.state.isTouched).toBe(false);
|
||||
|
||||
field.onFocus?.();
|
||||
|
||||
expect(fieldModel.state.isTouched).toBe(true);
|
||||
});
|
||||
|
||||
it('should set isTouched only once', () => {
|
||||
const field = toField(fieldModel);
|
||||
|
||||
field.onFocus?.();
|
||||
expect(fieldModel.state.isTouched).toBe(true);
|
||||
|
||||
field.onFocus?.();
|
||||
expect(fieldModel.state.isTouched).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should expose key property (non-enumerable)', () => {
|
||||
const field = toField(fieldModel);
|
||||
|
||||
expect((field as any).key).toBe(fieldModel.id);
|
||||
expect(Object.keys(field)).not.toContain('key');
|
||||
});
|
||||
|
||||
it('should hide _fieldModel property (non-enumerable)', () => {
|
||||
const field = toField(fieldModel);
|
||||
|
||||
expect((field as any)._fieldModel).toBe(fieldModel);
|
||||
expect(Object.keys(field)).not.toContain('_fieldModel');
|
||||
});
|
||||
|
||||
it('should preserve reactivity through getters', () => {
|
||||
const field = toField(fieldModel);
|
||||
|
||||
expect(field.name).toBe('username');
|
||||
expect(field.value).toBeUndefined();
|
||||
|
||||
fieldModel.value = 'NewValue';
|
||||
|
||||
expect(field.value).toBe('NewValue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toFieldState', () => {
|
||||
let formModel: FormModel;
|
||||
let fieldModel: FieldModel;
|
||||
|
||||
beforeEach(() => {
|
||||
formModel = new FormModel();
|
||||
formModel.init({});
|
||||
fieldModel = formModel.createField('username') as FieldModel;
|
||||
});
|
||||
|
||||
it('should convert FieldModelState to FieldState', () => {
|
||||
const fieldState = toFieldState(fieldModel.state);
|
||||
|
||||
expect(fieldState).toBeDefined();
|
||||
expect(fieldState.isTouched).toBe(false);
|
||||
expect(fieldState.isDirty).toBe(false);
|
||||
expect(fieldState.invalid).toBe(false);
|
||||
expect(fieldState.isValidating).toBe(false);
|
||||
});
|
||||
|
||||
it('should reflect isTouched state', () => {
|
||||
const fieldState = toFieldState(fieldModel.state);
|
||||
|
||||
expect(fieldState.isTouched).toBe(false);
|
||||
|
||||
fieldModel.state.isTouched = true;
|
||||
|
||||
expect(fieldState.isTouched).toBe(true);
|
||||
});
|
||||
|
||||
it('should reflect isDirty state', () => {
|
||||
const fieldState = toFieldState(fieldModel.state);
|
||||
|
||||
expect(fieldState.isDirty).toBe(false);
|
||||
|
||||
// Manually set dirty state
|
||||
fieldModel.state.isDirty = true;
|
||||
|
||||
expect(fieldState.isDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('should reflect invalid state', () => {
|
||||
const fieldState = toFieldState(fieldModel.state);
|
||||
|
||||
expect(fieldState.invalid).toBe(false);
|
||||
|
||||
fieldModel.state.invalid = true;
|
||||
|
||||
expect(fieldState.invalid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reflect isValidating state', () => {
|
||||
const fieldState = toFieldState(fieldModel.state);
|
||||
|
||||
expect(fieldState.isValidating).toBe(false);
|
||||
|
||||
fieldModel.state.isValidating = true;
|
||||
|
||||
expect(fieldState.isValidating).toBe(true);
|
||||
});
|
||||
|
||||
it('should return errors as flat array', () => {
|
||||
const fieldState = toFieldState(fieldModel.state);
|
||||
|
||||
expect(fieldState.errors).toBeUndefined();
|
||||
|
||||
fieldModel.state.errors = {
|
||||
validate1: ['Error 1', 'Error 2'],
|
||||
validate2: ['Error 3'],
|
||||
};
|
||||
|
||||
expect(fieldState.errors).toEqual(['Error 1', 'Error 2', 'Error 3']);
|
||||
});
|
||||
|
||||
it('should return warnings as flat array', () => {
|
||||
const fieldState = toFieldState(fieldModel.state);
|
||||
|
||||
expect(fieldState.warnings).toBeUndefined();
|
||||
|
||||
fieldModel.state.warnings = {
|
||||
validate1: ['Warning 1', 'Warning 2'],
|
||||
validate2: ['Warning 3'],
|
||||
};
|
||||
|
||||
expect(fieldState.warnings).toEqual(['Warning 1', 'Warning 2', 'Warning 3']);
|
||||
});
|
||||
|
||||
it('should handle empty errors object', () => {
|
||||
const fieldState = toFieldState(fieldModel.state);
|
||||
|
||||
fieldModel.state.errors = {};
|
||||
|
||||
expect(fieldState.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty warnings object', () => {
|
||||
const fieldState = toFieldState(fieldModel.state);
|
||||
|
||||
fieldModel.state.warnings = {};
|
||||
|
||||
expect(fieldState.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('should preserve reactivity through getters', () => {
|
||||
const fieldState = toFieldState(fieldModel.state);
|
||||
|
||||
expect(fieldState.isTouched).toBe(false);
|
||||
|
||||
fieldModel.state.isTouched = true;
|
||||
|
||||
expect(fieldState.isTouched).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { toForm, toFormState } from '@/core/to-form';
|
||||
import { FormModel } from '@/core/form-model';
|
||||
|
||||
describe('toForm', () => {
|
||||
let formModel: FormModel;
|
||||
|
||||
beforeEach(() => {
|
||||
formModel = new FormModel();
|
||||
formModel.init({
|
||||
initialValues: {
|
||||
username: 'John',
|
||||
email: 'john@example.com',
|
||||
age: 30,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert FormModel to Form', () => {
|
||||
const form = toForm(formModel);
|
||||
|
||||
expect(form).toBeDefined();
|
||||
expect(form.initialValues).toEqual({
|
||||
username: 'John',
|
||||
email: 'john@example.com',
|
||||
age: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('should expose initialValues from model', () => {
|
||||
const form = toForm(formModel);
|
||||
|
||||
expect(form.initialValues).toBe(formModel.initialValues);
|
||||
});
|
||||
|
||||
it('should expose values getter from model', () => {
|
||||
const form = toForm(formModel);
|
||||
|
||||
expect(form.values).toEqual({
|
||||
username: 'John',
|
||||
email: 'john@example.com',
|
||||
age: 30,
|
||||
});
|
||||
expect(form.values).toEqual(formModel.values);
|
||||
});
|
||||
|
||||
it('should expose values setter to update model', () => {
|
||||
const form = toForm(formModel);
|
||||
const newValues = {
|
||||
username: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
age: 25,
|
||||
};
|
||||
|
||||
form.values = newValues;
|
||||
|
||||
expect(formModel.values).toEqual(newValues);
|
||||
expect(form.values).toEqual(newValues);
|
||||
});
|
||||
|
||||
it('should expose state as FormState', () => {
|
||||
const form = toForm(formModel);
|
||||
|
||||
expect(form.state).toBeDefined();
|
||||
expect(form.state.isTouched).toBe(false);
|
||||
expect(form.state.isDirty).toBe(false);
|
||||
expect(form.state.invalid).toBe(false);
|
||||
expect(form.state.isValidating).toBe(false);
|
||||
});
|
||||
|
||||
describe('getValueIn', () => {
|
||||
it('should get value by field name', () => {
|
||||
const form = toForm(formModel);
|
||||
|
||||
expect(form.getValueIn('username')).toBe('John');
|
||||
expect(form.getValueIn('email')).toBe('john@example.com');
|
||||
expect(form.getValueIn('age')).toBe(30);
|
||||
});
|
||||
|
||||
it('should get nested value by path', () => {
|
||||
formModel.values = {
|
||||
user: {
|
||||
profile: {
|
||||
name: 'Alice',
|
||||
age: 25,
|
||||
},
|
||||
},
|
||||
};
|
||||
const form = toForm(formModel);
|
||||
|
||||
expect(form.getValueIn('user.profile.name')).toBe('Alice');
|
||||
expect(form.getValueIn('user.profile.age')).toBe(25);
|
||||
});
|
||||
|
||||
it('should get array value by index', () => {
|
||||
formModel.values = {
|
||||
users: [{ name: 'Alice' }, { name: 'Bob' }],
|
||||
};
|
||||
const form = toForm(formModel);
|
||||
|
||||
expect(form.getValueIn('users.0.name')).toBe('Alice');
|
||||
expect(form.getValueIn('users.1.name')).toBe('Bob');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent path', () => {
|
||||
const form = toForm(formModel);
|
||||
|
||||
expect(form.getValueIn('nonexistent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setValueIn', () => {
|
||||
it('should set value by field name', () => {
|
||||
const form = toForm(formModel);
|
||||
|
||||
form.setValueIn('username', 'Bob');
|
||||
|
||||
expect(formModel.values.username).toBe('Bob');
|
||||
expect(form.values.username).toBe('Bob');
|
||||
});
|
||||
|
||||
it('should set nested value by path', () => {
|
||||
formModel.values = {
|
||||
user: {
|
||||
profile: {
|
||||
name: 'Alice',
|
||||
age: 25,
|
||||
},
|
||||
},
|
||||
};
|
||||
const form = toForm(formModel);
|
||||
|
||||
form.setValueIn('user.profile.name', 'Charlie');
|
||||
|
||||
expect(formModel.values.user.profile.name).toBe('Charlie');
|
||||
});
|
||||
|
||||
it('should set array value by index', () => {
|
||||
formModel.values = {
|
||||
users: [{ name: 'Alice' }, { name: 'Bob' }],
|
||||
};
|
||||
const form = toForm(formModel);
|
||||
|
||||
form.setValueIn('users.0.name', 'Charlie');
|
||||
|
||||
expect(formModel.values.users[0].name).toBe('Charlie');
|
||||
});
|
||||
|
||||
it('should create nested structure if not exists', () => {
|
||||
formModel.values = {};
|
||||
const form = toForm(formModel);
|
||||
|
||||
form.setValueIn('user.profile.name', 'Alice');
|
||||
|
||||
expect(formModel.values.user.profile.name).toBe('Alice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate', () => {
|
||||
it('should bind model validate method', () => {
|
||||
const form = toForm(formModel);
|
||||
|
||||
expect(form.validate).toBeDefined();
|
||||
expect(typeof form.validate).toBe('function');
|
||||
});
|
||||
|
||||
it('should call form validate method', async () => {
|
||||
const form = toForm(formModel);
|
||||
|
||||
// Validate should be callable
|
||||
const result = await form.validate();
|
||||
|
||||
// Without validators, should return empty object or undefined
|
||||
expect(result === undefined || Object.keys(result || {}).length === 0).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should hide _formModel property (non-enumerable)', () => {
|
||||
const form = toForm(formModel);
|
||||
|
||||
expect((form as any)._formModel).toBe(formModel);
|
||||
expect(Object.keys(form)).not.toContain('_formModel');
|
||||
});
|
||||
|
||||
it('should preserve reactivity through getters', () => {
|
||||
const form = toForm(formModel);
|
||||
|
||||
expect(form.values.username).toBe('John');
|
||||
|
||||
formModel.values = { username: 'Alice' };
|
||||
|
||||
expect(form.values.username).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should work with empty initialValues', () => {
|
||||
const emptyFormModel = new FormModel();
|
||||
emptyFormModel.init({});
|
||||
const form = toForm(emptyFormModel);
|
||||
|
||||
expect(form.initialValues).toBeUndefined();
|
||||
expect(form.values).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toFormState', () => {
|
||||
let formModel: FormModel;
|
||||
|
||||
beforeEach(() => {
|
||||
formModel = new FormModel();
|
||||
formModel.init({
|
||||
initialValues: {
|
||||
username: 'John',
|
||||
email: 'john@example.com',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert FormModelState to FormState', () => {
|
||||
const formState = toFormState(formModel.state);
|
||||
|
||||
expect(formState).toBeDefined();
|
||||
expect(formState.isTouched).toBe(false);
|
||||
expect(formState.isDirty).toBe(false);
|
||||
expect(formState.invalid).toBe(false);
|
||||
expect(formState.isValidating).toBe(false);
|
||||
});
|
||||
|
||||
it('should reflect isTouched state', () => {
|
||||
const formState = toFormState(formModel.state);
|
||||
|
||||
expect(formState.isTouched).toBe(false);
|
||||
|
||||
formModel.state.isTouched = true;
|
||||
|
||||
expect(formState.isTouched).toBe(true);
|
||||
});
|
||||
|
||||
it('should reflect isDirty state', () => {
|
||||
const formState = toFormState(formModel.state);
|
||||
|
||||
expect(formState.isDirty).toBe(false);
|
||||
|
||||
// Manually set dirty state
|
||||
formModel.state.isDirty = true;
|
||||
|
||||
expect(formState.isDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('should reflect invalid state', () => {
|
||||
const formState = toFormState(formModel.state);
|
||||
|
||||
expect(formState.invalid).toBe(false);
|
||||
|
||||
formModel.state.invalid = true;
|
||||
|
||||
expect(formState.invalid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reflect isValidating state', () => {
|
||||
const formState = toFormState(formModel.state);
|
||||
|
||||
expect(formState.isValidating).toBe(false);
|
||||
|
||||
formModel.state.isValidating = true;
|
||||
|
||||
expect(formState.isValidating).toBe(true);
|
||||
});
|
||||
|
||||
it('should expose errors from model state', () => {
|
||||
const formState = toFormState(formModel.state);
|
||||
|
||||
expect(formState.errors).toBeUndefined();
|
||||
|
||||
formModel.state.errors = {
|
||||
username: 'Username is required',
|
||||
email: 'Invalid email format',
|
||||
};
|
||||
|
||||
expect(formState.errors).toEqual({
|
||||
username: 'Username is required',
|
||||
email: 'Invalid email format',
|
||||
});
|
||||
});
|
||||
|
||||
it('should expose warnings from model state', () => {
|
||||
const formState = toFormState(formModel.state);
|
||||
|
||||
expect(formState.warnings).toBeUndefined();
|
||||
|
||||
formModel.state.warnings = {
|
||||
username: 'Username should be longer',
|
||||
email: 'Consider using a different email',
|
||||
};
|
||||
|
||||
expect(formState.warnings).toEqual({
|
||||
username: 'Username should be longer',
|
||||
email: 'Consider using a different email',
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve reactivity through getters', () => {
|
||||
const formState = toFormState(formModel.state);
|
||||
|
||||
expect(formState.isTouched).toBe(false);
|
||||
|
||||
formModel.state.isTouched = true;
|
||||
|
||||
expect(formState.isTouched).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Errors } from '@/types';
|
||||
import { FieldEventUtils, mergeFeedbacks } from '@/core/utils';
|
||||
|
||||
describe('core/utils', () => {
|
||||
describe('mergeFeedbacks', () => {
|
||||
it('should merge when some key in source is empty array', () => {
|
||||
const origin = {
|
||||
a: ['error'],
|
||||
b: ['error'],
|
||||
} as unknown as Errors;
|
||||
const source = {
|
||||
a: [],
|
||||
} as unknown as Errors;
|
||||
|
||||
const result = mergeFeedbacks(origin, source);
|
||||
expect(result).toEqual({
|
||||
a: [],
|
||||
b: ['error'],
|
||||
});
|
||||
});
|
||||
it('should merge when some key in source is undefined', () => {
|
||||
const origin = {
|
||||
a: ['error'],
|
||||
b: ['error'],
|
||||
} as unknown as Errors;
|
||||
const source = {
|
||||
a: undefined,
|
||||
} as unknown as Errors;
|
||||
|
||||
const result = mergeFeedbacks(origin, source);
|
||||
expect(result).toEqual({
|
||||
a: undefined,
|
||||
b: ['error'],
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('FieldEventUtils.shouldTriggerFieldChangeEvent', () => {
|
||||
it('array append: should not trigger for all array child or grand child', () => {
|
||||
expect(
|
||||
FieldEventUtils.shouldTriggerFieldChangeEvent(
|
||||
{
|
||||
values: {},
|
||||
prevValues: {},
|
||||
name: 'arr',
|
||||
options: {
|
||||
action: 'array-append',
|
||||
indexes: [0],
|
||||
},
|
||||
},
|
||||
'arr.0',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
FieldEventUtils.shouldTriggerFieldChangeEvent(
|
||||
{
|
||||
values: {},
|
||||
prevValues: {},
|
||||
name: 'arr',
|
||||
options: {
|
||||
action: 'array-append',
|
||||
indexes: [0],
|
||||
},
|
||||
},
|
||||
'arr.0.x',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
FieldEventUtils.shouldTriggerFieldChangeEvent(
|
||||
{
|
||||
values: {},
|
||||
prevValues: {},
|
||||
name: 'arr',
|
||||
options: {
|
||||
action: 'array-append',
|
||||
indexes: [0],
|
||||
},
|
||||
},
|
||||
'arr',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
FieldEventUtils.shouldTriggerFieldChangeEvent(
|
||||
{
|
||||
values: {},
|
||||
prevValues: {},
|
||||
name: 'p.arr',
|
||||
options: {
|
||||
action: 'array-append',
|
||||
indexes: [0],
|
||||
},
|
||||
},
|
||||
'p',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
FieldEventUtils.shouldTriggerFieldChangeEvent(
|
||||
{
|
||||
values: {},
|
||||
prevValues: {},
|
||||
name: '',
|
||||
options: {
|
||||
action: 'array-append',
|
||||
indexes: [0],
|
||||
},
|
||||
},
|
||||
'0',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it('array splice: should not trigger for array child or grand child only when index < first spliced index', () => {
|
||||
expect(
|
||||
FieldEventUtils.shouldTriggerFieldChangeEvent(
|
||||
{
|
||||
values: {},
|
||||
prevValues: {},
|
||||
name: 'arr',
|
||||
options: {
|
||||
action: 'array-splice',
|
||||
indexes: [0],
|
||||
},
|
||||
},
|
||||
'arr.0',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
FieldEventUtils.shouldTriggerFieldChangeEvent(
|
||||
{
|
||||
values: {},
|
||||
prevValues: {},
|
||||
name: 'arr',
|
||||
options: {
|
||||
action: 'array-splice',
|
||||
indexes: [0],
|
||||
},
|
||||
},
|
||||
'arr.1',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
FieldEventUtils.shouldTriggerFieldChangeEvent(
|
||||
{
|
||||
values: {},
|
||||
prevValues: {},
|
||||
name: 'arr',
|
||||
options: {
|
||||
action: 'array-splice',
|
||||
indexes: [1],
|
||||
},
|
||||
},
|
||||
'arr.0',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
FieldEventUtils.shouldTriggerFieldChangeEvent(
|
||||
{
|
||||
values: {},
|
||||
prevValues: {},
|
||||
name: 'arr',
|
||||
options: {
|
||||
action: 'array-splice',
|
||||
indexes: [1, 2],
|
||||
},
|
||||
},
|
||||
'arr.1',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
FieldEventUtils.shouldTriggerFieldChangeEvent(
|
||||
{
|
||||
values: {},
|
||||
prevValues: {},
|
||||
name: 'arr',
|
||||
options: {
|
||||
action: 'array-splice',
|
||||
indexes: [4, 5],
|
||||
},
|
||||
},
|
||||
'arr.1',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
FieldEventUtils.shouldTriggerFieldChangeEvent(
|
||||
{
|
||||
values: {},
|
||||
prevValues: {},
|
||||
name: 'arr',
|
||||
options: {
|
||||
action: 'array-splice',
|
||||
indexes: [],
|
||||
},
|
||||
},
|
||||
'arr.1',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { hasError } from '../src/utils/validate';
|
||||
import { FeedbackLevel, FieldError } from '../src/types';
|
||||
|
||||
describe('utils/validate', () => {
|
||||
describe('hasError', () => {
|
||||
it('should return false when errors is empty', () => {
|
||||
expect(hasError({ xxx: [] })).toBe(false);
|
||||
expect(hasError({ xxx: undefined })).toBe(false);
|
||||
expect(hasError({})).toBe(false);
|
||||
expect(hasError({ aaa: [], bbb: [] })).toBe(false);
|
||||
expect(hasError({ aaa: undefined, bbb: [] })).toBe(false);
|
||||
});
|
||||
it('should return true when errors is not empty', () => {
|
||||
const mockError: FieldError = { name: 'xxx', level: FeedbackLevel.Error, message: 'err' };
|
||||
expect(hasError({ xxx: [mockError] })).toBe(true);
|
||||
expect(hasError({ aaa: [mockError], bbb: [mockError] })).toBe(true);
|
||||
expect(hasError({ aaa: undefined, bbb: [mockError] })).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,61 @@
|
||||
{
|
||||
"name": "@flowgram.ai/form",
|
||||
"version": "0.1.8",
|
||||
"description": "form",
|
||||
"keywords": [
|
||||
"flow",
|
||||
"engine"
|
||||
],
|
||||
"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/reactive": "workspace:*",
|
||||
"@flowgram.ai/utils": "workspace:*",
|
||||
"fast-equals": "^2.0.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nanoid": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@flowgram.ai/eslint-config": "workspace:*",
|
||||
"@flowgram.ai/ts-config": "workspace:*",
|
||||
"@testing-library/react": "^12",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^9.0.0",
|
||||
"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,34 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FieldModelState } from './types/field';
|
||||
import { FormModelState } from './types';
|
||||
|
||||
export const DEFAULT_FIELD_STATE: FieldModelState = {
|
||||
invalid: false,
|
||||
isDirty: false,
|
||||
isTouched: false,
|
||||
isValidating: false,
|
||||
};
|
||||
export const DEFAULT_FORM_STATE: FormModelState = {
|
||||
invalid: false,
|
||||
isDirty: false,
|
||||
isTouched: false,
|
||||
isValidating: false,
|
||||
};
|
||||
|
||||
export function createFormModelState(initialState?: Partial<FormModelState>) {
|
||||
if (!initialState) {
|
||||
return { ...DEFAULT_FORM_STATE };
|
||||
}
|
||||
return { ...DEFAULT_FORM_STATE, ...initialState };
|
||||
}
|
||||
|
||||
export function createFieldModelState(initialState?: Partial<FieldModelState>): FieldModelState {
|
||||
if (!initialState) {
|
||||
return { ...DEFAULT_FIELD_STATE };
|
||||
}
|
||||
return { ...DEFAULT_FIELD_STATE, ...initialState };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { CreateFormReturn, FormOptions } from '../types/form';
|
||||
import { Field, FieldArray, FieldName, FieldValue } from '../types';
|
||||
import { toForm } from './to-form';
|
||||
import { toFieldArray } from './to-field-array';
|
||||
import { toField } from './to-field';
|
||||
import { FormModel } from './form-model';
|
||||
import { FieldModel } from './field-model';
|
||||
import { FieldArrayModel } from './field-array-model';
|
||||
|
||||
// export interface CreateFormOptions<TValues = any> extends FormOptions<TValues> {
|
||||
// parentContainer?: interfaces.Container;
|
||||
// }
|
||||
|
||||
export type CreateFormOptions<T = any> = FormOptions<T> & {
|
||||
/**
|
||||
* 为 true 时,createForm 不会对form 初始化, 用户需要手动调用 control.init()
|
||||
* 该配置主要为了解决,用户需要去监听一些form 的初始化事件,那么他需要再配置完监听后再初始化。
|
||||
* 该配置默认为 false
|
||||
**/
|
||||
disableAutoInit?: boolean;
|
||||
};
|
||||
|
||||
export function createForm<TValues>(
|
||||
options?: CreateFormOptions<TValues>
|
||||
): CreateFormReturn<TValues> {
|
||||
const { disableAutoInit = false, ...formOptions } = options || {};
|
||||
const formModel = new FormModel();
|
||||
|
||||
if (!disableAutoInit) {
|
||||
formModel.init(formOptions || {});
|
||||
}
|
||||
|
||||
return {
|
||||
form: toForm(formModel),
|
||||
control: {
|
||||
_formModel: formModel,
|
||||
getField: <
|
||||
TFieldValue = FieldValue,
|
||||
TFieldModel extends Field<TFieldValue> | FieldArray<TFieldValue> = Field
|
||||
>(
|
||||
name: FieldName
|
||||
) => {
|
||||
const fieldModel = formModel.getField(name);
|
||||
if (fieldModel) {
|
||||
return fieldModel instanceof FieldArrayModel
|
||||
? toFieldArray<TFieldValue>(fieldModel as unknown as FieldArrayModel<TFieldValue>)
|
||||
: toField<TFieldValue>(fieldModel as unknown as FieldModel<TFieldValue>);
|
||||
}
|
||||
},
|
||||
init: () => formModel.init(formOptions || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { Emitter } from '@flowgram.ai/utils';
|
||||
|
||||
import { FieldValue } from '../types';
|
||||
import { Path } from './path';
|
||||
import { FieldModel } from './field-model';
|
||||
|
||||
export class FieldArrayModel<TValue = FieldValue> extends FieldModel<Array<TValue>> {
|
||||
protected onAppendEmitter = new Emitter<{
|
||||
index: number;
|
||||
value: TValue | undefined;
|
||||
arrayValue: Array<TValue>;
|
||||
}>();
|
||||
|
||||
readonly onAppend = this.onAppendEmitter.event;
|
||||
|
||||
protected onDeleteEmitter = new Emitter<{
|
||||
arrayValue: Array<TValue> | undefined;
|
||||
index: number;
|
||||
}>();
|
||||
|
||||
readonly onDelete = this.onDeleteEmitter.event;
|
||||
|
||||
get children() {
|
||||
const fields: FieldModel[] = [];
|
||||
this.form.fieldMap.forEach((field, name: string) => {
|
||||
if (this.path.isChild(name)) {
|
||||
fields.push(field);
|
||||
}
|
||||
});
|
||||
|
||||
// 按 index 排序
|
||||
return fields.sort((f1, f2) => {
|
||||
const p1 = f1.path.value;
|
||||
const p2 = f2.path.value;
|
||||
const i1 = parseInt(p1[p1.length - 1]);
|
||||
const i2 = parseInt(p2[p2.length - 1]);
|
||||
return i1 - i2;
|
||||
});
|
||||
}
|
||||
|
||||
map<T>(cb: (f: FieldModel, index: number, arr: FieldModel[]) => T) {
|
||||
const fields = (this.value || []).map((v: TValue, i: number) => {
|
||||
const pathString = this.path.concat(i).toString();
|
||||
let field = this.form.getField(pathString);
|
||||
if (!field) {
|
||||
field = this.form.createField(pathString);
|
||||
}
|
||||
return field;
|
||||
});
|
||||
return fields.map(cb);
|
||||
}
|
||||
|
||||
append(value?: TValue) {
|
||||
const curLength = this.value?.length || 0;
|
||||
const newElemPath = this.path.concat(curLength).toString();
|
||||
const newElemField = this.form.createField(newElemPath);
|
||||
const newArrayValue = this.value ? [...this.value, value] : [value];
|
||||
|
||||
const prevFormValues = this.form.values;
|
||||
|
||||
// 设置新的数组值并触发事件
|
||||
this.form.store.setIn(new Path(this.name), newArrayValue);
|
||||
this.form.fireOnFormValuesChange({
|
||||
values: this.form.values,
|
||||
prevValues: prevFormValues,
|
||||
name: this.name,
|
||||
options: {
|
||||
action: 'array-append',
|
||||
indexes: [curLength],
|
||||
},
|
||||
});
|
||||
// 触发新元素的初始值变更
|
||||
this.form.fireOnFormValuesInit({
|
||||
values: this.form.values,
|
||||
prevValues: prevFormValues,
|
||||
name: newElemPath,
|
||||
});
|
||||
|
||||
this.onAppendEmitter.fire({
|
||||
value,
|
||||
arrayValue: this.value as Array<TValue>,
|
||||
index: this.value!.length - 1,
|
||||
});
|
||||
return newElemField;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the element in given index and delete the corresponding FieldModel as well
|
||||
* @param index
|
||||
*/
|
||||
delete(index: number) {
|
||||
// const field = this.form.getField(name);
|
||||
// if (!field) {
|
||||
// throw new Error(
|
||||
// `[Form] Error in FieldArrayModel.delete: delete failed, no field found for name ${name}`,
|
||||
// );
|
||||
// }
|
||||
// const index = field.path.getArrayIndex(this.path);
|
||||
this._splice(index, 1);
|
||||
|
||||
this.onDeleteEmitter.fire({ arrayValue: this.value, index });
|
||||
}
|
||||
|
||||
_splice(start: number, deleteCount = 1) {
|
||||
if (start < 0 || deleteCount < 0) {
|
||||
throw new Error(
|
||||
`[Form] Error in FieldArrayModel.splice: Invalid Params, start and deleteCount should > 0`
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.value || this.value.length === 0 || deleteCount > this.value.length) {
|
||||
throw new Error(
|
||||
`[Form] Error in FieldArrayModel.splice: delete count exceeds array length, tried to delete ${deleteCount} elements, but array length is ${
|
||||
this.value?.length || 0
|
||||
}`
|
||||
);
|
||||
}
|
||||
const oldFormValues = this.form.values;
|
||||
|
||||
const tempValue = [...this.value];
|
||||
tempValue.splice(start, deleteCount);
|
||||
|
||||
// 设置数组值并触发事件
|
||||
this.form.store.setIn(new Path(this.name), tempValue);
|
||||
|
||||
this.form.fireOnFormValuesChange({
|
||||
values: this.form.values,
|
||||
prevValues: oldFormValues,
|
||||
name: this.name,
|
||||
options: {
|
||||
action: 'array-splice',
|
||||
indexes: Array.from({ length: deleteCount }, (_, i) => i + start),
|
||||
},
|
||||
});
|
||||
|
||||
const children = this.children;
|
||||
|
||||
// 如果要删除的元素都在数组末端, 直接删除
|
||||
if (start + deleteCount >= children.length) {
|
||||
for (let i = start; i < children.length; i++) {
|
||||
this.form.disposeField(children[i].name);
|
||||
}
|
||||
}
|
||||
|
||||
const toDispose: FieldModel[] = [];
|
||||
const newFieldMap = new Map<string, FieldModel>(this.form.fieldMap);
|
||||
|
||||
const recursiveHandleChildField = (field: FieldModel, index: number) => {
|
||||
if (field.children?.length) {
|
||||
field.children.forEach((cField) => {
|
||||
recursiveHandleChildField(cField, index);
|
||||
});
|
||||
}
|
||||
// start 以前的项不变
|
||||
if (index < start) {
|
||||
newFieldMap.set(field.name, field);
|
||||
}
|
||||
// 要删除的项, 放入toDispose
|
||||
else if (index < start + deleteCount) {
|
||||
toDispose.push(field);
|
||||
}
|
||||
// 剩余的项 index 向前移动 {deleteCount} 位, 并触发变更事件
|
||||
else {
|
||||
const originName = field.name;
|
||||
const targetName = field.path
|
||||
.replaceParent(this.path.concat(index), this.path.concat(index - deleteCount))
|
||||
.toString();
|
||||
newFieldMap.set(targetName, field);
|
||||
if (!field.children.length) {
|
||||
field.updateNameForLeafState(targetName);
|
||||
field.bubbleState();
|
||||
}
|
||||
field.name = targetName;
|
||||
|
||||
// 最后 {deleteCount} 项,需要fire 被变更为undefined, 并从 newMap 中删除
|
||||
if (index > children.length - deleteCount - 1) {
|
||||
newFieldMap.delete(originName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 对数组所有子项做删除或 index 移动操作
|
||||
children.map((field, index) => {
|
||||
recursiveHandleChildField(field, index);
|
||||
});
|
||||
|
||||
toDispose.forEach((f) => {
|
||||
f.dispose();
|
||||
});
|
||||
this.form.fieldMap = newFieldMap;
|
||||
this.form.alignStateWithFieldMap();
|
||||
}
|
||||
|
||||
swap(from: number, to: number) {
|
||||
if (!this.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (from < 0 || to < 0 || from > this.value.length - 1 || to > this.value.length - 1) {
|
||||
throw new Error(
|
||||
`[Form]: FieldArrayModel.swap Error: invalid params 'form' and 'to', form=${from} to=${to}. expect the value between 0 to ${
|
||||
length - 1
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
const oldFormValues = this.form.values;
|
||||
const tempValue = [...this.value];
|
||||
|
||||
const fromValue = tempValue[from];
|
||||
const toValue = tempValue[to];
|
||||
|
||||
tempValue[to] = fromValue;
|
||||
tempValue[from] = toValue;
|
||||
|
||||
this.form.store.setIn(this.path, tempValue);
|
||||
this.form.fireOnFormValuesChange({
|
||||
values: this.form.values,
|
||||
prevValues: oldFormValues,
|
||||
name: this.name,
|
||||
options: {
|
||||
action: 'array-swap',
|
||||
indexes: [from, to],
|
||||
},
|
||||
});
|
||||
|
||||
// swap related FieldModels
|
||||
const newFieldMap = new Map<string, FieldModel>(this.form.fieldMap);
|
||||
|
||||
const fromFields = this.findAllFieldsAt(from);
|
||||
const toFields = this.findAllFieldsAt(to);
|
||||
const fromRootPath = this.getPathAt(from);
|
||||
const toRootPath = this.getPathAt(to);
|
||||
const leafFieldsModified: FieldModel[] = [];
|
||||
fromFields.forEach((f) => {
|
||||
const newName = f.path.replaceParent(fromRootPath, toRootPath).toString();
|
||||
f.name = newName;
|
||||
if (!f.children.length) {
|
||||
f.updateNameForLeafState(newName);
|
||||
leafFieldsModified.push(f);
|
||||
}
|
||||
newFieldMap.set(newName, f);
|
||||
});
|
||||
toFields.forEach((f) => {
|
||||
const newName = f.path.replaceParent(toRootPath, fromRootPath).toString();
|
||||
f.name = newName;
|
||||
if (!f.children.length) {
|
||||
f.updateNameForLeafState(newName);
|
||||
}
|
||||
newFieldMap.set(newName, f);
|
||||
leafFieldsModified.push(f);
|
||||
});
|
||||
this.form.fieldMap = newFieldMap;
|
||||
leafFieldsModified.forEach((f) => f.bubbleState());
|
||||
this.form.alignStateWithFieldMap();
|
||||
}
|
||||
|
||||
move(from: number, to: number) {
|
||||
if (!this.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (from < 0 || to < 0 || from > this.value.length - 1 || to > this.value.length - 1) {
|
||||
throw new Error(
|
||||
`[Form]: FieldArrayModel.move Error: invalid params 'form' and 'to', form=${from} to=${to}. expect the value between 0 to ${
|
||||
length - 1
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
const tempValue = [...this.value];
|
||||
|
||||
const fromValue = tempValue[from];
|
||||
|
||||
tempValue.splice(from, 1);
|
||||
tempValue.splice(to, 0, fromValue);
|
||||
|
||||
this.form.setValueIn(this.name, tempValue);
|
||||
|
||||
// todo(fix): should move fields in order to make sure fields' state is also moved
|
||||
}
|
||||
|
||||
protected insertAt(index: number, value: TValue) {
|
||||
if (!this.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (index < 0 || index > this.value.length) {
|
||||
throw new Error(`[Form]: FieldArrayModel.insertAt Error: index exceeds array boundary`);
|
||||
}
|
||||
|
||||
const tempValue = [...this.value];
|
||||
tempValue.splice(index, 0, value);
|
||||
this.form.setValueIn(this.name, tempValue);
|
||||
|
||||
// todo: should move field in order to make sure field state is also moved
|
||||
}
|
||||
|
||||
/**
|
||||
* get element path at given index
|
||||
* @param index
|
||||
* @protected
|
||||
*/
|
||||
protected getPathAt(index: number) {
|
||||
return this.path.concat(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* find all fields including child and grandchild fields at given index.
|
||||
* @param index
|
||||
* @protected
|
||||
*/
|
||||
protected findAllFieldsAt(index: number) {
|
||||
const rootPath = this.getPathAt(index);
|
||||
const rootPathString = rootPath.toString();
|
||||
|
||||
const res: FieldModel[] = this.form.fieldMap.get(rootPathString)
|
||||
? [this.form.fieldMap.get(rootPathString)!]
|
||||
: [];
|
||||
|
||||
this.form.fieldMap.forEach((field, fieldName) => {
|
||||
if (rootPath.isChildOrGrandChild(fieldName)) {
|
||||
res.push(field);
|
||||
}
|
||||
});
|
||||
return res;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import { get, groupBy, some } from 'lodash-es';
|
||||
import { Disposable, DisposableCollection, Emitter } from '@flowgram.ai/utils';
|
||||
import { ReactiveState } from '@flowgram.ai/reactive';
|
||||
|
||||
import { toFeedback } from '../utils/validate';
|
||||
import { FieldModelState, FieldName, FieldValue, Ref } from '../types/field';
|
||||
import {
|
||||
Errors,
|
||||
FeedbackLevel,
|
||||
FieldError,
|
||||
FieldWarning,
|
||||
Validate,
|
||||
ValidateTrigger,
|
||||
Warnings,
|
||||
} from '../types';
|
||||
import { createFieldModelState, DEFAULT_FIELD_STATE } from '../constants';
|
||||
import {
|
||||
clearFeedbacks,
|
||||
FieldEventUtils,
|
||||
mergeFeedbacks,
|
||||
shouldValidate,
|
||||
updateFeedbacksName,
|
||||
} from './utils';
|
||||
import { Path } from './path';
|
||||
import { FormModel } from './form-model';
|
||||
|
||||
interface OnValueChangePayload<TValue> {
|
||||
value: TValue | undefined;
|
||||
prevValue: TValue | undefined;
|
||||
formValues: any;
|
||||
prevFormValues: any;
|
||||
}
|
||||
|
||||
export class FieldModel<TValue extends FieldValue = FieldValue> implements Disposable {
|
||||
readonly onValueChangeEmitter = new Emitter<OnValueChangePayload<TValue>>();
|
||||
|
||||
readonly form: FormModel;
|
||||
|
||||
readonly id: string;
|
||||
|
||||
readonly onValueChange = this.onValueChangeEmitter.event;
|
||||
|
||||
protected toDispose = new DisposableCollection();
|
||||
|
||||
protected _ref?: Ref;
|
||||
|
||||
protected _path: Path;
|
||||
|
||||
protected _state: ReactiveState<FieldModelState> = new ReactiveState<FieldModelState>(
|
||||
createFieldModelState()
|
||||
);
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* 原用于直接给field 设置validate 逻辑,现将该逻辑放到form._options.validate 中设置,该字段暂时弃用
|
||||
*/
|
||||
originalValidate?: Validate;
|
||||
|
||||
protected _renderCount: number = 0;
|
||||
|
||||
constructor(path: Path, form: FormModel) {
|
||||
this._path = path;
|
||||
this.form = form;
|
||||
this.id = nanoid();
|
||||
|
||||
const changeDisposable = this.form.onFormValuesChange((payload) => {
|
||||
const { values, prevValues } = payload;
|
||||
if (FieldEventUtils.shouldTriggerFieldChangeEvent(payload, this.name)) {
|
||||
this.onValueChangeEmitter.fire({
|
||||
value: get(values, this.name),
|
||||
prevValue: get(prevValues, this.name),
|
||||
formValues: values,
|
||||
prevFormValues: prevValues,
|
||||
});
|
||||
if (
|
||||
shouldValidate(ValidateTrigger.onChange, this.form.validationTrigger) &&
|
||||
FieldEventUtils.shouldTriggerFieldValidateWhenChange(payload, this.name)
|
||||
) {
|
||||
this.validate();
|
||||
}
|
||||
}
|
||||
});
|
||||
this.toDispose.push(changeDisposable);
|
||||
|
||||
// if (shouldValidate(ValidateTrigger.onChange, this.form.validationTrigger)) {
|
||||
// const validateDisposable = this.form.onFormValuesChange(({ name, values, prevValues }) => {
|
||||
// /**
|
||||
// * Field 值变更时,所有 ancestor 以及所有child 和 grand child 的校验都要触发
|
||||
// */
|
||||
// if (Glob.isMatchOrParent(this.name, name) || Glob.isMatchOrParent(name, this.name)) {
|
||||
// this.validate();
|
||||
// }
|
||||
// });
|
||||
// this.toDispose.push(validateDisposable);
|
||||
// }
|
||||
|
||||
this.toDispose.push(this.onValueChangeEmitter);
|
||||
|
||||
this.initState();
|
||||
}
|
||||
|
||||
protected _mount: boolean = false;
|
||||
|
||||
get renderCount() {
|
||||
return this._renderCount;
|
||||
}
|
||||
|
||||
set renderCount(n: number) {
|
||||
this._renderCount = n;
|
||||
}
|
||||
|
||||
private initState() {
|
||||
const initialErrors = get(this.form.state.errors, this.name);
|
||||
const initialWarnings = get(this.form.state.warnings, this.name);
|
||||
|
||||
if (initialErrors) {
|
||||
this.state.errors = {
|
||||
[this.name]: initialErrors,
|
||||
};
|
||||
}
|
||||
if (initialWarnings) {
|
||||
this.state.warnings = {
|
||||
[this.name]: initialWarnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
get path() {
|
||||
return this._path;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this._path.toString();
|
||||
}
|
||||
|
||||
set name(name: FieldName) {
|
||||
this._path = new Path(name);
|
||||
}
|
||||
|
||||
get ref() {
|
||||
return this._ref;
|
||||
}
|
||||
|
||||
set ref(ref: Ref | undefined) {
|
||||
this._ref = ref;
|
||||
}
|
||||
|
||||
get state() {
|
||||
return this._state.value;
|
||||
}
|
||||
|
||||
get reactiveState() {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.form.getValueIn(this.name);
|
||||
}
|
||||
|
||||
set value(value: TValue | undefined) {
|
||||
this.form.setValueIn(this.name, value);
|
||||
if (!this.state.isTouched) {
|
||||
this.state.isTouched = true;
|
||||
this.bubbleState();
|
||||
}
|
||||
}
|
||||
|
||||
updateNameForLeafState(newName: string) {
|
||||
const { errors, warnings } = this.state;
|
||||
const nameInErrors = errors ? Object.keys(errors)?.[0] : undefined;
|
||||
if (nameInErrors && errors?.[nameInErrors] && nameInErrors !== newName) {
|
||||
this.state.errors = {
|
||||
[newName]: errors?.[nameInErrors]
|
||||
? updateFeedbacksName(errors?.[nameInErrors], newName)
|
||||
: errors?.[nameInErrors],
|
||||
};
|
||||
}
|
||||
const nameInWarnings = warnings ? Object.keys(warnings)?.[0] : undefined;
|
||||
if (nameInWarnings && warnings?.[nameInWarnings] && nameInWarnings !== newName) {
|
||||
this.state.warnings = {
|
||||
[newName]: warnings?.[nameInWarnings]
|
||||
? updateFeedbacksName(warnings?.[nameInWarnings], newName)
|
||||
: warnings?.[nameInWarnings],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// recursiveUpdateName(name: FieldName) {
|
||||
// if (this.children?.length) {
|
||||
// this.children.forEach(c => {
|
||||
// c.recursiveUpdateName(c.path.replaceParent(this.path, new Path(name)).toString());
|
||||
// });
|
||||
// } else {
|
||||
// this.updateNameForLeafState(name);
|
||||
// this.bubbleState();
|
||||
// }
|
||||
// this.name = name;
|
||||
// }
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @param validate
|
||||
* @param from
|
||||
*/
|
||||
updateValidate(validate: Validate | undefined, from?: 'ui') {
|
||||
if (from === 'ui') {
|
||||
// todo(heyuan):暂时逻辑: 只在没有全局配置校验时来自ui 的validate 才生效。 后续需要支持多validate合并, ui 和全局的都需要生效
|
||||
if (!this.originalValidate) {
|
||||
this.originalValidate = validate;
|
||||
}
|
||||
} else {
|
||||
this.originalValidate = validate;
|
||||
}
|
||||
}
|
||||
|
||||
bubbleState() {
|
||||
const { errors, warnings } = this.state;
|
||||
|
||||
if (this.parent) {
|
||||
this.parent.state.isTouched = some(
|
||||
this.parent.children.map((c) => c.state.isTouched),
|
||||
Boolean
|
||||
);
|
||||
this.parent.state.invalid = some(
|
||||
this.parent.children.map((c) => c.state.invalid),
|
||||
Boolean
|
||||
);
|
||||
this.parent.state.isDirty = some(
|
||||
this.parent.children.map((c) => c.state.isDirty),
|
||||
Boolean
|
||||
);
|
||||
this.parent.state.isValidating = some(
|
||||
this.parent.children.map((c) => c.state.isValidating),
|
||||
Boolean
|
||||
);
|
||||
this.parent.state.errors = errors
|
||||
? mergeFeedbacks<Errors>(this.parent.state.errors, errors)
|
||||
: clearFeedbacks(this.name, this.parent.state.errors);
|
||||
this.parent.state.warnings = warnings
|
||||
? mergeFeedbacks<Warnings>(this.parent.state.warnings, warnings)
|
||||
: clearFeedbacks(this.name, this.parent.state.warnings);
|
||||
|
||||
this.parent.bubbleState();
|
||||
return;
|
||||
}
|
||||
// parent 不存在,则更新form state
|
||||
this.form.state.isTouched = some(
|
||||
this.form.fields.map((f) => f.state.isTouched),
|
||||
Boolean
|
||||
);
|
||||
this.form.state.invalid = some(
|
||||
this.form.fields.map((f) => f.state.invalid),
|
||||
Boolean
|
||||
);
|
||||
this.form.state.isDirty = some(
|
||||
this.form.fields.map((f) => f.state.isDirty),
|
||||
Boolean
|
||||
);
|
||||
this.form.state.isValidating = some(
|
||||
this.form.fields.map((f) => f.state.isValidating),
|
||||
Boolean
|
||||
);
|
||||
this.form.state.errors = errors
|
||||
? mergeFeedbacks<Errors>(this.form.state.errors, errors)
|
||||
: clearFeedbacks(this.name, this.form.state.errors);
|
||||
this.form.state.warnings = warnings
|
||||
? mergeFeedbacks<Warnings>(this.form.state.warnings, warnings)
|
||||
: clearFeedbacks(this.name, this.form.state.warnings);
|
||||
// console.log('>>>> bubble state: ', this.form.state.errors, this.form.state.invalid, this.form.fields.map(f => f.state.invalid))
|
||||
}
|
||||
|
||||
clearState() {
|
||||
this.state.errors = DEFAULT_FIELD_STATE.errors;
|
||||
this.state.warnings = DEFAULT_FIELD_STATE.warnings;
|
||||
this.state.isTouched = DEFAULT_FIELD_STATE.isTouched;
|
||||
this.state.isDirty = DEFAULT_FIELD_STATE.isDirty;
|
||||
this.bubbleState();
|
||||
}
|
||||
|
||||
get children(): FieldModel[] {
|
||||
const res: FieldModel[] = [];
|
||||
this.form.fieldMap.forEach((field, path: string) => {
|
||||
if (this.path.isChild(path)) {
|
||||
res.push(field);
|
||||
}
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
get parent(): FieldModel | undefined {
|
||||
const parentPath = this.path.parent;
|
||||
if (!parentPath) {
|
||||
return undefined;
|
||||
}
|
||||
return this.form.fieldMap.get(parentPath.toString());
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (!this.value) {
|
||||
return;
|
||||
}
|
||||
this.value = undefined;
|
||||
}
|
||||
|
||||
async validate() {
|
||||
// 以下代码由于导致arr 配置的校验不触发,暂时注释,支持对父节点配置校验逻辑
|
||||
// const children = this.children;
|
||||
|
||||
// 如果是非叶子field, 执行children的校验。暂不支持在父级上配校验器
|
||||
// if (children?.length) {
|
||||
// await Promise.all(this.children.map(c => c.validate()));
|
||||
// return;
|
||||
// }
|
||||
await this.validateSelf();
|
||||
}
|
||||
|
||||
async validateSelf() {
|
||||
this.state.isValidating = true;
|
||||
this.bubbleState();
|
||||
const { errors, warnings } = await this._runAsyncValidate();
|
||||
|
||||
if (errors?.length) {
|
||||
this.state.errors = groupBy(errors, 'name');
|
||||
this.state.invalid = true;
|
||||
} else {
|
||||
this.state.errors = { [this.name]: [] };
|
||||
this.state.invalid = false;
|
||||
}
|
||||
|
||||
if (warnings?.length) {
|
||||
this.state.warnings = groupBy(warnings, 'name');
|
||||
} else {
|
||||
this.state.warnings = { [this.name]: [] };
|
||||
}
|
||||
|
||||
this.state.isValidating = false;
|
||||
this.bubbleState();
|
||||
this.form.onValidateEmitter.fire(this.form.state);
|
||||
}
|
||||
|
||||
protected async _runAsyncValidate(): Promise<{
|
||||
errors?: FieldError[];
|
||||
warnings?: FieldWarning[];
|
||||
}> {
|
||||
let errors: FieldError[] = [];
|
||||
let warnings: FieldWarning[] = [];
|
||||
|
||||
const results = await this.form.validateIn(this.name);
|
||||
if (!results?.length) {
|
||||
return {};
|
||||
} else {
|
||||
const feedbacks = results.map((result) => toFeedback(result, this.name)).filter(Boolean) as (
|
||||
| FieldError
|
||||
| FieldWarning
|
||||
)[];
|
||||
|
||||
if (!feedbacks?.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const groupedFeedbacks = groupBy(feedbacks, 'level');
|
||||
|
||||
warnings = warnings.concat((groupedFeedbacks[FeedbackLevel.Warning] as FieldWarning[]) || []);
|
||||
errors = errors.concat((groupedFeedbacks[FeedbackLevel.Error] as FieldError[]) || []);
|
||||
}
|
||||
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
updateState(s: Partial<FieldModel>) {
|
||||
// todo
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.children.map((c) => c.dispose());
|
||||
// Do not reset state when field disposed, since it will clear errors and warnings in form model as well.
|
||||
// todo: remove following line and related ut after a few weeks test online
|
||||
// this.clearState();
|
||||
this.toDispose.dispose();
|
||||
this.form.fieldMap.delete(this.path.toString());
|
||||
}
|
||||
|
||||
onDispose(fn: () => void) {
|
||||
this.toDispose.onDispose(fn);
|
||||
}
|
||||
|
||||
get disposed() {
|
||||
return this.toDispose.disposed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { flatten, get } from 'lodash-es';
|
||||
import { deepEqual } from 'fast-equals';
|
||||
import { Disposable, Emitter } from '@flowgram.ai/utils';
|
||||
import { ReactiveState } from '@flowgram.ai/reactive';
|
||||
|
||||
import { feedbackToFieldErrorsOrWarnings, hasError, toFeedback } from '../utils/validate';
|
||||
import { Glob } from '../utils/glob';
|
||||
import { keepValidKeys } from '../utils';
|
||||
import {
|
||||
FormModelState,
|
||||
FormOptions,
|
||||
FormState,
|
||||
OnFormValuesChangePayload,
|
||||
OnFormValuesInitPayload,
|
||||
OnFormValuesUpdatedPayload,
|
||||
} from '../types/form';
|
||||
import { FieldName, FieldValue } from '../types/field';
|
||||
import { Errors, FeedbackLevel, FormValidateReturn, Validate, Warnings } from '../types';
|
||||
import { createFormModelState } from '../constants';
|
||||
import { getValidByErrors, mergeFeedbacks } from './utils';
|
||||
import { Store } from './store';
|
||||
import { Path } from './path';
|
||||
import { FieldModel } from './field-model';
|
||||
import { FieldArrayModel } from './field-array-model';
|
||||
|
||||
export class FormModel<TValues = any> implements Disposable {
|
||||
protected _fieldMap: Map<string, FieldModel> = new Map();
|
||||
|
||||
readonly store = new Store();
|
||||
|
||||
protected _options: FormOptions = {};
|
||||
|
||||
protected onFieldModelCreateEmitter = new Emitter<FieldModel>();
|
||||
|
||||
readonly onFieldModelCreate = this.onFieldModelCreateEmitter.event;
|
||||
|
||||
readonly onFormValuesChangeEmitter = new Emitter<OnFormValuesChangePayload>();
|
||||
|
||||
readonly onFormValuesChange = this.onFormValuesChangeEmitter.event;
|
||||
|
||||
readonly onFormValuesInitEmitter = new Emitter<OnFormValuesInitPayload>();
|
||||
|
||||
readonly onFormValuesInit = this.onFormValuesInitEmitter.event;
|
||||
|
||||
readonly onFormValuesUpdatedEmitter = new Emitter<OnFormValuesUpdatedPayload>();
|
||||
|
||||
readonly onFormValuesUpdated = this.onFormValuesUpdatedEmitter.event;
|
||||
|
||||
readonly onValidateEmitter = new Emitter<FormModelState>();
|
||||
|
||||
readonly onValidate = this.onValidateEmitter.event;
|
||||
|
||||
protected _state: ReactiveState<FormModelState> = new ReactiveState<FormModelState>(
|
||||
createFormModelState()
|
||||
);
|
||||
|
||||
protected _initialized = false;
|
||||
|
||||
set fieldMap(map) {
|
||||
this._fieldMap = map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单初始值,初始化设置后不可修改
|
||||
* @protected
|
||||
*/
|
||||
// protected _initialValues?: TValues;
|
||||
|
||||
get fieldMap() {
|
||||
return this._fieldMap;
|
||||
}
|
||||
|
||||
get context() {
|
||||
return this._options.context;
|
||||
}
|
||||
|
||||
get initialValues() {
|
||||
return this._options.initialValues;
|
||||
}
|
||||
|
||||
get values() {
|
||||
return this.store.values;
|
||||
}
|
||||
|
||||
set values(v) {
|
||||
const prevValues = this.values;
|
||||
if (deepEqual(prevValues, v)) {
|
||||
return;
|
||||
}
|
||||
this.store.values = v;
|
||||
this.fireOnFormValuesChange({
|
||||
values: this.values,
|
||||
prevValues,
|
||||
name: '',
|
||||
});
|
||||
}
|
||||
|
||||
get validationTrigger() {
|
||||
return this._options.validateTrigger;
|
||||
}
|
||||
|
||||
get state() {
|
||||
return this._state.value;
|
||||
}
|
||||
|
||||
get reactiveState() {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
get fields(): FieldModel[] {
|
||||
return Array.from(this.fieldMap.values());
|
||||
}
|
||||
|
||||
updateState(state: Partial<FormState>) {
|
||||
// todo
|
||||
}
|
||||
|
||||
get initialized() {
|
||||
return this._initialized;
|
||||
}
|
||||
|
||||
fireOnFormValuesChange(payload: OnFormValuesChangePayload) {
|
||||
this.onFormValuesChangeEmitter.fire(payload);
|
||||
this.onFormValuesUpdatedEmitter.fire(payload);
|
||||
}
|
||||
|
||||
fireOnFormValuesInit(payload: OnFormValuesInitPayload) {
|
||||
this.onFormValuesInitEmitter.fire(payload);
|
||||
this.onFormValuesUpdatedEmitter.fire(payload);
|
||||
}
|
||||
|
||||
init(options: FormOptions<TValues>) {
|
||||
this._options = options;
|
||||
if (options.initialValues) {
|
||||
const prevValues = this.store.values;
|
||||
this.store.values = options.initialValues;
|
||||
this.fireOnFormValuesInit({
|
||||
values: options.initialValues,
|
||||
prevValues,
|
||||
name: '',
|
||||
});
|
||||
}
|
||||
this._initialized = true;
|
||||
}
|
||||
|
||||
createField<TValue = FieldValue>(name: FieldName, isArray?: boolean): FieldModel<TValue> {
|
||||
const path = new Path(name);
|
||||
const pathString = path.toString();
|
||||
|
||||
if (this.fieldMap.get(pathString)) {
|
||||
return this.fieldMap.get(pathString)!;
|
||||
}
|
||||
|
||||
// const fieldValue = value || get(this.initialValues, pathString);
|
||||
|
||||
const field: FieldModel = isArray
|
||||
? new FieldArrayModel(path, this)
|
||||
: new FieldModel(path, this);
|
||||
|
||||
this.fieldMap.set(pathString, field);
|
||||
field.onDispose(() => {
|
||||
this.fieldMap.delete(pathString);
|
||||
});
|
||||
this.onFieldModelCreateEmitter.fire(field);
|
||||
|
||||
return field;
|
||||
}
|
||||
|
||||
createFieldArray<TValue = FieldValue>(
|
||||
name: FieldName,
|
||||
value?: Array<TValue>
|
||||
): FieldArrayModel<TValue> {
|
||||
return this.createField<Array<TValue>>(name, true) as FieldArrayModel<TValue>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁Field 模型和子模型,但不会删除field的值
|
||||
* @param name
|
||||
*/
|
||||
disposeField(name: string) {
|
||||
const field = this.fieldMap.get(name);
|
||||
if (field) {
|
||||
field.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除field, 会删除值和 Field 模型, 以及对应的子模型
|
||||
* @param name
|
||||
*/
|
||||
deleteField(name: string) {
|
||||
const field = this.fieldMap.get(name);
|
||||
if (field) {
|
||||
// 销毁值
|
||||
field.clear();
|
||||
// 销毁模型
|
||||
field.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
getField<TFieldModel extends FieldModel | FieldArrayModel = FieldModel>(
|
||||
name: FieldName
|
||||
): TFieldModel | undefined {
|
||||
return this.fieldMap.get(new Path(name).toString()) as TFieldModel | undefined;
|
||||
}
|
||||
|
||||
getValueIn<TValue>(name: FieldName): TValue {
|
||||
return this.store.getIn<TValue>(new Path(name));
|
||||
}
|
||||
|
||||
setValueIn<TValue>(name: FieldName, value: TValue): void {
|
||||
const prevValues = this.values;
|
||||
|
||||
this.store.setIn(new Path(name), value);
|
||||
|
||||
this.fireOnFormValuesChange({
|
||||
values: this.values,
|
||||
prevValues,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
setInitValueIn<TValue = any>(name: FieldName, value: TValue): void {
|
||||
const path = new Path(name);
|
||||
const prevValue = this.store.getIn(path);
|
||||
if (prevValue === undefined) {
|
||||
const prevValues = this.values;
|
||||
this.store.setIn(new Path(name), value);
|
||||
this.fireOnFormValuesInit({
|
||||
values: this.values,
|
||||
prevValues,
|
||||
name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
validateDisabled = false;
|
||||
|
||||
clearValueIn(name: FieldName) {
|
||||
this.setValueIn(name, undefined);
|
||||
}
|
||||
|
||||
async validateIn(name: FieldName) {
|
||||
if (this.validateDisabled) return [];
|
||||
const validateOptions = this.getValidateOptions();
|
||||
if (!validateOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validateKeys = Object.keys(validateOptions).filter((pattern) =>
|
||||
Glob.isMatch(pattern, name)
|
||||
);
|
||||
|
||||
const validatePromises = validateKeys.map(async (validateKey) => {
|
||||
const validate = validateOptions![validateKey];
|
||||
|
||||
return validate({
|
||||
value: this.getValueIn(name),
|
||||
formValues: this.values,
|
||||
context: this.context,
|
||||
name,
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.all(validatePromises);
|
||||
}
|
||||
|
||||
protected getValidateOptions(): Record<string, Validate> | undefined {
|
||||
const validate = this._options.validate;
|
||||
if (typeof validate === 'function') {
|
||||
return validate(this.values, this.context);
|
||||
}
|
||||
return validate;
|
||||
}
|
||||
|
||||
async validate(): Promise<FormValidateReturn> {
|
||||
if (this.validateDisabled) return [];
|
||||
const validateOptions = this.getValidateOptions();
|
||||
if (!validateOptions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const feedbacksArrPromises = Object.keys(validateOptions).map(async (nameRule) => {
|
||||
const validate = validateOptions![nameRule];
|
||||
const values = this.values;
|
||||
const paths = Glob.findMatchPathsWithEmptyValue(values, nameRule);
|
||||
return Promise.all(
|
||||
paths.map(async (path) => {
|
||||
const result = await validate({
|
||||
value: get(values, path),
|
||||
formValues: values,
|
||||
context: this.context,
|
||||
name: path,
|
||||
});
|
||||
|
||||
const feedback = toFeedback(result, path);
|
||||
const field = this.getField(path);
|
||||
|
||||
const errors = feedbackToFieldErrorsOrWarnings<Errors>(
|
||||
path,
|
||||
feedback?.level === FeedbackLevel.Error ? feedback : undefined
|
||||
);
|
||||
const warnings = feedbackToFieldErrorsOrWarnings<Warnings>(
|
||||
path,
|
||||
feedback?.level === FeedbackLevel.Warning ? feedback : undefined
|
||||
);
|
||||
|
||||
if (field) {
|
||||
field.state.errors = errors;
|
||||
field.state.warnings = warnings;
|
||||
field.state.invalid = hasError(errors);
|
||||
field.bubbleState();
|
||||
}
|
||||
|
||||
// 无论是否存在 field 都要保证 form 的state 被更新
|
||||
this.state.errors = mergeFeedbacks(this.state.errors, errors);
|
||||
this.state.warnings = mergeFeedbacks(this.state.warnings, warnings);
|
||||
|
||||
this.state.invalid = !getValidByErrors(this.state.errors);
|
||||
return feedback;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
this.state.isValidating = true;
|
||||
const feedbacksArr = await Promise.all(feedbacksArrPromises);
|
||||
this.state.isValidating = false;
|
||||
this.onValidateEmitter.fire(this.state);
|
||||
|
||||
return flatten(feedbacksArr).filter(Boolean) as FormValidateReturn;
|
||||
}
|
||||
|
||||
alignStateWithFieldMap() {
|
||||
const keys = Array.from(this.fieldMap.keys());
|
||||
|
||||
if (this.state.errors) {
|
||||
this.state.errors = keepValidKeys(this.state.errors, keys);
|
||||
}
|
||||
if (this.state.warnings) {
|
||||
this.state.warnings = keepValidKeys(this.state.warnings, keys);
|
||||
}
|
||||
this.fieldMap.forEach((f) => {
|
||||
if (f.state.errors) {
|
||||
f.state.errors = keepValidKeys(f.state.errors, keys);
|
||||
}
|
||||
if (f.state.warnings) {
|
||||
f.state.warnings = keepValidKeys(f.state.warnings, keys);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.fieldMap.forEach((f) => f.dispose());
|
||||
this.store.dispose();
|
||||
this._initialized = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { FormModel } from './form-model';
|
||||
export { createForm, type CreateFormOptions } from './create-form';
|
||||
export { FieldModel } from './field-model';
|
||||
export { FieldArrayModel } from './field-array-model';
|
||||
|
||||
export { toField, toFieldState } from './to-field';
|
||||
export { toFieldArray } from './to-field-array';
|
||||
export { toForm, toFormState } from './to-form';
|
||||
export { Path } from './path';
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { toPath } from 'lodash-es';
|
||||
|
||||
export class Path {
|
||||
protected _path: string[] = [];
|
||||
|
||||
constructor(path: string | string[]) {
|
||||
this._path = toPath(path);
|
||||
}
|
||||
|
||||
get parent(): Path | undefined {
|
||||
if (this._path.length < 2) {
|
||||
return undefined;
|
||||
}
|
||||
return new Path(this._path.slice(0, -1));
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this._path.join('.');
|
||||
}
|
||||
|
||||
get value(): string[] {
|
||||
return this._path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅计直系child
|
||||
* @param path
|
||||
*/
|
||||
isChild(path: string) {
|
||||
const target = new Path(path).value;
|
||||
const self = this.value;
|
||||
|
||||
if (target.length - self.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < self.length; i++) {
|
||||
if (target[i] !== self[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个数组path大小
|
||||
* 返回小于0则path1<path2, 大于0 则path1>path2, 等于0则相等
|
||||
* @param path1
|
||||
* @param path2
|
||||
*/
|
||||
static compareArrayPath(path1: Path, path2: Path): number | void {
|
||||
let i = 0;
|
||||
while (path1.value[i] && path2.value[i]) {
|
||||
const index1 = parseInt(path1.value[i]);
|
||||
const index2 = parseInt(path2.value[i]);
|
||||
|
||||
if (!isNaN(index1) && !isNaN(index2)) {
|
||||
return index1 - index2;
|
||||
} else if (path1.value[i] !== path2.value[i]) {
|
||||
throw new Error(
|
||||
`[Form] Path.compareArrayPath invalid input Error: two path should refers to the same array, but got path1: ${path1.toString()}, path2: ${path2.toString()}`
|
||||
);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
throw new Error(
|
||||
`[Form] Path.compareArrayPath invalid input Error: got path1: ${path1.toString()}, path2: ${path2.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
isChildOrGrandChild(path: string) {
|
||||
const target = new Path(path).value;
|
||||
const self = this.value;
|
||||
|
||||
if (target.length - self.length < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < self.length; i++) {
|
||||
if (target[i] !== self[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
getArrayIndex(parent: Path) {
|
||||
return parseInt(this._path[parent.value.length]);
|
||||
}
|
||||
|
||||
concat(name: number | string) {
|
||||
if (typeof name === 'string' || typeof name === 'number') {
|
||||
return new Path(this._path.concat(new Path(name.toString())._path));
|
||||
}
|
||||
throw new Error(
|
||||
`[Form] Error in Path.concat: invalid param type, require number or string, but got ${typeof name}`
|
||||
);
|
||||
}
|
||||
|
||||
replaceParent(parent: Path, newParent: Path) {
|
||||
if (parent.value.length > this.value.length) {
|
||||
throw new Error(
|
||||
`[Form] Error in Path.replaceParent: invalid parent param: ${parent}, parent length should not greater than current length.`
|
||||
);
|
||||
}
|
||||
const rest = [];
|
||||
for (let i = 0; i < this.value.length; i++) {
|
||||
if (i < parent.value.length && parent.value[i] !== this.value[i]) {
|
||||
throw new Error(
|
||||
`[Form] Error in Path.replaceParent: invalid parent param: '${parent}' is not a parent of '${this.toString()}'`
|
||||
);
|
||||
}
|
||||
if (i >= parent.value.length) {
|
||||
rest.push(this.value[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return new Path(newParent.value.concat(rest));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { get, clone, cloneDeep } from 'lodash-es';
|
||||
|
||||
import { shallowSetIn } from '../utils';
|
||||
import { FieldValue } from '../types/field';
|
||||
import { Path } from './path';
|
||||
|
||||
export class Store<TValues = FieldValue> {
|
||||
protected _values: TValues;
|
||||
|
||||
get values(): TValues {
|
||||
return clone(this._values);
|
||||
}
|
||||
|
||||
set values(v) {
|
||||
this._values = cloneDeep(v);
|
||||
}
|
||||
|
||||
setIn<TValue = FieldValue>(path: Path, value: TValue): void {
|
||||
// shallow clone set
|
||||
this._values = shallowSetIn(this._values || {}, path.toString(), value);
|
||||
}
|
||||
|
||||
getIn<TValue = FieldValue>(path: Path): TValue {
|
||||
return get(this.values, path.value);
|
||||
}
|
||||
|
||||
dispose() {}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user