如何實現條件組合元件

袋鼠云数栈前端發表於2024-03-21

我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。

本文作者:霜序
本文首發於:https://juejin.cn/post/7299384698882539574

在大資料業務中,時常會出現且或關係邏輯的拼接,有需要做成視覺化配置,如下圖

file

目前該元件已經開源到了我們元件庫 dt-react-component詳細檢視

前期分析

需要確定好資料結構

因為是巢狀結構,可以透過 ➕➖ 來增加層級或者資料,因此採用樹形結構來儲存資料。

export interface IFilterValue<T> {
  key: string;
  level?: number; // 當前節點的層級,用於判斷一些按鈕的展示
  type?: number; // 當前節點的條件關係,1 | 2
  rowValues?: T; // Form 節點的相關的資訊(子節點無條件節點時才有)
  children?: IFilterValue<T>[]; // 子節點的資訊(子節點存在條件節點時才有)
}

上述的圖片的資料為:

 {
    "key": "qTipLrlUt",
    "level": 1,
    "children": [
        {
            "key": "B6Jrbqcfof",
            "type": 2,
            "level": 2,
            "children": [
                {
                    "rowValues": {
                        "condition": 1,
                        "rowPermission": ""
                    },
                    "key": "deg8x8UgZ",
                    "level": 2
                },
                {
                    "key": "_sczw_1h8H",
                    "type": 1,
                    "level": 3,
                    "children": [
                        {
                            "key": "Z5UkUPJoA",
                            "rowValues": {
                                "condition": 1,
                                "rowPermission": ""
                            },
                            "level": 3
                        },
                        {
                            "key": "MbpJILqHGx",
                            "rowValues": {
                                "condition": 1,
                                "rowPermission": ""
                            },
                            "level": 3
                        }
                    ]
                }
            ]
        },
        {
            "rowValues": {
                "condition": 1,
                "rowPermission": ""
            },
            "key": "qx6bG0o5H",
            "level": 1
        }
    ],
    "type": 1
}

明確每個操作按鈕的實現

明確元件的封裝

  • 元件只希望實現條件節點/線條/操作按鈕的展示,因此後面的元件需要作為引數 component 傳入
  • 元件對層級有一個控制,支援 maxLevel 來控制
  • 每一次新增資料的時候,預設值需要傳入 initValues
  • 支援兩種模式 編輯狀態 和 檢視狀態
  • 支援受控和非受控兩種模式

元件封裝

FilterRules

提供給使用者使用的元件,實現資料的增刪改查操作。可以採用受控和非受控兩種模式。
它接受的引數如下:

interface IProps<T> {
  value?: IFilterValue<T>;
  disabled?: boolean;
  maxLevel?: number;
  initValues: T;
  notEmpty?: { data: boolean; message?: string };
  component: (props: IComponentProps<T>) => React.ReactNode;
  onChange?: (value: IFilterValue<T> | undefined) => void;
}
export const FilterRules = <T>(props: IProps<T>) => {
  const {
    component,
    maxLevel = 5,
    disabled = false,
    notEmpty = { data: true, message: '必須有一條資料' },
    value,
    initValues,
    onChange,
  } = props;
  // 查詢當前操作的節點
  const finRelationNode = (
    parentData: IFilterValue<T>,
    targetKey: string,
    needCurrent?: boolean,
  ): IFilterValue<T> | null | undefined => {};
  const handleAddCondition = (keyObj: { key: string; isOut?: boolean }) => {};
  // 增加新的資料,判斷是在當前節點下新增或者新生成一個條件節點
  const addCondition = (
    treeNode: any,
    keyObj: { key: string; isOut?: boolean },
    initRowValue: T,
  ) => {};
  const handleDeleteCondition = (key: string) => {};
  // 刪除節點,刪除當前節點下的一條資料或者是刪除一個條件節點
  const deleteCondition = (parentData: IFilterValue<T>, key: string) => {};
  // 刪除一個條件節點時,更新當前資料的層級
  const updateLevel = (node: IFilterValue<T>) => {};
  // 更改條件節點的條件
  const handleChangeCondition = (
    key: string,
    type: ROW_PERMISSION_RELATION,
  ) => {};
  // 改變節點的的資料
  const handleChangeRowValues = (key: string, values: T) => {};
  return (
    <RulesController<T>
      maxLevel={maxLevel}
      disabled={disabled}
      value={value}
      component={component}
      onAddCondition={handleAddCondition}
      onDeleteCondition={handleDeleteCondition}
      onChangeCondition={handleChangeCondition}
      onChangeRowValues={handleChangeRowValues}
    />
  );
};

編輯情況

非受控元件使用
<Form form={form}>
  <Form.Item name={'condition'}>
    <FilterRules<IRowValue>
      component={(props) => (
        <RowColumnConfig columns={record?.columns ?? []} {...props} />
      )}
      maxLevel={MAX_LEVEL}
      initValues={INIT_ROW_VALUES}
    />
  </Form.Item>
</Form>;

// RowColumnConfig 實現,name 可能是 children[0].formValues
<Form.Item
  name={['condition', ...name, 'column']}
  rules={[{ message: '請選擇欄位', required: true }]}
  initialValue={column}
>
  <Select placeholder="請選擇欄位">
    {columns.map((item) => (
      <Option key={item} value={item}>
        {item}
      </Option>
    ))}
  </Select>
</Form.Item>;

// 最後透過 form.validateFields() 拿到的和上述的資料結構一致
受控元件使用
const [ruleData, setRuleData] = useState({
  key: shortid(),
  level: 0,
  rowValues: {
    column: first.column,
    condition: first.condition,
    rowPermission: first?.value,
  },
});

<FilterRules<IRowValue>
  value={ruleData}
  component={(props) => (
    <RowColumnConfig columns={record?.columns ?? []} {...props} />
  )}
  maxLevel={MAX_LEVEL}
  initValues={INIT_ROW_VALUES}
  onChange={setRuleData}
/>;
// 透過 ruleData 就能夠拿到最後的結果

檢視使用

<FilterRules
  component={(props) => <RowColumnConfig columns={[]} {...props} />}
  disabled
  value={value}
/>

編輯檢視使用(後續新增)

file

上圖為最後實現的效果,適用於部分資料禁用且可以編輯其他資料。常見業務情景:上一次儲存的資料不可修改,但需要在當前基礎上繼續新增資料。
在這種使用模式下,FilterRules 元件上的 props 依舊為 false,透過設定 value 中每一個節點的 disabled 屬性來實現上述功能

// 修改 IFilterValue 的型別
// 💭注意,如果當前節點是條件節點,children 內節點的狀態和當前節點的 disabled 息息相關
export interface IFilterValue<T> {
    key: string;
    level?: number;                   // 當前節點的層級,用於判斷一些按鈕的展示
    type?: number;                    // 當前節點的條件關係,1 | 2
	+ disabled?: boolean;               // 當前節點的狀態	
    rowValues?: T;                    // Form 節點的相關的資訊(子節點無條件節點時才有)
    children?: IFilterValue<T>[];     // 子節點的資訊(子節點存在條件節點時才有)
}

上述圖片的資料結構如下

const INIT_CHECK_DATA = {
    key: shortid(),
    level: 0,
    type: 1,
    children: [
        {
            rowValues: {
                input: '',
            },
            disabled: true,
            key: shortid(),
            level: 1,
        },
        {
            key: shortid(),
            type: 1,
            level: 2,
            disabled: true,
            children: [
                {
                    rowValues: {
                        input: '',
                    },
                    key: shortid(),
                    level: 2,
                },
                {
                    key: shortid(),
                    rowValues: {
                        input: '',
                    },
                    level: 2,
                },
            ],
        },
        {
            rowValues: {
                input: '',
            },
            key: shortid(),
            level: 1,
        },
        {
            rowValues: {
                input: '',
            },
            key: shortid(),
            level: 1,
        },
    ],
};

在這種模式下,要去計算對應的高度和渲染正確的樣式時,對於其 disabled 的計算需要改為 FilterRules 的 disabled 和當前節點的 disabled 做整合,disabled || !!item.disabled
具體程式碼修改檢視 PR

RulesController

做節點的展示,渲染正確的元件

具體實現

編輯時高度計算

計算每個節點的高度

file

  • 如果是普通節點(藍色),它的高度為 ITEM_HEIGHT + MARGIN (輸入框的高度 + marginBottom)
  • 如果是條件節點(灰色),它的高度為 children 中每一個節點的高度 + 新增節點的高度 ITEM_HEIGHT
const calculateTreeItemHeight = (item, isEdit) => {
  if (!item?.children)
    return weakMap.set(item, {
      height: ITEM_HEIGHT + MARGIN,
      lineHeight: ITEM_HEIGHT,
    });
  item.children.map((child) => calculateTreeItemHeight(child, disabled));
  const height = item.children.reduce(
    (prev, curr) => prev + weakMap.get(curr).height,
    ITEM_HEIGHT,
  );
  weakMap.set(item, { height });
};

計算每個節點的連線高度

file

  • 如果是最後一個條件節點
    線條長度(紅色線條)為 塊級高度 - (第一個節點高度 - MARGIN)/2 - 最後一個節點/2
  • 如果不是最後一個條件節點
    線條長度為 firstNodeLineHeight + 剩餘子節點高度 + 新增節點/2
    • 第一個子節點是普通節點(藍色線條):firstNodeLineHeight = 節點高度/2 + MARGIN
    • 第一個子節點是條件節點(綠色線條):firstNodeLineHeight = 子節點線條高度 + 新增節點/2
const calculateTreeItemHeight = (item: IFilterValue<T>, disabled: boolean) => {
  if (!item?.children)
    return weakMap.set(item, {
      height: ITEM_HEIGHT + MARGIN,
      lineHeight: ITEM_HEIGHT,
    });
  item.children.map((child) => calculateTreeItemHeight(child, disabled));
  const isLastCondition = !item.children.some(isCondition);
  const firstNodeIsCondition = isCondition(item.children[0]);
  const height = item.children.reduce(
    (prev, curr) => prev + weakMap.get(curr).height,
    ITEM_HEIGHT,
  );
  let lineHeight;
  // 如果當前節點是最後的判斷節點
  if (isLastCondition) {
    const firstNodeLineHeight = weakMap.get(item.children[0]).height - MARGIN;
    const lastNodeHeight = ITEM_HEIGHT;
    lineHeight = height - firstNodeLineHeight / 2 - lastNodeHeight / 2;
  } else {
    const firstNodeLineHeight = firstNodeIsCondition
      ? weakMap.get(item.children[0]).lineHeight / 2 + ITEM_HEIGHT / 2
      : ITEM_HEIGHT / 2 + MARGIN;
    lineHeight =
      firstNodeLineHeight +
      item.children
        ?.slice(1)
        .reduce(
          (prev, curr) => prev + weakMap.get(curr).height,
          ITEM_HEIGHT / 2,
        );
  }
  weakMap.set(item, { height, lineHeight });
};

檢視時高度計算

計算每個節點的高度

節點高度 等於每一個節點的高度之和

const calculateTreeItemHeight = (item: IFilterValue<T>, disabled: boolean) => {
  if (!item?.children)
    return weakMap.set(item, {
      height: ITEM_HEIGHT + MARGIN,
      lineHeight: ITEM_HEIGHT,
    });
  item.children.map((child) => calculateTreeItemHeight(child, disabled));
  const height = item.children.reduce(
    (prev, curr) => prev + weakMap.get(curr).height,
    0,
  );
  weakMap.set(item, { height });
};

具體的高度圖如下圖所示:

file

計算每個節點的連線高度

連線高度為 firstNodeLineHeight + 中間節點高度 + lastNodeLineHeight

  • 如果是最後一個條件節點
    lineHeight(紅色) = 塊級高度(藍色) - MARGIN - ITEM_HEIGHT/2 - ITEM_HEIGHT/2(紫色)

file

  • 如果不是最後一個條件節點,需要根據其子節點在做計算

file

對於上述這種情況,我們需要遞迴計算當前條件節點的第一個節點應該減去的高度和最後節點應該減去的高度(藍色部分)

const firstNodeLineHeight = firstNode.height - getNodeReduceHeight(item, true);
const lastNodeLineHeight =
  lastNode.height - MARGIN - getNodeReduceHeight(item, false);

// 如果是普通節點,返回值為 ITEM_HEIGHT / 2
// 如果是條件節點,返回值 currentNode.lineHeight /2 + getNodeReduceHeight(currentNode, isFirst)。需要遞迴遍歷對應的節點算出總共要減去的高度

const getNodeReduceHeight = (item: IFilterValue<T>, isFirst) => {
  const currentNode = isFirst
    ? item?.children?.[0]
    : item?.children?.[item?.children?.length - 1];
  if (!currentNode) return ITEM_HEIGHT / 2;
  const currentNodeIsCondition = isCondition(currentNode);
  if (currentNodeIsCondition) {
    return (
      currentNode.lineHeight / 2 + getNodeReduceHeight(currentNode, isFirst)
    );
  }
  return ITEM_HEIGHT / 2;
};

新增新內容

file

  • 最外層的新增(紅色按鈕)
    • 直接操作當前層級(最外層)的 children,新增一組 INIT_ROW_VALUES
  • 巢狀層的最下新增按鈕(黃色)
    • 獲取到當前層的 children,新增一組 INIT_ROW_VALUES
  • 巢狀層的每一行新增按鈕(紫色)
    • 會新增一個巢狀關係
// 根據點選的按鈕,來獲取相關的 Node,對於紅色/黃色按鈕來說獲取當前層級 Node
const finRelationNode = (
  parentData: IFilterValue<T>,
  targetKey: string,
  needCurrent?: boolean,
) => {
  const parentDataTemp = parentData;
  if (parentDataTemp.key === targetKey) return parentDataTemp;
  if (!parentDataTemp.children?.length) return null;
  for (let i = 0; i < parentDataTemp.children.length; i++) {
    const current = parentDataTemp.children[i];
    if (current.key === targetKey)
      return needCurrent ? current : parentDataTemp;
    const node: IFilterValue<T> | null | undefined = finRelationNode(
      current,
      targetKey,
      needCurrent,
    );
    if (node) return node;
  }
};

const handleAddCondition = (keyObj: { key: string; isOut?: boolean }) => {
  const cloneData = clone(value);
  const appendNode = finRelationNode(
    cloneData as IFilterValue<T>,
    keyObj.key,
    keyObj.isOut,
  );
  addCondition(appendNode, keyObj, initValues as T);
  onChange?.(cloneData);
};

const addCondition = (
  treeNode: any,
  keyObj: { key: string; isOut?: boolean },
  initRowValue: T,
) => {
  const key = keyObj.key;
  if (keyObj.isOut)
    return treeNode.children.push(
      Object.assign(
        {},
        { rowValues: initRowValue },
        { key: shortId(), level: treeNode.level },
      ),
    );
  const children = treeNode?.children;
  if (!children) {
    const newNode = {
      key: treeNode.key,
      level: treeNode.level + 1,
      type: ROW_PERMISSION_RELATION.AND,
      children: [
        {
          rowValues: treeNode.rowValues,
          key: shortId(),
          level: treeNode?.level + 1,
        },
        { rowValues: initRowValue, key: shortId(), level: treeNode?.level + 1 },
      ],
    };
    delete treeNode.rowValues;
    Object.assign(treeNode, newNode);
    return;
  }
  for (let i = 0; i < children.length; i += 1) {
    if (children[i].key !== key) continue;
    if (treeNode?.level <= maxLevel) {
      children[i] = {
        key: children[i].key,
        type: ROW_PERMISSION_RELATION.AND,
        level: treeNode?.level + 1,
        children: [
          Object.assign({}, children[i], {
            key: shortId(),
            level: treeNode?.level + 1,
          }),
          Object.assign({
            key: shortId(),
            rowValues: initRowValue,
            level: treeNode?.level + 1,
          }),
        ],
      };
    }
  }
};

點選刪除內容

file

  • 點選紫色按鈕,第二個條件節點只剩一個 children,需要刪除第二個條件節點,且重新計算每一行的層級
  • 點選黃色按鈕,當前條件節點的 children 刪除一行資料
const deleteCondition = (parentData: IFilterValue<T>, key: string) => {
  let parentDataTemp = parentData;
  parentDataTemp.children = parentDataTemp?.children?.filter(
    (item) => item.key !== key,
  );
  if (parentDataTemp?.children?.length === 1) {
    const newChild = updateLevel(parentDataTemp.children[0]);
    const key = parentDataTemp.key;
    delete parentDataTemp.children;
    delete parentDataTemp.type;
    parentDataTemp = Object.assign(parentDataTemp, {
      ...newChild,
      key,
      level: newChild.level,
    });
  }
};

const updateLevel = (node: IFilterValue<T>) => {
  let newChildren;
  if (node.children)
    newChildren = node.children.map((element) => updateLevel(element));
  const newNode: IFilterValue<T> = {
    ...node,
    children: newChildren,
    level: (node?.level as number) - 1,
  };
  return newNode;
};

切換條件節點

獲取到當前層級的節點,改變對應的 type 值

const handleChangeCondition = (key: string, type: ROW_PERMISSION_RELATION) => {
  const cloneData = clone(value);
  const changeNode = finRelationNode(cloneData, key, true);
  changeNode.type =
    type === ROW_PERMISSION_RELATION.AND
      ? ROW_PERMISSION_RELATION.OR
      : ROW_PERMISSION_RELATION.AND;
  onChange?.(cloneData);
};

改變元件資料

const handleChangeRowValues = (key: string, values: T) => {
  const cloneData = clone(value);
  const changeNode = finRelationNode(cloneData, key, true);
  changeNode.rowValues = {
    ...(changeNode.rowValues ?? {}),
    ...values,
  };
  onChange?.(cloneData);
};

總結

該元件已經實現完成,FilterRules 主要是運算元據,RuleController 主要是條件/線條/元件的渲染。支援使用者自定義 component 傳入 FilterRules。
目前的侷限性為,component 的高度為 32,已經下間距為 16,其他的高度可能會導致線條渲染問題。

最後

歡迎關注【袋鼠雲數棧UED團隊】~
袋鼠雲數棧 UED 團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎 star

  • 大資料分散式任務排程系統——Taier
  • 輕量級的 Web IDE UI 框架——Molecule
  • 針對大資料領域的 SQL Parser 專案——dt-sql-parser
  • 袋鼠雲數棧前端團隊程式碼評審工程實踐文件——code-review-practices
  • 一個速度更快、配置更靈活、使用更簡單的模組打包器——ko
  • 一個針對 antd 的元件測試工具庫——ant-design-testing

相關文章