Code Monkey home page Code Monkey logo

use-tree-state's Introduction

Use Tree State

travis build codecov npm bundle size dependency
npm npm GitHub top language

A super-light and customizable React hook to manage tree state like never before ✨✨

An example package that uses this hook internally: react-folder-tree

Features

built in CRUD handlers: add, modify, delete tree nodes with 1 line of code
custom handlers: define any custom state transition for your need
half check: auto calculate new checked status for all nodes
onChange: listen to state change and events

Usage

🌀 install

$ yarn add use-tree-state
$ npm install use-tree-state --save

🌀 initialization

import useTreeState, { testData } from 'use-tree-state';

const TreeApp = () => {
  const { treeState } = useTreeState({ data: testData });

  return (<Tree state={ treeState } />);
};

🌀 custom initialization

Initial tree state is an object that describes a nested tree node structure, which looks like:

{
  // reserved keys, can customize initial value
  name: 'root node',  
  checked (optional): 0 (unchecked, default) | 0.5 (half checked) | 1(checked),
  isOpen (optional): true (default) | false,
  children (optional): [array of treenode],

  // internal key (auto generated), plz don't include it in the initial data
  _id: 0,

  // all other keys are not reserved, can carry any extra info about this node
  nickname (optional): 'pikachu',
  url (optional): 'www.pokemon.com',
  ...
}

checked and isOpen status could be auto initialized by props initCheckedStatus and initOpenStatus. We can also provide data with custom checked and isOpen status, and set initCheckedStatus and initOpenStatus to 'custom'.

Example:

const { treeState } = useTreeState({
  data: testData,
  options: {
    initCheckedStatus: 'checked',   // 'unchecked' (default) | 'checked' | 'custom'
    initOpenStatus: 'open',         // 'open' (default) | 'closed' | 'custom'
  }
});

🌀 update tree state

There are a couple built in tree state reducers that can update tree state conveniently.

Note that these reducers are slightly different than redux reducers. These are more like wrapped reducers which are functions that

f(path: array<int>, ...args) => update state internally
or
fByProp(propName: string, targetValue: any, ...args) => update state internally

For more details please refer to Built-in Reducers section.

const TreeApp = () => {
  const { treeState, reducers } = useTreeState({ data: testData });
  const {
    // update state using node's path to find target
    checkNode,
    toggleOpen,
    renameNode,
    deleteNode,
    addNode,

    // update state using any node's property to find target
    checkNodeByProp,
    toggleOpenByProp,
    renameNodeByProp,
    deleteNodeByProp,
    addNodeByProp,
  } = reducers;

  const check_first_node = () => checkNode([0]);
  const check_node_whos_name_is_Goku = () => checkNodeByProp('name', 'Goku');

  const open_first_node = () => toggleOpen([0], 1);
  const open_node_whos_url_is_www = () => toggleOpenByProp('url', 'www', 1);
  const close_node_whos_num_is_123 = () => toggleOpenByProp('num', 123, 0);

  const rename_third_node_to_pikachu = () => renameNode([2], 'pikachu');
  const rename_snorlax_node_to_pikachu = () => renameNode('name', 'snorlax', 'pikachu');

  const remove_fourth_node = () => deleteNode([3]);
  const remove_unnecessary_node = () => deleteNodeByProp('necessary', false);

  const add_leaf_node_in_root_node = () => addNode([], false);
  const add_parent_node_in_Pokemon_node = () => addNodeByProp('type', 'Pokemon', true);

  return (...);
};

🌀 onChange listener

we can pass in an onChange(newState: tree-state-obj, event: obj) to the hook to listen for state change event.

const handleStateChange = (newState, event) => {
  const { type, path, params } = event;

  console.log('last event: ', { type, path, params });
  console.log('state changed to: ', newState);
};

const { treeState } = useTreeState({
  data: testData,
  onChange: handleStateChange,      // <== here!!
});

Built-in Reducers

There are two types of built in reducers (or call it handlers if you prefer) that differ in how they find target node to operate on.

1) find target node by path

  • reducers.checkNode
  • reducers.toggleOpen
  • reducers.renameNode
  • reducers.deleteNode
  • reducers.addNode

their format is f(path: array<int>, ...args) => update state internally, where path is an array of indexes from root to the target node.

An example that shows each node and corresponding path

const treeState = {
  name: 'root',         // path = []
  children: [
    { name: 'node_0' }    // path = [0]
    { name: 'node_1' }    // path = [1]
    {
      name: 'node_2',     // path = [2]
      children: [
        { name: 'node_2_0' },   // path = [2, 0]
        { name: 'node_2_1' },   // path = [2, 1]
      ],
    }
  ],
};

2) find target node by property (can be any property in tree node data)

  • reducers.checkNodeByProp
  • reducers.toggleOpenByProp
  • reducers.renameNodeByProp
  • reducers.deleteNodeByProp
  • reducers.addNodeByProp

their format is fByProp(propName: string, targetValue: any, ...args) => update state internally

🌀 reducers details

checkNode(path: array<int>, checked: 1 | 0)

checkNodeByProp(propName: string, targetValue: any, checked: 1 | 0)

Set checked property of the target node, 1 for 'checked', 0 for 'unchecked'.

It will also update checked status for all other nodes:

  • if we (un)checked a parent node, all children nodes will also be (un)checked
  • if some (but not all) of a node's children are checked, this node becomes half check (internally set checked = 0.5)

toggleOpen(path: array<int>, isOpen: bool)

toggleOpenByProp(propName: string, targetValue: any, isOpen: bool)

Set the open status isOpen for the target node. isOpen: false usually means in UI we shouldn't see it's children.

This only works for parent nodes, which are the nodes that has children property.


renameNode(path: array<int>, newName: string)

renameNodeByProp(propName: string, targetValue: any, newName: string)

You know what it is.


deleteNode(path: array<int>)

deleteNodeByProp(propName: string, targetValue: any)

Delete the target node. If target node is a parent, all of it's children will also be removed.


addNode(path: array<int>, hasChildren: bool)

addNodeByProp(propName: string, targetValue: any, hasChildren: bool)

Add a node as a children of target node. hasChildren: true means this new node is a parent node, otherwise it is a leaf node.

This only works for parent nodes.


setTreeState(newState: tree-state-object)

Instead of 'update' the tree state, this will set whole tree state directly. Didn't test this method, but leave this api anyways, so use with cautions! And plz open an issue if it doesn't work : )

Custom Reducers

There are two ways to build custom state transition functions. We provide an util to help find the target node: findTargetNode(root: tree-state-obj, path: array<int>) .

🌀 method 1: wrap custom reducers (recommended)

We can build any custom reducers of format

myReducer(root: tree-state-obj, path: array<int> | null, ...params): tree-state-obj

and pass it to the hook constructor. Hook will then expose a wrapped version of it, and we can use it like

reducers.myReducer(path: array<int> | null, ...params)

to update the treeState.

import useTreeState, {
  testData,
  findTargetNode,
} from 'use-tree-state';

// this app demos how to build a custom reducer that rename a node to 'pikachu'
const TreeApp = () => {
  // our custom reducer
  const renameToPikachuNTimes = (root, path, n) => {
    const targetNode = findTargetNode(root, path);
    targetNode.name = 'pika'.repeat(n);

    return { ...root };
  };

  const { treeState, reducers } = useTreeState({
    data: testData,
    customReducers: {
      renameToPikachuNTimes,  // pass in and hook will wrap it
    },
  });

  const renameFirstNodeToPikaPikaPika = () => {
    // use the wrapped custom reducer
    reducers.renameToPikachuNTimes([0], 3);
  }

  return (<>
    <button onClick={ renameFirstNodeToPikaPikaPika }>
      pika pika
    </button>

    <Tree state={ treeState } />
  </>);

};

🌀 method 2: set tree state from outside

const TreeApp = () => {
  const { treeState, reducers } = useTreeState({ data: testData });
  const { setTreeState } = reducers;

  // our custom reducer to set tree state directly
  const renameToPikachuNTimes = (root, path, n) => {
    // treeState is a ref to the internal state, plz don't alter it directly
    const newState = deepClone(root); 

    const targetNode = findTargetNode(newState, path);
    targetNode.name = 'pika'.repeat(n);

    setTreeState(newState);
  };

  const renameFirstNodeToPikaPikaPika = () => {
    renameToPikachuNTimes(treeState, [0], 3);
  }

  return (<>
    <button onClick={ renameFirstNodeToPikaPikaPika }>
      pika pika
    </button>

    <Tree state={ treeState } />
  </>);
};

🌀 find node by any node property

⚡️live exmaple

Other than the built-in reducers that CRUD by prop, we can build more general reducers that do anything by prop, with the help of these two adapters:

  • findTargetPathByProp(root: tree-state-obj, propName: string, targetValue: string): array<int>
  • findAllTargetPathByProp(root: tree-state-obj, propName: string, targetValue: string): array<array<int>>

For example, let's rewrite renameNodeByProp in a more custom way

import { findTargetPathByProp } from 'use-tree-state';

// our custom reducer, note that we omit the `path` param as _ since we don't need it
const renameNodeByTargetName = (root, _, targetName, newName) => {
  // only need this one extra line to find path first
  // if 'name' is not unique, we can find all nodes by `findAllTargetPathByProp`
  const path = findTargetPathByProp(root, 'name', targetName);    // <== here!!!

  // then everything else is just the same
  const targetNode = findTargetNode(root, path);
  targetNode.name = newName;

  return { ...root };
};

// ......

// then we can use it like
reducers.renameNodeByTargetName(null, 'snorlax', 'pikachu');

Side Notes We chose to use path to find target node as the primary interface because:

  • path is always unique
  • this is the fastest way to find a target node
  • we can dynamically general path in <Tree /> component, which perfectly matches such interface (example)

Bugs? Questions? Contributions?

Feel free to open an issue, or create a pull request!

use-tree-state's People

Contributors

shunjizhan avatar

Stargazers

 avatar  avatar  avatar  avatar

Watchers

 avatar

use-tree-state's Issues

expose a `reinitialize()` API

currently there is no way to reinitialize tree state

const { reinitialize } = useTreeState();
reinitialize({ data, options, customreducers })

memoize reducers

should wrap reducers in useCallback, to avoid unnecessary rerender

extract errors

define custom errors to be more consistent

  • FindNodeError
  • ToggleOpenError
  • AddNodeError

provide CRUD by key

this will be more convenient for users

reducers.renameNodeByProp('name', 'snorlax', 'pikachu')
reducers.deleteNodeByProp('url', '', 'root/children1')
...

Open one child per node at a time

It would be nice to see a configuration option that allows you to have only one child node open for any parent node at one time.

  • single node
  • parent node1 (open)
    • child node1 (closed)
    • child node2 (open)
    • child node3 (close) - If we open this, child node2 closes automatically
  • parent node2 (closed)
  • parent node3 (closed) - if we open this, parent node1 closes automatically (and all its child nodes)

helper to find path by prop name

now all the reducers takes a path param, but might be useful to be able to do renameNode(oldname: string, newName:string)

internally we can do

renameNodeByName = (name, newName) => {
  const path = findPathByProp('name', name);
  renameNode(path, newName)
}

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.