Kaynağa Gözat

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
YuanHeDx 9 ay önce
ebeveyn
işleme
6dd4e2d3ab

+ 1 - 1
apps/demo-node-form/src/components/field-wrapper.tsx

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

+ 1 - 1
apps/demo-node-form/src/constant.ts

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

+ 7 - 0
apps/docs/components/node-form/array/index.css

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

+ 116 - 0
apps/docs/components/node-form/array/node-registry.tsx

@@ -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,
+};

+ 146 - 0
apps/docs/components/node-form/array/preview.tsx

@@ -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>
+  );
+};

+ 2 - 1
apps/docs/src/zh/examples/node-form/_meta.json

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

+ 10 - 0
apps/docs/src/zh/examples/node-form/array.mdx

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

+ 131 - 11
packages/node-engine/form/__tests__/field-array-model.test.ts

@@ -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', () => {

+ 89 - 1
packages/node-engine/form/src/core/field-array-model.ts

@@ -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;
   }
 }

+ 3 - 3
packages/node-engine/form/src/core/utils.ts

@@ -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;
     }
 

+ 1 - 1
packages/node-engine/form/src/types/form.ts

@@ -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[];
 }
 

+ 4 - 0
packages/node-engine/node/src/form-model-v2.ts

@@ -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[] {