fix: fix FieldArrayModel.swap state issue with doc example covered (#70)

* feat: add get values to FormModel

* fix: fix FieldArrayModel.swap state issue with doc example added

* doc: fix array demo code
This commit is contained in:
YuanHeDx 2025-03-20 15:59:55 +08:00 committed by GitHub
parent 3dc0b94dd8
commit 6dd4e2d3ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 511 additions and 19 deletions

View File

@ -4,7 +4,7 @@ import './field-wrapper.css';
interface FieldWrapperProps {
required?: boolean;
title: string;
title?: string;
children?: React.ReactNode;
error?: string;
note?: string;

View File

@ -4,7 +4,7 @@ import './field-wrapper.css';
interface FieldWrapperProps {
required?: boolean;
title: string;
title?: string;
children?: React.ReactNode;
error?: string;
note?: string;

View File

@ -0,0 +1,7 @@
.array-item-wrapper {
display: flex;
align-items: center;
}
.icon-button-popover {
padding: 6px 8px;
}

View File

@ -0,0 +1,116 @@
import {
DataEvent,
EffectFuncProps,
Field,
FieldRenderProps,
FormMeta,
ValidateTrigger,
WorkflowNodeRegistry,
FieldArray,
FieldArrayRenderProps,
} from '@flowgram.ai/free-layout-editor';
import { FieldWrapper } from '@flowgram.ai/demo-node-form';
import { Input, Button, Popover } from '@douyinfe/semi-ui';
import { IconPlus, IconCrossCircleStroked, IconArrowDown } from '@douyinfe/semi-icons';
import './index.css';
import '../index.css';
export const render = () => (
<div className="demo-node-content">
<div className="demo-node-title">Array Examples</div>
<FieldArray name="array">
{({ field, fieldState }: FieldArrayRenderProps<string>) => (
<FieldWrapper title={'My Array'}>
{field.map((child, index) => (
<Field name={child.name} key={child.key}>
{({ field: childField, fieldState: childState }: FieldRenderProps<string>) => (
<FieldWrapper error={childState.errors?.[0]?.message}>
<div className="array-item-wrapper">
<Input {...childField} size={'small'} />
{index < field.value!.length - 1 ? (
<Popover
content={'swap with next element'}
className={'icon-button-popover'}
showArrow
position={'topLeft'}
>
<Button
theme="borderless"
size={'small'}
icon={<IconArrowDown />}
onClick={() => field.swap(index, index + 1)}
/>
</Popover>
) : null}
<Popover
content={'delete current element'}
className={'icon-button-popover'}
showArrow
position={'topLeft'}
>
<Button
theme="borderless"
size={'small'}
icon={<IconCrossCircleStroked />}
onClick={() => field.delete(index)}
/>
</Popover>
</div>
</FieldWrapper>
)}
</Field>
))}
<div>
<Button
size={'small'}
theme="borderless"
icon={<IconPlus />}
onClick={() => field.append('default')}
>
Add
</Button>
</div>
</FieldWrapper>
)}
</FieldArray>
</div>
);
interface FormData {
array: string[];
}
const formMeta: FormMeta<FormData> = {
render,
validateTrigger: ValidateTrigger.onChange,
defaultValues: {
array: ['default'],
},
validate: {
'array.*': ({ value }) =>
value.length > 8 ? 'max length exceeded: current length is ' + value.length : undefined,
},
effect: {
'array.*': [
{
event: DataEvent.onValueInit,
effect: ({ value, name }: EffectFuncProps<string, FormData>) => {
console.log(name + ' value init to ', value);
},
},
{
event: DataEvent.onValueChange,
effect: ({ value, name }: EffectFuncProps<string, FormData>) => {
console.log(name + ' value changed to ', value);
},
},
],
},
};
export const nodeRegistry: WorkflowNodeRegistry = {
type: 'custom',
meta: {},
defaultPorts: [{ type: 'output' }, { type: 'input' }],
formMeta,
};

View File

@ -0,0 +1,146 @@
import {
DEFAULT_INITIAL_DATA,
defaultInitialDataTs,
fieldWrapperCss,
fieldWrapperTs,
} from '@flowgram.ai/demo-node-form';
import { Editor } from '../editor.tsx';
import { PreviewEditor } from '../../preview-editor.tsx';
import { nodeRegistry } from './node-registry.tsx';
const nodeRegistryFile = {
code: `import {
DataEvent,
EffectFuncProps,
Field,
FieldRenderProps,
FormMeta,
ValidateTrigger,
WorkflowNodeRegistry,
FieldArray,
FieldArrayRenderProps,
} from '@flowgram.ai/free-layout-editor';
import { FieldWrapper } from '@flowgram.ai/demo-node-form';
import { Input, Button, Popover } from '@douyinfe/semi-ui';
import { IconPlus, IconCrossCircleStroked, IconArrowDown } from '@douyinfe/semi-icons';
import './index.css';
import '../index.css';
export const render = () => (
<div className="demo-node-content">
<div className="demo-node-title">Array Examples</div>
<FieldArray name="array">
{({ field, fieldState }: FieldArrayRenderProps<string>) => (
<FieldWrapper title={'My Array'}>
{field.map((child, index) => (
<Field name={child.name} key={child.key}>
{({ field: childField, fieldState: childState }: FieldRenderProps<string>) => (
<FieldWrapper error={childState.errors?.[0]?.message}>
<div className="array-item-wrapper">
<Input {...childField} size={'small'} />
{index < field.value!.length - 1 ? (
<Popover
content={'swap with next element'}
className={'icon-button-popover'}
showArrow
position={'topLeft'}
>
<Button
theme="borderless"
size={'small'}
icon={<IconArrowDown />}
onClick={() => field.swap(index, index + 1)}
/>
</Popover>
) : null}
<Popover
content={'delete current element'}
className={'icon-button-popover'}
showArrow
position={'topLeft'}
>
<Button
theme="borderless"
size={'small'}
icon={<IconCrossCircleStroked />}
onClick={() => field.delete(index)}
/>
</Popover>
</div>
</FieldWrapper>
)}
</Field>
))}
<div>
<Button
size={'small'}
theme="borderless"
icon={<IconPlus />}
onClick={() => field.append('default')}
>
Add
</Button>
</div>
</FieldWrapper>
)}
</FieldArray>
</div>
);
interface FormData {
array: string[];
}
const formMeta: FormMeta<FormData> = {
render,
validateTrigger: ValidateTrigger.onChange,
defaultValues: {
array: ['default'],
},
validate: {
'array.*': ({ value }) =>
value.length > 8 ? 'max length exceeded: current length is ' + value.length : undefined,
},
effect: {
'array.*': [
{
event: DataEvent.onValueInit,
effect: ({ value, name }: EffectFuncProps<string, FormData>) => {
console.log(name + ' value init to ', value);
},
},
{
event: DataEvent.onValueChange,
effect: ({ value, name }: EffectFuncProps<string, FormData>) => {
console.log(name + ' value changed to ', value);
},
},
],
},
};
export const nodeRegistry: WorkflowNodeRegistry = {
type: 'custom',
meta: {},
defaultPorts: [{ type: 'output' }, { type: 'input' }],
formMeta,
};
`,
active: true,
};
export const NodeFormArrayPreview = () => {
const files = {
'node-registry.tsx': nodeRegistryFile,
'initial-data.ts': { code: defaultInitialDataTs, active: true },
'field-wrapper.tsx': { code: fieldWrapperTs, active: true },
'field-wrapper.css': { code: fieldWrapperCss, active: true },
};
return (
<PreviewEditor files={files} previewStyle={{ height: 500 }} editorStyle={{ height: 500 }}>
<Editor registry={nodeRegistry} initialData={DEFAULT_INITIAL_DATA} />
</PreviewEditor>
);
};

View File

@ -1,4 +1,5 @@
[
"basic",
"effect"
"effect",
"array"
]

View File

@ -0,0 +1,10 @@
---
outline: false
---
# 数组
import { NodeFormArrayPreview } from '../../../../components/node-form/array/preview';
<NodeFormArrayPreview />

View File

@ -4,6 +4,8 @@ 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', () => {
@ -580,53 +582,171 @@ describe('FormArrayModel', () => {
it('can swap from 0 to middle index', () => {
const arrayField = formModel.createFieldArray('arr');
arrayField!.append('a');
arrayField!.append('b');
arrayField!.append('c');
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 swap from 0 to last index', () => {
const arrayField = formModel.createFieldArray('arr');
arrayField!.append('a');
arrayField!.append('b');
arrayField!.append('c');
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');
arrayField!.append('a');
arrayField!.append('b');
arrayField!.append('c');
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');
arrayField!.append('b');
arrayField!.append('c');
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', () => {

View File

@ -204,6 +204,7 @@ export class FieldArrayModel<TValue = FieldValue> extends FieldModel<Array<TValu
);
}
const oldFormValues = this.form.values;
const tempValue = [...this.value];
const fromValue = tempValue[from];
@ -212,7 +213,46 @@ export class FieldArrayModel<TValue = FieldValue> extends FieldModel<Array<TValu
tempValue[to] = fromValue;
tempValue[from] = toValue;
this.form.setValueIn(this.name, tempValue);
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) {
@ -236,5 +276,53 @@ export class FieldArrayModel<TValue = FieldValue> extends FieldModel<Array<TValu
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;
}
}

View File

@ -5,7 +5,7 @@ import { Errors, Feedback, OnFormValuesChangePayload, ValidateTrigger, Warnings
import { Path } from './path';
export function updateFeedbacksName(feedbacks: Feedback<any>[], name: string) {
return feedbacks.map((f) => ({
return (feedbacks || []).map((f) => ({
...f,
name,
}));
@ -91,7 +91,7 @@ export namespace FieldEventUtils {
) {
const { name: changedName, options } = payload;
if (options?.action === 'array-splice') {
if (options?.action === 'array-splice' || options?.action === 'array-swap') {
// const splicedIndexes = options?.indexes || [];
//
// const splicedPaths = splicedIndexes.map(index => new Path(changedName).concat(index));
@ -109,7 +109,7 @@ export namespace FieldEventUtils {
// return false;
// }
// splice 情况下仅触发数组field的校验
// splice 和 swap 都属于数组跟级别的变更仅需触发数组field的校验, 无需校验子项
return fieldName === changedName;
}

View File

@ -108,7 +108,7 @@ export interface CreateFormReturn<TValues> {
}
export interface OnFormValuesChangeOptions {
action?: 'array-append' | 'array-splice';
action?: 'array-append' | 'array-splice' | 'array-swap';
indexes?: number[];
}

View File

@ -135,6 +135,10 @@ export class FormModelV2 extends FormModel implements Disposable {
return this.node.getNodeRegistry().formMeta;
}
get values() {
return this.nativeFormModel?.values;
}
protected _feedbacks: FormFeedback[] = [];
get feedbacks(): FormFeedback[] {