Intro

This article will show you how to develop a cascading multiple selector for Antd from scratch. First look at the effect:

Making, the Sandbox

After reading this article, you can not only learn how to implement cascading multiple selection, but also learn:

  • How do I publish a Typescript NPM Package
  • Write basic unit tests, execute unit tests using Github Action, and display elegant badges on Readme

(If you’re familiar with all of the above, reading on probably won’t help much 🧐)

background

Ant Design is The world’s second most popular React UI Framework. Ant Design is The world’s second most popular React UI Framework.

Antd provides a very good components, more Button/DatePicker/Form/Cascader/Tree/Notifaction/Modal, too much too much, these components complete component of ecological, drastically increased the common development of students daily development efficiency.

However, some special scenarios, special requirements, Antd components may not be able to support, and are not planned to support. For example, there are a lot of issues that can be found on Github and Antd developers either don’t support it or suggest TreeSelect.

(No disrespect meant, thanks to Antd developers for their output)

But as an average developer, a component as complex as TreeSelect would probably be more than most people could implement in a week (by which I mean just all the features of TreeSelect). Moreover, in the pursuit of agile delivery, a fast trial-and-error company simply can’t give you that much time to implement a common component that doesn’t see immediate benefits.

You’ve no doubt encountered similar situations where a “good open source library user” is humbled and helpless in the face of a product’s strong, irrefutable requirements and a community with no suitable open source components 😿.

There are also few tutorial articles on component implementation, so this article will show you how to implement a cascading multiple selector from scratch.

Need to understand

To give the reader an insight into the requirements, a brief description (click on the Sandbox) :

  • Click the input box to display the popover above or below the input box. Click the area outside the popover again to close the popover
  • Click the text to expand the next level menu, click Checkbox to switch the selected state
  • Unchecked unchecked unchecked unchecked Unchecked Unchecked Unchecked unchecked unchecked unchecked unchecked unchecked
  • Supports Cancel and Confirm operations. Cancel closes the popup window and Confirm submits the selection.
  • After submitting the selection, display the value of the parent node in the input box, which is the Treeselect.show_parent policy in Antd.
  • Click the x of the selected item to delete the selected item.

Component design

Before you start writing the building code, think about the additional work that unimportant functional logic might cause, what states the component has, and what parameters it can support. Having a design document like this will give you a clearer idea of how to code later.

Pre agreed

In order to reduce complexity, there are some pre-conventions that can be made according to the specific requirement scenario:

  • Only multiple selection is supported. What about radio? Using Antd’s Cascader component, of course.)
  • The keys of all nodes are strings, which are unique in the whole tree, facilitating the determination of whether a node is selected
  • The component’s value string array type, which does not support numbers or symbols, will suffice for most scenarios
  • The information a node needs to provide is value (must, used as a unique tag), title (must, node text display), and children (not must, child node array).

State

Cascading multi-choice components generally require the following states:

  • Controls whether cascading menus are displayed (Boolean)
  • Controls which nodes (string array) are currently selected. According to the design, onChange is executed after Confirm is clicked, value ≠= component value is currently selected.
  • Controls the currently expanded hierarchy of data (arrays of arrays)
  • The path that controls the current active state, the state highlighted in light blue, (an array)

I forgot where I saw the state design principle of “all states that can be computed should be computed”. In the cascading multi-selection component, as the final display is treeselect.show_parent strategy, what you see is what you get can keep the value consistent, as shown in the following figure, the value is [‘ Shenzhen ‘, ‘Liwan ‘]. In Guangzhou, guangdong province, the selected “state” is calculated.

Props

As a form component, the required parameters are just those that can be easily listed.

Props Type Description
value string[] Data binding
data TreeNode[] Node data {title: string, value: string, children? : TreeNode }
allowClear boolean Is it allowed to be clear
placeholder string Placeholder
onChange (newVal) => void Value Change callback function
className boolean Additional CSS class name
style React.CSSProperties Additional styles
disabled boolean Whether to disable

Support for value and onChange props can be used in Antd forms.

Implementation details

The Selector style

The first thing to do is, in the form input box, select the label 🏷 style for the node, since it is delivered as a form control for Antd. All must be consistent with the style/behavior of other Select components.

Consider the appearance box model, hover/ highlight state style, allowClear style, disabled style, and so on. This part of the style can be implemented yourself or scraped directly from the ANTD Select component.

However, this situation is easy to miss, fearing that there are some scenarios that have not been considered. The most time-efficient way to do this is to use the class name of Antd Selector and just reuse it, masquerade as a Selector 🤓.

Popover and animation

Next, the Selector event is handled, controlling the expansion and collapse of the menu. In this step, you should do the following things

  • The Selector binding listens for the event that shows the menu when it’s clicked
  • To be independent of the Selector’s container and style, you need to use a Portal to render the menu outside of the React Root Dom node
  • Listen for the menu click outside event and close the menu when the event is triggered
  • Display and close menus with animations to maintain consistent interaction with other pop-up components.

The minimum implementation is a small amount of work, but it is not important in the case of cascading multiple components, which can be handed over to a public library. The rC-Cascader component used by Antd has an rC-trigger component wrapped around its outer layer. Click here to see if the abstract component container does the function mentioned in 👆.

return (
  <Trigger
    .
  >
    {React.cloneElement(children, {
      onKeyDown: this.handleKeyDown,
      tabIndex: disabled ? undefined : 0,
    })}
  </Trigger>
);
Copy the code

Rc-xxx is the basic component provided by Antd development team. We usually use Antd component in the RC-XXX component packaging.

Using rC-trigger, you can complete the basic function of popover very quickly.

import { Button, Empty } from 'antd'
import Trigger from 'rc-trigger'

const [popupVisible, setPopupVisible] = useState(false)

return (
  <Trigger
    action={! disabled ? ['click'] : []}
    popup={
      <Popup />} popupVisible={popupVisible} onPopupVisibleChange={setPopupVisible} popupStyle={{ position: 'Absolute ',}} popupAlign={{points: ['tl', 'bl'], offset: [0, 3]}}<Selector
      {. props} / >
  </Trigger>
)
Copy the code

Rc-trigger not only provides pop-ups, alignments, click-triggers, and even animations, just set popupTransitionName=”slide-up” to get the same animation as any other Select component.

How to implement the RC-Trigger component

Checkbox 🌲 Status linkage

Next comes the centerpiece of this component, Checkbox 🌲 state maintenance.

In the following example, If Shenzhen is selected, all child nodes under Shenzhen are displayed as selected (but not reflected in value). Guangzhou is partially selected, because value contains part of the districts under Guangzhou, and so is the semi-selected state of Guangdong Province.

structure

Given that tree structures require frequent up-down traversal, we might need a bidirectional multi-fork tree structure 🌲. The parent-child connection is already represented in the children field, and you need to add a parent attribute to each child node pointing to the parent node.

In languages that use a garbage collection mechanism for reference counting, circular references are prone to memory leaks. The modern Javascript garbage collection mechanism is tag cleanup.

In order to obtain the corresponding node through value, the tree structure is flattened after the parent → child association to obtain a one-dimensional array. The code is as follows:

export function flattenTree(root: TreeNode[]) :TreeNode[] {
  const res: TreeNode[] = []

  function dfs(nodes: TreeNode[], parent: TreeNode | null = null) {
	// ...
    for (let i = 0; i < nodes.length; i++) {
      const node = nodes[i]
      const { children } = node

      constnewNode = { ... node, parent } res.push(newNode)if (children) { dfs(children, newNode) }
    }
    // ...
  }
  dfs(root)

  return res
}
Copy the code

Check

When the user clicks the Checkbox of a certain level, it needs to recursively traverse all the direct parent nodes upwards to determine whether the children are in checked state. If so, switch value to the value of the parent node and delete all its child node values.

Current value [' Shenzhen ', 'Tianhe district ',' Liwan District '] click on the check box of "Luogang District", need to add to value [' Shenzhen ', 'Tianhe District ',' Liwan District ', 'Luogang District '] go up, come to' Guangzhou ', find all the child nodes are selected, Delete all child node values and add their own values. [' Shenzhen ', 'Guangzhou '] Continue to judge, when it comes to Guangdong province, all the child nodes are selected, delete all the child nodes' values and add their own values. [' Guangdong province '] because there is no higher level, the upward traversal is abortedCopy the code

The code is as follows:

// The status is improved
export function liftTreeState(item: TreeNode, curVal: ValueType[]) :ValueType[] {
  const { value } = item

  // Add the current node value
  const nextValue = curVal.concat(value)
  let last = item

  // eslint-disable-next-line no-constant-condition
  while (true) {
    // If all the children of the parent are checked, add value to the object and continue trying to promote
    if( last? .parent? .children! .every((child: TreeNode) = > nextValue.includes(child.value))
    ) {
      nextValue.push(last.parent.value)
      last = last.parent
    } else {
      break}}// Remove all descendants value of the last checked parent
  return removeAllDescendanceValue(last, nextValue)
}
Copy the code

UnCheck

When the user clicks Uncheck, the logic is basically the reverse of the Check operation.

After deselecting “Liwan District”, first traverse from this node all the way up to the parent node in the current value, and temporarily save this path parentPath [‘ Liwan District ‘, ‘Guangzhou City ‘,’ Guangdong Province ‘].

If the current node is discarded on parentPath, put it in nextValue if it is not.

Current value [' guangdong province ', 'Hunan Province '] Guangdong Province in parentPath, need to discard. [' Hunan '] continue to recurse downward, Shenzhen is not on parentPath, need to be added to Value, not on parentPath, child nodes can no longer traverse. [' Hunan ', 'Shenzhen '] Guangzhou is in parentPath, not in value, continue recursion. [' Hunan Province ', 'Shenzhen City '] Liwan district is on parentPath and not in Value, so it is directly ignored. Tianhe district and Luogang District are not on parentPath and need to be added to Value. [' Hunan ', 'Shenzhen ',' Tianhe ', 'Luogang '] No further nodes, traversal abortedCopy the code

The code is as follows:

// State sinks
export function sinkTreeState(root: TreeNode, value: ValueType[]) :ValueType[] {
  const parentValues: ValueType[] = []
  const subTreeValues: ValueType[] = []

  / / get parentPath
  function getCheckedParent(
    node: TreeNode | null | undefined
  ) :TreeNode | null {
    if(! node) {return null
    }
    parentValues.push(node.value)
    if (value.includes(node.value)) {
      return node
    }

    return getCheckedParent(node.parent)
  }

  const checkedParent = getCheckedParent(root)
  if(! checkedParent) {return value
  }
  
  // Recursively iterate over all child nodes
  function dfs(node: TreeNode) {
    if(! node.children || node.value === root.value) {return
    }
    node.children.forEach((item: TreeNode) = > {
      if(item.value ! == root.value) {if (parentValues.includes(item.value)) {
          dfs(item)
        } else {
          subTreeValues.push(item.value)
        }
      }
    })
  }
  dfs(checkedParent)

  // Replace the value of the checkedParent subtree
  const nextValue = removeAllDescendanceValue(checkedParent, value).filter(
    (item) = >item ! == checkedParent.value )return Array.from(new Set(nextValue.concat(subTreeValues)))
}
Copy the code

Connected Checkbox

As mentioned earlier, we only save the value of Show_Parent; the partially selected state and the child node selected state are computed at run time. The calculation rules are as follows:

  • If a child object is a checked state by itself or by itself
  • If a parent node is indeterminate and its tail is not checked, and some of its molecules are checked
  • Is a child node in an Unchecked state
export const ConnectedCheckbox = React.memo(
  (props: Pick<MenuItemProps, 'node'>) = > {
    const { node } = props
    const { value: containerValue, handleSelectChange } = MultiCascader.useContainer()

    const handleChange = useCallback(
      (event: CheckboxChangeEvent) = > {
        const { checked } = event.target
        handleSelectChange(node, checked)
      },
      [node]
    )
    // it or its parent is checked
    const checked = useMemo(() = > hasParentChecked(node, containerValue), [
      containerValue,
      node,
    ])
    // It does not have checked, but it has the child state checked
    const indeterminate = useMemo(
      () = >! checked && hasChildChecked(node, containerValue), [checked, containerValue, node] )return (
      <Checkbox
        onChange={handleChange}
        checked={checked}
        indeterminate={indeterminate}
      />)})Copy the code

The hasParentChecked and hasChildChecked methods are just simple scrolling up and down, so I’m going to skip this because the code is simpler.

In order to facilitate the component state management (code of MultiCascader. UseContainer), I introduced the unstated – next to the library, or the use of Hooks behind + React the Context, but the code is more concise, Typescript supports friendliness.

All functions

With the Checkbox linkage above, the logic to continue to implement [select all] becomes very simple. Add an All node to the original data and bind the All Checkbox to the Footer. When all nodes at the first level are selected, values are automatically promoted up the parent.

const flattenData = useMemo(() = > {
  // Add a TreeNode node on top of the original data if you want to support full selection
  if (selectAll) {
    return flattenTree([
      {
        title: 'All'.value: All,
        parent: null.children: data,
      },
    ])
  }
  return flattenTree(data || [])
}, [data, selectAll])

// If you want to support All, render the ConnectedCheckbox in the Footer and assign the All node
{selectAll ? (
  <div className={` ${prefix}-popup-all`} >
    <ConnectedCheckbox node={flattenData[0]} />
    &nbsp;&nbsp;{selectAllText}
  </div>
) : null}
Copy the code

Cascading menu

By default, the menu is expanded to list all the parent nodes of the first level. If a flattener is supported, take the first children of the flattener data; otherwise, run through the data and find all the children that do not have parents.

const [menuData, setMenuData] = useState([
  selectAll
    ? flattenData[0].children!
    : flattenData.filter((item) = >! item.parent), ])Copy the code

Since each non-leaf node holds the children data, the cascading menu essentially adds the children of the parent node to the array that maintains the cascading state after the parent node is clicked.

const addMenu = useCallback((menu: TreeNode[], index: number) = > {
  if (menu && menu.length) {
    setMenuData((prevMenuData) = > [...prevMenuData.slice(0, index), menu])
  } else {
    // If children also want to update menu
    // For example, the current level 3 menu is expanded and another level 2 leaf node is clicked
    setMenuData((prevMenuData) = > [...prevMenuData.slice(0, index)])
  }
}, [])
Copy the code

By now, the core logic of the whole cascade multiple selection has been developed, the rest of the logic is not here to expand, interested students can look at the source code on Github.

The next section describes how to publish React components developed in Typescript to NPM.

Typescript NPM Package

Our component code is written in Typescript, but users don’t necessarily use Typescript or compile files in node_modules, so the code published to NPM should be in JS.

For Typescript projects, you first need to install both Typescript and Tslib packages.

$ yarn add typescript tslib -D
Copy the code

Modify package.json to add TSC Script and main fields.

The main field is the entry file that specifies when someone else uses the package. The original Typescript code entry is SRC /index.tsx. The link operation is performed in dev so that validation can be performed in the local project first. TSC Watch recompiles immediately every time the code changes. PrepublishOnly recompiles Typescript each time it is ready for publication.

"main": "dist/index.js"."scripts": {
  "dev": "yarn link && yarn tsc --watch"."tsc": "tsc"."prepublishOnly": "rm -rf dist/ && npm run tsc"
},
Copy the code

Add the tsconfig.json file to the root directory, specify the output path, whether to generate a declaration file, execute JSX, and so on. The declaration field is set to true, and Typescript automatically generates d.ts files at compile time. By providing these declaration files, you can have code associative hints in editors such as VSCode.

{
  "compilerOptions": {
		/ /...
    "outDir": "dist".// ...
    "declaration": true.// ...
    "jsx": "react"
  },
  "include": ["./src/**/*"]}Copy the code

What about Less files?

The style files in the component are written in less. Similarly, we can’t require users of the package to use less. We need to provide a CSS file (Typescript doesn’t handle less files).

// Add antD's own file to the less file to use its supplied variables
@import '.. /node_modules/antd/es/style/themes/default.less';

@prefix: ~'antd-multi-cascader';

.@{prefix} {
  text-align: left;

  &-hidden {
    display: none;
  }

  / /...
}
Copy the code

Install the less

$ yarn add -D less
Copy the code

Go back to package.json and add lessc script specifying that SRC /index.less is compiled to dist/index.less. – The js argument refers to antD’s dfault.less file, which uses inline javascript functionality.

"scripts": {
    "compile": "yarn tsc && yarn lessc"."tsc": "tsc"."lessc": "lessc src/index.less dist/index.css --js"."prepublishOnly": "yarn test && rm -rf dist/ && npm run compile"
  },
Copy the code

After publishing to NPM, you can apply components and style files to your project. ✨

import MultiCascader from "antd-multi-cascader";
import "antd-multi-cascader/dist/index.css";
Copy the code

Unit testing and Github Actions

Unit tests are a cheap and effective way to ensure code quality. In addition to helping you find problems when your code doesn’t work as expected, they are also a great tool for troubleshooting problems. In general, the higher unit test coverage, the easier it is to gain acceptance from other developers.

On the front end, we can write unit tests for methods, modules, or even a component. Writing and running unit tests starts with installing a unit testing framework, such as Facebook’s Jest.

yarn add -D jest @types/jest ts-jest
Copy the code

Add the jest.config.js file to the project root so that Jest knows how to parse and which unit test files to match.

module.exports = {
  preset: 'ts-jest'.testMatch: ['<rootDir>/src/**/__tests__/*.tsx'].collectCoverageFrom: ['src/**/*.{ts,tsx}'],}Copy the code

Then you can write the unit test code. The global Describe method declares a group of unit tests in which hooks for the unit test lifecycle, before, after, beforeEach, afterEach, and so on, can be defined in which data can be built for running unit tests.

The IT method, where a specific use case is executed, uses the Expect method provided by Jest, which takes a parameter and returns an object with many assertion methods. Calling these assertion methods has the effect of verifying the expected execution. The following example asserts that the flattener hasChildChecked(flattener value [0], [‘1’]) returns true. Given the input, the validation method allows the result, and the next time you accidentally break the method, the unit test can tell you immediately and fix it before it snowballs.

import { hasChildChecked } from '.. '

describe('src/components/MultiCascader/utils.tsx'.() = > {
  describe('hasChildChecked'.() = > {
    let flattenValue: TreeNode[]

    beforeEach(() = > {
      flattenValue = createFlattenTree()
    })

    it('should tell has child checked or not'.() = > {
      expect(hasChildChecked(flattenValue[0], ['1'])).toEqual(true)})})})Copy the code
  • In this component, there are many pure methods related to 🌲 operations, very much writing unit tests. Testing-library.com/ is recommended if you want to write unit tests for the React component

After writing the unit tests, add the test command to package.json scripts and also add the test task before prepublishOnly so that the unit tests can be run each time before packaging.

"scripts": {
  "test": "jest"."prepublishOnly": "yarn test && rm -rf dist/ && npm run compile"
},
Copy the code

During continuous integration, we can use Github Actions, Gitlab CI, Travis CI and other tools to run unit tests, Lint, Sonarqube and other tasks to ensure the quality of code submission.

Use making Actions is very simple, need only in the project root directory to create. Making/workflows/test. The yml file. Copy the code and start a Node service to run the unit tests every time you submit the code and PR.

name: Test

on: [push.pull_request]

jobs:
  release:
    runs-on: The ${{ matrix.os }}

    strategy:
      matrix:
        os: [Macos 10.14]

    steps:
      - uses: actions/checkout@v2
      - name: Use Node.js
        uses: actions/setup-node@v1
        with:
          node-version: '12.x'
      - run: npm install
      - run: npm run test -- --coverage
Copy the code

Want to get a Badge showing Coverage? IO /gh, select the repository, add the CODECOV_TOKEN displayed on the Github project settings-secrets page, and add the following code to the test.yml file.

- name: Upload coverage to Codecov
        uses: codecov/codecov-action@v1
        with:
          token: The ${{ secrets.CODECOV_TOKEN }}
          flags: unittests
          name: codecov-umbrella
          fail_ci_if_error: true
Copy the code

Then you can through the https://img.shields.io/codecov/c/github/ [user] / [project] / master. The SVG link to get the badge. 🎉

conclusion

After reading this article, you’ll be ready to start developing a cascading multiple selector, too. If you have any questions during the reading, please leave a comment.

As a minimum realization to meet product requirements, the content of this article is for reference only, and it should be carefully used in actual projects, because there are at least the following defects.

  • Performance requirements in case of large amount of data. The author has very small amount of data in the project and does not take performance into account too much when coding. If there is a large amount of data, you need to enter the Virtual List
  • No support for search, dynamic loading of menu content, no support for custom render
  • Without rigorous testing, parameter changes during use may bring unexpected results
  • There is no support for button operation, Web accessibility, etc

For the Checkbox 🌲 status linkage section, check out the source code for another great open source component library, RSuitejs-Multi-Cascader. Due to style, ecology, and the need for flexibility to respond to project requirements, it was not chosen and implemented on its own.

Finally, all the code is available on Github. If you want more details, go to 👀 and welcome to Star

Students who have the same needs are also welcome to try it out. If you have any questions in the process of using it, please also welcome to issue~

// npm
$ npm install antd-multi-cascader
// yarn
$ yarn add antd-multi-cascader
Copy the code

link

  • Github.com/react-compo…
  • Github.com/ant-design/…
  • Rsuitejs.com/components/…