Recently our productsDMS(Data Management Services)In, a new feature has been added – conditional branches can be added in task choreography. As the name implies, a conditional expression is required in a conditional branch to determine whether to continue with the following branch. Here, the optional condition items are specified limited, if it is only a simple input box, the user is prone to error when using, and it is not easy to make an error prompt. So, we designed the following component to satisfy all possible conditional expressions. As shown in figure:

The conditional groups in the diagram are parentheses, so let’s say we want to write oneKey1 > 0 && (Key2 < 20 || Key3 > 10)Such an expression, then, can be expressed as follows:Let’s follow the author to implement such an interesting component.

preparation

Technical Stack Description

The React component is used, and the UI library is used by Ali Group Fusion Next. You can use any UI library you like, or you can use no UI at all. In addition, immer is used for immutable processing.

So the dependencies in package.json are as follows:

{
	"dependencies": {
    "@alifd/next": "^ 1.23.10"."immer": "^ 9.0.3"."react": "^ 16.4.1"."react-dom": "^ 16.4.1"}}Copy the code

Defining data structures

React renders based on data, and every interface change is caused by data changes. Therefore, defining an appropriate data structure for components is critical. According to the DEMO image, it is not difficult to think of the following data structure:

// For ease of understanding, TypeScript definitions are used here, but TypeScript is not used in component source code
enum Logics = {
  and = 'and',
  or = 'or'};interface Item { [key: string] :any }

// Complete data structure
interface Relation {
	ops: Logics;
  children: Array<Item & Relation>;
}


/ / sample:
const value: Relation = {
	ops: 'and'.// Optional values: and, or
  children: [{key: 'Key1'.op: '>'.value: 0}, {ops: 'or'.children: [{key: 'Key2'.op: '<'.value: 20}, {key: 'Key3'.op: '>'.value: 10,},],},}Copy the code

Careful readers will find that the data definition of Item is open, and there is no specific Key in the fixed structure. That’s because the exact data structure varies depending on the needs of the business. Used for illustration only in this article, there are only conditions for a few simple items, a selection of relations, and the value is just an input box. In real business, things are more complicated. Conditions are usually specific items that are retrieved from the server and may be related or mutually exclusive. The relations and values may vary depending on the selected condition, and most commonly, the selection condition item may have a specific data type. For example, there are numbers, strings, enumerated values, etc., in the case of numbers, the relationship will be greater than, less than, equal to, etc. In the case of strings, the relations may be equal, include, belong, etc.; When enumerations are encountered, the value may be a selection box, and the selection value may be obtained by the specific interface. These are complex situations that are difficult to list in a component.

Writing process

Defining component structure

According to the above data structure, it can be easily obtained that this is a recursive nested structure, which can be divided into Item and Group(Relation). Therefore, the corresponding component can also be divided into Item and Group children. This component is named RelationTree and the two sub-components are named RelationGroup and RelationItem respectively.

RelationGroup corresponds to a condition group. The whole component is a condition group. RelationItem is the relationship item within each group. Considering the complexity of the business (described in detail at the end of the previous section), rendering of the conditions, relations, and value parts is passed in as functions. The overall structure is shown as follows:With that in mind, we can start writing the framework of the entire component in code.

RelationTree.jsx

import React, { useState } from 'react';
import RelationGroup, { defaultOpsValue } from './RelationGroup';

import './RelationTree.less';

const defaultRelation = {
  ops: defaultOpsValue,
  children: [] {}};function RelationTree({ value, setElementTerm }) {
  const [relations, setRelations] = useState(defaultRelation);

  return (
    <div className="vui-relation-tree">{/* a complete value is RelationGroup */}<RelationGroup// Haven't started rendering any controls yet, soposIs emptypos=""
        data={relations}
        setElementTerm={setElementTerm}
      />
    </div>
  );
}

export default RelationTree;

Copy the code

RelationGroup.jsx

import React from 'react';
import { Select, Button } from '@alifd/next';
import RelationItem from './RelationItem';

const { Option } = Select;
export const posSeparator = '_';
export const defaultOpsValue = 'and';

function RelationGroup({ data, pos, setElementTerm }) {
  const { children, ops } = data;
  const relationValue = ops || defaultOpsValue;

  return (
    <div className="vui-relation-group">
      <div className="relational">
        <Select className="relation-sign" value={relationValue}>
          <Option value="and">and</Option>
          <Option value="or">or</Option>
        </Select>
      </div>
      <div className="conditions">{ children.map((record, i) => { const { children: list } = record; const newPos = getNewPos(pos, i); return list && list.length ? (// The item containing children is rendered with RelationGroup<RelationGroup
              pos={newPos}
              key={newPos}
              data={record}
              setElementTerm={setElementTerm}
            />// Render with RelationItem without children<RelationItem
              pos={newPos}
              key={newPos}
              data={record}
              setElementTerm={setElementTerm}
            />); })}<div className="operators">
          <Button type="normal" className="add-term">Add conditions</Button>
          <Button type="normal" className="add-group">And conditions set</Button>
        </div>
      </div>
    </div>
  );
}

const getNewPos = (pos, i) = > {
  // If the current item is the entire value (that is, the start item of the component), the new position is the current sequence number
  return pos ? `${pos}${posSeparator}${i}` : String(i);
};

export default RelationGroup;


Copy the code

RelationItem.jsx

import React from 'react';
import { Button } from '@alifd/next';

function RelationItem({ data, pos, setElementTerm }) {
  if (typeofsetElementTerm ! = ='function') {
    console.error('setElementTerm property must be set and must be a Function that returns ReactElement ');
    return null;
  }

  return (
    <div className="vui-relation-item">
      { setElementTerm(data, pos) }
      <Button type="normal" className="delete-term">delete</Button>
    </div>
  );
}

export default RelationItem;


Copy the code

Component calls

With the previous component framework, you can add component calls to facilitate CSS and logical debugging.

import ReactDOM from 'react-dom';
import { Select, Input } from '@alifd/next';
import RelationTree from './RelationTree/RelationTree';

import '@alifd/next/index.css';
import './index.less';

const { Option } = Select;

// Term containing specific business logic
const RelationTerm = ({ data }) = > {
  const { key, op, value } = data;
  return (
    <div className="term">
      <span className="element">
        <Select placeholder="Please select conditions" value={key}>
          <Option value="Key1">Key1</Option>
          <Option value="Key2">Key2</Option>
          <Option value="Key3">Key3</Option>
        </Select>
      </span>
      <span className="comparison">
        <Select placeholder="Please select a relation" value={op}>
          <Option value="= =">Is equal to the</Option>
          <Option value=! "" =">Is not equal to</Option>
          <Option value=">">Is greater than</Option>
          <Option value="> =">Greater than or equal to</Option>
          <Option value="<">Less than</Option>
          <Option value="< =">Less than or equal to</Option>
        </Select>
      </span>
      <span className="value">
        <Input placeholder="Please enter condition value" value={value} />
      </span>
    </div>
  );
}

// Render function
const setElementTerm = (record, pos) = > {
  return <RelationTerm data={record} />
};

const data = {
	ops: 'and'.children: [{key: 'Key1'.op: '>'.value: 0 },
    {
      ops: 'or'.children: [{key: 'Key2'.op: '<'.value: 20 },
        { key: 'Key3'.op: '>'.value: 10},],},],}; ReactDOM.render(<RelationTree value={data} setElementTerm={setElementTerm} />.document.getElementById('root'));Copy the code

CSS styles

By performing the component calls I wrote earlier, the entire HTML structure comes out, which you can use to complete your CSS. The CSS for this component is slightly more complex, with a few lines, as shown below:CSS in HTML uses a box model, i.e. a border is wrapped around the content. Lines of this height and position that do not align with the edge of an element cannot be implemented directly. Here we need to use some unconventional properties — pseudo-classes. The author mainly uses the pseudo-class property ::before to simulate the visual effect shown in the diagram. The details are as follows:

.vui-relation-tree {
  .vui-relation-group {
    .relational {
      width: 78px;
      padding: 20px 0 0 16px;
      // A vertical line representing a group. Figure 1 marks the position
      border-right: 1px solid #d9d9d9;
    }
 
  	.conditions{>div {
        position: relative;
				
        // Realize the short horizontal line displayed in the middle position of each item. Figure ②, ③, ④ mark the horizontal line of the position
        &::before {
          content: "";
          display: inline-block;
          position: absolute;
          top: 0;
          left: 0px;
          width: 16px;
          height: 14px;
          border-bottom: 1px solid #d9d9d9;
          background-color: #fff;
        }
				
        // The first item needs to cover the front part of the vertical line; Figure 3. "blank" area above marked position
        &:first-child {
          &::before {
            left: -1px;
            width: 17px; }}&.vui-relation-group:before {
          top: 20px;
        }
				
        // The last item needs to cover the back of the vertical line; Figure 4. "blank" area below marked position
        &:last-child {
          &::before {
            top: inherit;
            bottom: 0;
            left: -1px;
            width: 17px;
            border-bottom: 0;
            border-top: 1px solid #d9d9d9;
          }
        }
      }
    }
  }
}
Copy the code

The other styles are just plain old, simple styles that won’t be spelled out.

Event interaction

Finally, the interaction logic of the event is supplemented. The events that need to be improved in the component include: add condition, add condition group, delete (condition), change logical connector, and change condition item content. Either of these events will ultimately change the data in state, so we can write a method that centralizes the logic of changing the data:

/ * * *@param {object} Data RelationTree Full value *@param {string} Pos position The value is a character string in the form of 0_0_1 *@param {string} Type, operation type, such as: addTerm addGroup, changeOps (change the logical operators &&, | |), changeTerm, deleteTerm *@param {string} Record Change single value */
// If immutable is used, just change the contents of the method to immutable
const getNewValue = (data = {}, pos = ' ', type, record) = > {
  if(! pos) {return record;
  }

  const arrPos = getArrPos(pos);
  const last = arrPos.length - 1;
  // Use immer for data processing
  return produce(data, (draft) = > {
    let prev = { data: draft, idx: 0 };
    // Temporarily store the data of the current condition group traversed
    let current = draft.children || [];
    // According to the POS traversal data, each number in pos represents the number of its condition group
    arrPos.forEach((strIdx, i) = > {
      const idx = Number(strIdx);
      if (i === last) {
        switch (type) {
          case 'addTerm':
          case 'addGroup': // Add a condition or group of conditions
            current.splice(idx + 1.0, record);
            break;
          case 'deleteTerm': // Delete the condition
            current.splice(idx, 1);
            // If the last item of the group is deleted, the whole group is deleted
            if(! current.length) { prev.data.splice(prev.idx,1);
            }
            break;
          default: // Change the logical connector or condition contentcurrent[idx] = record; }}else {
        prev = { data: current, idx };
        // Copy the data for the next condition group to currentcurrent = (current[idx] && current[idx].children) || []; }}); }); };Copy the code

The focus here is on “changeTerm”, which is rendered by a callback function, but a change in the value of Term also triggers an onChange for the entire component. The concrete implementation is as follows:

  1. Term’s callback rendering function executes with onChange passed as an argument to the callback function. The code is as follows:
import React from 'react';
import { Button } from '@alifd/next';

function RelationItem({ data, pos, setElementTerm, onTermChange }) {
  // The value entry must be in {key: value} format
  const handleTermChange = (value) = > {
    if (typeof onTermChange === 'function') { onTermChange(pos, { ... data, ... value }); }};if (typeofsetElementTerm ! = ='function') {
    console.error('setElementTerm property must be set and must be a Function that returns ReactElement ');
    return null;
  }

  return (
    <div className="vui-relation-item">
      { setElementTerm(data, pos, handleTermChange) }
      <Button type="normal" className="delete-term">delete</Button>
    </div>
  );
}

export default RelationItem;
Copy the code
  1. The onTermChange in RelationItem above is actually handled in RelationTree. The code is as follows:
import React, { useState } from 'react';
import RelationGroup, { defaultOpsValue } from './RelationGroup';

import './RelationTree.less';

const defaultRelation = {
  ops: defaultOpsValue,
  children: [] {}};function RelationTree({ value, setElementTerm }) {
  const [relations, setRelations] = useState(defaultRelation);
  
  const setOnChange = (pos, record, type) = > {
    // getNewValue is the method explained at the beginning of this chapter; I will not repeat it here
    const value = getNewValue(relations, pos, type, record);
    if (typeof onChange === 'function') {
      onChange(value, type, record);
    }
    setRelations(value);
  };
  
  const handleTermChange = (pos, record) = > {
    setOnChange(pos, record, 'changeTerm');
  };

  return (
    <div className="vui-relation-tree">
      <RelationGroup
        pos=""
        data={relations}
        setElementTerm={setElementTerm}
				onTermChange={handleTermChange}
      />
    </div>
  );
}

export default RelationTree;
Copy the code
  1. The onChange event callback passed in when the component is called is executed in the callback function and is passed in as required ({key: value} format). The code is as follows:
import React from 'react';
import { Select, Input } from '@alifd/next';

const { Option } = Select;

const RelationTerm = ({ data, onChange }) = > {
  const setOnChange = (params) = > {
    if (typeof onChange === 'function') {
      // Execute the onChange callback passed in {key: value} formatonChange(params); }};const handleKeyChange = (val) = > {
    setOnChange({ key: val });
  };

  const handleOpsChange = (val) = > {
    setOnChange({ op: val });
  };

  const handleValueChange = (val) = > {
    setOnChange({ value: val });
  };

  const { key, op, value } = data;
  return (
    <div className="term">
      <span className="element">
        <Select placeholder="Please select conditions" value={key} onChange={handleKeyChange}>
          <Option value="Key1">Key1</Option>
          <Option value="Key2">Key2</Option>
          <Option value="Key3">Key3</Option>
        </Select>
      </span>
      <span className="comparison">
        <Select placeholder="Please select a relation" value={op} onChange={handleOpsChange}>
          <Option value="= =">Is equal to the</Option>
          <Option value=! "" =">Is not equal to</Option>
          <Option value=">">Is greater than</Option>
          <Option value="> =">Greater than or equal to</Option>
          <Option value="<">Less than</Option>
          <Option value="< =">Less than or equal to</Option>
        </Select>
      </span>
      <span className="value">
        <Input placeholder="Please enter condition value" value={value} onChange={handleValueChange} />
      </span>
    </div>
  );
}

export default RelationTerm;

Copy the code

Other events are as simple as attaching the onClick or onChange property to the corresponding node and then calling the above method, as detailed in the “complete example”.

Complete sample

Only the relationtree. JSX, relationgroup. JSX, and relationItem. JSX files belong to the internal logic of the component. Relationter. JSX is the service logic and is used as an input parameter when invoking the component.

RelationTree.jsx

import produce from 'immer';
import React, { useEffect, useState } from 'react';
import RelationGroup, { getArrPos, defaultOpsValue } from './RelationGroup';

import './RelationTree.less';

const defaultRelation = {
  ops: defaultOpsValue,
  children: [] {}};function RelationTree({ value, onChange, setElementTerm }) {
  const [relations, setRelations] = useState(defaultRelation);
  // console.log('relations', relations);
  if (value) {
    useEffect(() = > {
      setRelations(value);
    }, [value]);
  }

  / * * *@param {string} Pos position The value is a character string in the form of 0_0_1 *@param {object} Record Single value of change *@param {string} Type, operation type, such as: addTerm addGroup, changeOps (change the logical operators &&, | |), changeTerm, deleteTerm * /
  const setOnChange = (pos, record, type) = > {
    const value = getNewValue(relations, pos, type, record);
    if (typeof onChange === 'function') {
      onChange(value, type, record);
    }
    setRelations(value);
  };

  const handleAddGroup = (pos, record) = > {
    setOnChange(pos, record, 'addGroup');
  };
  const handleAddTerm = (pos, record) = > {
    setOnChange(pos, record, 'addTerm');
  };
  const handleOpsChange = (pos, record) = > {
    setOnChange(pos, record, 'changeOps');
  };
  const handleDeleteTerm = (pos, record) = > {
    setOnChange(pos, record, 'deleteTerm');
  };
  const handleTermChange = (pos, record) = > {
    setOnChange(pos, record, 'changeTerm');
  };

  return (
    <div className="vui-relation-tree">
      <RelationGroup
        pos=""
        data={relations}
        setElementTerm={setElementTerm}
        onAddGroup={handleAddGroup}
        onAddTerm={handleAddTerm}
        onOpsChange={handleOpsChange}
        onDeleteTerm={handleDeleteTerm}
        onTermChange={handleTermChange}
      />
    </div>
  );
}


/ * * *@param {object} Data RelationTree Full value *@param {string} Pos position The value is a character string in the form of 0_0_1 *@param {string} Type, operation type, such as: addTerm addGroup, changeOps (change the logical operators &&, | |), changeTerm, deleteTerm *@param {string} Record Change single value */
const getNewValue = (data = {}, pos = ' ', type, record) = > {
  if(! pos) {return record;
  }

  const arrPos = getArrPos(pos);
  const last = arrPos.length - 1;
  // Use immer for data processing
  return produce(data, (draft) = > {
    let prev = { data: draft, idx: 0 };
    // Temporarily store the data of the current condition group traversed
    let current = draft.children || [];
    // According to the POS traversal data, each number in pos represents the number of its condition group
    arrPos.forEach((strIdx, i) = > {
      const idx = Number(strIdx);
      if (i === last) {
        switch (type) {
          case 'addTerm':
          case 'addGroup': // Add a condition or group of conditions
            current.splice(idx + 1.0, record);
            break;
          case 'deleteTerm': // Delete the condition
            current.splice(idx, 1);
            // If the last item of the group is deleted, the whole group is deleted
            if(! current.length) { prev.data.splice(prev.idx,1);
            }
            break;
          default: // Change the logical connector or condition contentcurrent[idx] = record; }}else {
        prev = { data: current, idx };
        // Copy the data for the next condition group to currentcurrent = (current[idx] && current[idx].children) || []; }}); }); }export default RelationTree;
Copy the code

RelationGroup.jsx

import React from 'react';
import { Select, Button } from '@alifd/next';
import RelationItem from './RelationItem';

const { Option } = Select;
export const posSeparator = '_';
export const defaultOpsValue = 'and';

function RelationGroup({ data, pos, setElementTerm, onAddGroup, onAddTerm, onOpsChange , onDeleteTerm, onTermChange }) {
  const getLastPos = () = > {
    const arrPos = getArrPos(pos);
    const { children } = data;
    arrPos.push(children.length - 1)
    return arrPos.join(posSeparator);
  };
  const handleOpsChange = (value) = > {
    if (typeof onOpsChange === 'function') { onOpsChange(pos, { ... data,ops: value }); }};const handleAddTermClick = () = > {
    const record = {};
    const pos = getLastPos();
    if (typeof onAddTerm === 'function') { onAddTerm(pos, record); }};const handleAddGroupClick = () = > {
    const record = { ops: defaultOpsValue, children: [{}] };
    const pos = getLastPos();
    if (typeof onAddGroup === 'function') { onAddGroup(pos, record); }};const { children, ops } = data;
  const relationValue = ops || defaultOpsValue;

  return (
    <div className="vui-relation-group">
      <div className="relational">
        <Select className="relation-sign" value={relationValue} onChange={handleOpsChange}>
          <Option value="and">and</Option>
          <Option value="or">or</Option>
        </Select>
      </div>
      <div className="conditions">
        { children.map((record, i) => {
          const { children: list } = record;
          const newPos = getNewPos(pos, i);
          
          return list && list.length ? (
            <RelationGroup
              pos={newPos}
              key={newPos}
              data={record}
              setElementTerm={setElementTerm}
              onAddGroup={onAddGroup}
              onAddTerm={onAddTerm}
              onOpsChange={onOpsChange}
              onDeleteTerm={onDeleteTerm}
              onTermChange={onTermChange}
            />
          ) : (
            <RelationItem
              pos={newPos}
              key={newPos}
              data={record}
              setElementTerm={setElementTerm}
              onDeleteTerm={onDeleteTerm}
              onTermChange={onTermChange}
            />); })}<div className="operators">
          <Button type="normal" className="add-term" onClick={handleAddTermClick}>Add conditions</Button>
          <Button type="normal" className="add-group" onClick={handleAddGroupClick}>And conditions set</Button>
        </div>
      </div>
    </div>
  );
}

const getNewPos = (pos, i) = > {
  // If the current item is the entire value (that is, the start item of the component), the new position is the current sequence number
  return pos ? `${pos}${posSeparator}${i}` : String(i);
};

export const getArrPos = (pos) = > {
  return (pos && pos.split(posSeparator)) || [];
};

export default RelationGroup;
Copy the code

RelationItem.jsx

import React from 'react';
import { Button } from '@alifd/next';

function RelationItem({ data, pos, setElementTerm, onDeleteTerm, onTermChange }) {
  const handleDeleteTermClick = () = > {
    if (typeof onDeleteTerm === 'function') { onDeleteTerm(pos, data); }}// The value entry must be in {key: value} format
  const handleTermChange = (value) = > {
    if (typeof onTermChange === 'function') { onTermChange(pos, { ... data, ... value }); }};if (typeofsetElementTerm ! = ='function') {
    console.error('setElementTerm property must be set and must be a Function that returns ReactElement ');
    return null;
  }

  return (
    <div className="vui-relation-item">
      { setElementTerm(data, pos, handleTermChange) }
      <Button type="normal" onClick={handleDeleteTermClick} className="delete-term">delete</Button>
    </div>
  );
}

export default RelationItem;

Copy the code

RelationTerm.jsx

import React from 'react';
import { Select, Input } from '@alifd/next';

const { Option } = Select;

const RelationTerm = ({ data, onChange }) = > {
  const setOnChange = (params) = > {
    if (typeof onChange === 'function') {
      // Execute the onChange callback passed in {key: value} formatonChange(params); }};const handleKeyChange = (val) = > {
    setOnChange({ key: val });
  };

  const handleOpsChange = (val) = > {
    setOnChange({ op: val });
  };

  const handleValueChange = (val) = > {
    setOnChange({ value: val });
  };

  const { key, op, value } = data;
  return (
    <div className="term">
      <span className="element">
        <Select placeholder="Please select conditions" value={key} onChange={handleKeyChange}>
          <Option value="Key1">Key1</Option>
          <Option value="Key2">Key2</Option>
          <Option value="Key3">Key3</Option>
        </Select>
      </span>
      <span className="comparison">
        <Select placeholder="Please select a relation" value={op} onChange={handleOpsChange}>
          <Option value="= =">Is equal to the</Option>
          <Option value=! "" =">Is not equal to</Option>
          <Option value=">">Is greater than</Option>
          <Option value="> =">Greater than or equal to</Option>
          <Option value="<">Less than</Option>
          <Option value="< =">Less than or equal to</Option>
        </Select>
      </span>
      <span className="value">
        <Input placeholder="Please enter condition value" value={value} onChange={handleValueChange} />
      </span>
    </div>
  );
}

export default RelationTerm;

Copy the code

index.jsx

import ReactDOM from 'react-dom';
import RelationTree from './RelationTree/RelationTree';
import RelationTerm from './RelationTerm';

import '@alifd/next/index.css';
import './index.less';

const setElementTerm = (record, pos, onChange) = > {
  return <RelationTerm data={record} onChange={onChange} />
};

ReactDOM.render(
  <RelationTree setElementTerm={setElementTerm} />.document.getElementById('root'));Copy the code

conclusion

Finally, let’s briefly summarize the key points in the design of this component, which are listed as follows:

  1. Pos is a string consisting of the sequence number of the array where each node resides, for example, 0_0_1. This value makes it easy to locate the current change item in the tree structure data, greatly simplifying the code logic for component event interactions.
  2. Ingenious use of ::before, :first-child, : last-Child pseudo-classes to achieve unconventional box model visual effect — like square brackets style.
  3. The business logic is completely stripped away by passing Term as a parameter, maximizing reusability.

This is all about this component. If you have any other ideas or suggestions, please leave a comment.