SortableJS / react-sortablejs

React bindings for SortableJS
http://sortablejs.github.io/react-sortablejs/
MIT License
2.04k stars 210 forks source link

Where can I read the source code of the examples? Also when can nesting be supporte #224

Open Rihel opened 2 years ago

Rihel commented 2 years ago

With the mobx library, the list data is all responsive, so nesting will be much easier, making it similar to Vuedraggable?

madeinspace commented 2 years ago

https://codesandbox.io/s/react-sortablejs-examples-pejkn2

julienben commented 2 years ago

I didn't fully understand the issue with nesting. I've been using nesting for the feature I'm building and it works just fine, I can drag from anywhere to anywhere.

ayushm-agrawal commented 2 years ago

@julienben, could you please put out a code sandbox example for nested DND with a parent-child relationship between items?

julienben commented 2 years ago

https://codesandbox.io/s/react-sortablejs-nested-demo-nouiv3

Very simplified version of my tool. You have groups and items. You can nest infinitely in any way you want (except items, which do not accept children, but you can make it all groups if you need).

andresin87 commented 2 years ago

can i close this issue tough?

julienben commented 2 years ago

@andresin87 I'm guessing you can close but I was wondering if you had any thoughts on my nesting example above? There's only one "setState" function, it uses DFS to know which nesting to update.

danieliser commented 2 years ago

Nesting does work fine, but all lists must basically use the same setState as @julienben pointed out.

Another way that I've recently implemented ( found with a bunch of random google searching ), was passing both a top level setList method as well as an indexs array down to each child list, which contains that lists index in the parent list.

This allows easily manipulating the list state by using indexs for traversal.

The child list setList looks something like this:

        setList={currentList => {
          setList(rootList=> {
            const newRootList= [...rootList];
            const _indexs = [...indexs];
            const currentListIndex = _indexs.pop();

            // Get ref to currentLst inside rootList.
            const currentListRef= _indexs.reduce(
              (arr, i) => arr[i]["children"],
              tempList
            );

            // Replace with new updated list.
            currentListRef[currentListIndex ]["children"] = currentList;

            return newRootList;
          });
        }}

lastArr in this case is a reference to the curennt list inside the nested structure, setting its value there updates it inside the root list.

No extra packages needed, just a clever indexs based list traversal using references.

Full examples here:

julienben commented 2 years ago

One issue I'm facing is that it stops working with React 18. Not urgent since most other deps still need to catch up to it but it'd be a shame to be stuck to React 17 because of it.

Heilemann commented 1 year ago

@danieliser Trying this approach, but both the parent and the child fire setList (first parent with original list, then child with updated, the parent with its updated but not including the child's updates), so I end up missing e.g. a child moved into a folder.

The parent shouldn't really need to update, but not sure how to best stop it.

danieliser commented 1 year ago

@julienben - We recently were forced to React 18 (product based on WordPress which upgraded to 18 recently), and I was pleasantly surprised our code still built & ran fine. None of the sortable/draggable stuff broke, and the nesting works fine still.

danieliser commented 1 year ago

@Heilemann - Happy to try and work it out, it was a rather confusing set of code which I heavily documented to not forget what exactly is occurring.

Do you have a sandbox?

Heilemann commented 1 year ago

@danieliser Thank you, but I moved on to @dnd-kit. It's probably overall better suited for my needs anyway. I spent two weeks ramming my head against nested support with this library, and just couldn't ever get it to not be wonky ¯_(ツ)_/¯

danieliser commented 1 year ago

@Heilemann no worries. The biggest headache I had was when I tried to be clever and prevent redrawing entire list on every update. If you don't call the parent setlist which triggers a full redraw, then no changes get saved.

That said we were looking at dnd-kit as well, but previously we weren't looking to move to react-18 until our deps themselves moved up.

I also ended up rewriting it all using Context and hooks so that each parent creates a new context that children live in, but that really just means no passing props, the nesting setList concept still applied as each childContext calls parentContext inside it.

danieliser commented 1 year ago

Some updates on our implementation, which I just simplified greatly using contexts while also being more reliable.

I'm posting this here as a full working solution, albeit not the most out of the box simple solution, but it is working great, even on React 18. This should dispell the notes in the readme that it isn't possible. .Happy to boil this into a codesandbox without our extra flavor if its really needed.

This results in infinite nesting while minimizing added setState calls to the bare minimum with no recursion penalty.

A deeply nested item can modify itself without affecting its parents. which is extremely important to performance and reliability.

  1. Our setup uses a <List items={[]} onChange={...} indexs={ [0, 1] } /> component, as well as a ListContext which includes methods like addItem, removeItem etc.
  2. ListContext includes both a setList and setRootList methods as well as indexs array, isDragging and setIsDragging props handled by useControlledState etc.
  3. ListContext is then nestable, and when its nested it checks for a parent context, and if found passes the parent shared methods (ie setRootList etc) into the child context, if not it generates them as the root list.
  4. useControlledState is used to allow shared dragging status through the entire chain. We use it to add animated css borders to dropzones.
  5. Lastly is the nested setList method which I'll outline on its own below as it is warranted to prevent confusion due to its "magic" methodology which is totally solid JS magic.

The setRootList method and a few other consts you might need for reference.

Of note the setRootList method accepts both new value for entire list, or a callback that takes the existing (unchanged) list as a first argument for modification. Nested setList calls will use the latter, only root will pass value directly.

// Used for parent traversal when nested.
const parentQueryContext = useQuery(); // our custom context.

const isRoot = typeof parentQueryContext.isRoot === 'undefined';
const setRootList = isRoot
    ? ( newList ) => {
            /**
             * The root list can accept a functional state action.
             * Check for that and call it with current items if needed.
             */
            const _newList =
                typeof newList !== 'function'
                    ? newList
                    : // Root list passes in current state to updaters.
                        newList( items );

            // ! Warning, this is a bad idea. It results in changes not saving.
            // ! if ( isEqual( items, _newList ) ) {
            // * Prevents saving state during dragging as a suitable replacement.
            if ( isDragging ) {
                return;
            }

            onChange( {
                ...query,
                items: _newList,
            } );
        }
    : parentQueryContext.setRootList;

And the context value containing the nestable setList method.

Biggest things to note here:

  1. If its not the root list, we pass in a callback to setRootList.
  2. That callback clones the entire tree.
  3. Then using the magic of .reduce on the indexs array and a clone of the full list as the starting variable, we are able to recursively walk our way down to the nested list we want to modify, returning a reference to that nested list within the full list.
  4. The reference from step 3 above is the entire magic. Modifying that reference by replacing it with the newList essentially modifies it in place in the cloned full root list.
  5. We then simply return the new modified copy of the root list within the setRootList callback.

So when moving items from one nested list to another, it makes 2 state save calls in general, one for the removal, one for the addition. No calling setState all the way back up the chain. A deeply nested item can modify itself without affecting its parents.

/**
 * Generate a context to be provided to all children consumers of this query.
 */
const queryContext: QueryContextProps = {
    ...parentQueryContext,
    isRoot,
    indexs,
    isDragging,
    setIsDragging,
        items,
    setRootList,
    /**
     * The root setList method calls the onChange method directly.
     * Nested lists will then call setRootList and pass a SetStateFunctional
     * that modifies the rootList based on the current list indexs list.
     */
    setList: ( newList ) => {
        // Don't save state when dragging, will save a lot of calls.
        if ( isDragging ) {
            return;
        }

        // If its the root, check they aren't already equal( optional ), then pass the new list directly to the setRootList method.
        if ( isRoot ) {
            if ( ! isEqual( items, newList ) ) {
                setRootList( newList );
            }
        } else {
            setRootList( ( rootList ) => {
                // Clone root list.
                const newRootList = [ ...rootList ];

                // Clone indexs to current list.
                const parentIndexs = [ ...indexs ];

                // Remove this lists index from cloned indexs.
                const currentListIndex = parentIndexs.pop() ?? 0;

                /**
                 * Get reference to latest array in nested structure.
                 *
                 * This clever function loops over each parent indexs,
                 * starting at the root, returning a reference to the
                 * last & current nested list within the data structure.
                 *
                 * The accumulator start value is the entire root tree.
                 *
                 * Effectively drilling down from root -> current
                 *
                 * Return reference to child list for each parent index.
                 */
                const directParentList = parentIndexs.reduce(
                    ( arr, i ) => {
                        const nextParentGroup = arr[ i ] as GroupItem;
                        return nextParentGroup.query.items;
                    },
                    newRootList
                );

                // Get reference to current list from the parent list reference.
                const closestParentGroup = directParentList[
                    currentListIndex
                ] as GroupItem;

                // Prevent saving state if items are equal.
                if (
                    ! isEqual( closestParentGroup.query.items, newList )
                ) {
                    // Replaced referenced items list with updated list.
                    closestParentGroup.query.items = newList;
                }

                return newRootList;
            } );
        }
    },

The full code is available here, fully typed in TypeScript:

Notes about our usage:

image

And one with the isDragging state, and you will notice only parents of the current item are sutable dropzones. A group can't drop into itself and our solution's CSS handles that nicely.

image