Nested list/checklist where clicking parents toggles their children

I wanted to build a sidebar for a dash app that took a nested lists and made them into a big checklist. Something like this:

nested_lists_with_top_level_controls

I’m pretty new to dash and didn’t see how I could assemble the standard checklist, etc., to do this, so ended up building my own where:

  • The list is a html.Ul of nested html.Li and html.Ul elements. Each row is a html.Li, and any row with nested children has an html.Ul that contains the children
  • I maintain the state of the list using the html elements of the list and modifying the className of Li’s accordingly. Any Li with “checked” in className is checked and shown as green, the rest are not
  • A callback listens for clicks on any html.Li element using a dynamic callback. On a click it accepts State(top_level_sidebar_Ul, “children”), which is used to save the state of the sidebar, and operates on that structure. It figures out what was clicked, whether that item is a mid-tier heading or a leaf (node without children), finds any leaves under this heading if needed, decides whether the applicable Li objects should be checked/unchecked, and does so. Output goes back to the top_level_sidebar_Ul.children attribute

This all works, but it was a bigger task than I expected (150-200 lines of ok code after comments and data removed, with lots of logic to traverse the children structure used to hold state). I’m curious how others with more experience would tackle this or whether there’s something built-in that would have done the job.

Thanks!

----- Code, if anyone is interested:

import json
import operator
from functools import reduce
from typing import Union

import dash
import dash_html_components as html
from dash.dependencies import Input, Output, ALL, State


app = dash.Dash(__name__)
app.config['suppress_callback_exceptions'] = True

fake_data = {
    "Top Level": [
        "Item 1",
        "Item 2",
        "Item 3",
    ],
    "Item 2": [
        "Item 2-1",
        "Item 2-2",
    ],
    "Item 3": [
        "Item 3-1",
        "Item 3-2",
    ],
    "Item 1": [
        "Item 1-1",
        "Item 1-2",
    ],
    "Item 1-1": [
        "Item 1-1-1",
    ],
    "Item 1-2": [
        "Item 1-2-1",
        "Item 1-2-2",
        "Item 1-2-3",
        "Item 1-2-4",
    ],
}


def set_className_entry(original_className, className_to_toggle, return_has_className=True):
    """
    Sets/unsets a className within a space separated list of classNames

    If return_has_className=True, the returned className will include exactly one of className_to_toggle.
    If return_has_className=False, the returned className will include exactly none of className_to_toggle.

    Returns:
        (str): Updated className
    """
    name_list = original_className.strip().split()

    # Remove all of className in question
    name_list = [x for x in name_list if x != className_to_toggle]

    if return_has_className:
        name_list.append(className_to_toggle)

    return " ".join(name_list)


def get_className_state(full_className, className_to_check):
    name_list = full_className.strip().split()
    return className_to_check in name_list


def generate_checklist_li_id(id):
    return {"type": "list_item",
            "id": id,
            }


def generate_checklist_ul_id(id):
    return {"type": "unordered_list",
            "id": id,
            }


def get_path_to_id_in_serialized_ul(ul: dict, obj_id: str, _upstream_path=None):
    """
    Depth first search of a dict of Plotly objects with children, returning the path to the first obj with the id obj_id

    Args:
        ul (dict):
        obj_id:  Any valid Plotly object id (str, dict)
        _upstream_path: Used for recursion.  Typically should not be defined externally

    Returns:
        (tuple): a tuple defining all steps taken in the ul children dict to get to the item, eg:
                    (index_lvl_1, key_lvl_2, ...)
    """
    _upstream_path = tuple() if not _upstream_path else _upstream_path

    for i, child in enumerate(ul):
        # Iterate on objects in children list.  These will have {'props', 'type'}, with 'props' having 'id' and
        # 'children'
        this_upstream_path = _upstream_path + (i,)
        if child['props']['id'] == obj_id:
            return this_upstream_path
        else:
            # If there are children, go deeper
            if isinstance(child['props']['children'], (tuple, list)):
                returned_from_child = get_path_to_id_in_serialized_ul(child['props']['children'],
                                                                      obj_id,
                                                                      _upstream_path=this_upstream_path + ('props',
                                                                                                           'children'))
                if returned_from_child:
                    return returned_from_child

    # Nothing found, return None
    return None


def get_by_path(obj, path):
    return reduce(operator.getitem, path, obj)


def get_leaves_below_sidebar_obj(ul_children: dict, path_to_obj: Union[str, tuple, list]):
    """
    Get all leaf nodes (Li objects without corresponding Ul) from a dict defining the the children of a Ul

    Args:
        ul_children: Children attribute of a Ul object, defined as a dictionary.  Same format as Dash passes to a
                     callback watching the "children" attribute of a Ul.
        path_to_obj: A tuple of keys and indices defining where to start within ul_children when looking for children.
                     For example:
                        (index_lvl_1, key_lvl_2, ...)
                     The same as returned by get_path_to_id_in_serialized_ul()

    Returns:
        (list): List of references to the leaves found in ul_children (if mutated in place, they will change the
                ul_children instance)
    """
    # Traverse the children of this object, returning and child Li objs that have no paired Ul or any leaves of nested
    # Uls
    if path_to_obj:
        if not isinstance(path_to_obj, (tuple, list)):
            path_to_obj = (path_to_obj,)

        start_obj = get_by_path(ul_children, path_to_obj)
    else:
        start_obj = ul_children

    to_return = []

    # Temp variables to determine which items are leaves
    lis = {}
    uls_ids = set()

    # start_obj may be pointed at a dict defining a dash component with format:
    #   {
    #       'namespace': ...,
    #       'type': ...,
    #       'props': {
    #           'children': ...,
    #       }
    #   }
    # Where we want to inspect the 'children' to see if there are any Li's without Ul's.  Or, we might be pointed
    # directly at 'children' (such as when getting input directly from a dash callback input).  Figure out which
    # situation we're in
    if 'props' in start_obj:
        path_to_obj = path_to_obj + ('props', 'children')
        start_obj = start_obj['props']['children']

    # If we have children, recurse.  Else, return this
    if isinstance(start_obj, (tuple, list)):
        for i, child in enumerate(start_obj):
            child_type = child['type']
            child_idname = child['props']['id']['id']
            if child_type == 'Li':
                lis[child_idname] = child
            elif child_type == 'Ul':
                uls_ids.add(child_idname)
                to_return.extend(get_leaves_below_sidebar_obj(ul_children, path_to_obj + (i,)))
    else:
        to_return.append(start_obj)

    # Add Lis found that have no paired Uls
    to_add_to_return = [li for li_id, li in lis.items() if li_id not in uls_ids]
    to_return.extend(to_add_to_return)

    return to_return


def make_sidebar_children(data, top_item, inherited_class="", child_class=""):
    """
    Recursively generate a hierarchical list defined by data, starting at top_item, using Ul and Li objects

    Ul and Li objects ids are defined by Dash id dicts so they can be subscribed to by a callback as a group

    For each node, we generate:
        * An Li object with children=item_name
        * (If node is a middle node with additional children) a Ul object with children=[child_nodes, built recursively]

    Args:
        data (dict): Dict of lists of relationships within the nested list.  For example:
                        {
                            "Item-1": ["Item-1-1", "Item-1-2", ...],
                            "Item-2": ["Item-2-1", "Item-2-2", ...],
                            "Item-1-1": ["Item-1-1-1", "Item-1-1-2", ...],
                            ...
                        }
                     Note that this does not handle repeated names (eg: Item-1-1 cannot have the same name as Item-2)
        top_item (str): The key in data that denotes the head of the hierarchy to generate
        inherited_class (str): HTML class name to apply once to all levels of the list
        child_class (str): HTML class name to apply once per step in the list (so Item 1-1 would have it once,
                           Item 1-1-1 would have it twice, etc.).  Useful for incrementing tab behaviour

    Returns:
        (list): List of html elements for use as the children attribute of a html.Ul
    """
    this_className = f"{inherited_class} {child_class}"
    content = []

    for name in data[top_item]:
        content.append(html.Li(
            children=name,
            id=generate_checklist_li_id(name),
            className=this_className,
        ))

        if name in data:
            nested_children = make_sidebar_children(data, name, inherited_class=this_className, child_class=child_class)
            content.append(html.Ul(
                id=generate_checklist_ul_id(name),
                children=nested_children,
            ))

    return content


def make_sidebar_ul(data, top_item, inherited_class="", child_class=""):
    """
    Returns a sidebar defined using a html.Ul with nested Li and Ul elements

    Optionally can have class names applied recursively to each level of child within the list (eg: for formatting)

    Args:
        See make_sidebar_children

    Returns:
        (html.Ul)
    """
    children = make_sidebar_children(data=data, top_item=top_item, inherited_class=inherited_class, child_class=child_class)

    ul = html.Ul(id="sidebar-ul",
                 children=children,
                 )
    return ul


@app.callback(
    Output("sidebar-ul", "children"),
    [Input({"type": "list_item", "id": ALL}, 'n_clicks'), ],
    [State("sidebar-ul", "children")]
)
def register_sidebar_list_click(n_clicks, ul_children):
    """
    Updates a nested set of Ul and Li objects based on click events.

    Args:
        n_clicks: Unused (defines the trigger event)
        ul_children: The children attribute of a watched Ul that contains a nested list defined by Li and Ul objects,
                     where any Li that is paired with a commonly named Ul is is a heading and any Li without a Ul is a
                     "leaf" node that represents an item that can be checked/unchecked.  Clicking a leaf Li will toggle
                     it's checked status, whereas clicking a heading will toggle the checked status of all
                     leaves below it.

    Returns:
        (dict): A dict of the updated item that can be converted directly to Plotly JSON format. 
    """
    # Skip callback if nothing has been triggered (callback fires at app start which will raise exceptions)
    if dash.callback_context.triggered:
        clicked_li_id = json.loads(dash.callback_context.triggered[0]['prop_id'].split('.')[0])
    else:
        raise dash.exceptions.PreventUpdate()

    # Use the ul_children object as the state for the list.  Work on it directly by grabbing references to its mutable
    # components and modifying to form the returned object.

    # Determine whether the clicked Li is a leaf node or a header node that has children by looking for a Ul with a
    # common id
    paired_ul_id = generate_checklist_ul_id(clicked_li_id['id'])
    paired_ul_path = get_path_to_id_in_serialized_ul(ul_children, paired_ul_id)

    # If found, get children under this.  Else, get the clicked li itself
    if paired_ul_path:
        leaves = get_leaves_below_sidebar_obj(ul_children, paired_ul_path)
    else:
        clicked_li_path = get_path_to_id_in_serialized_ul(ul_children, clicked_li_id)
        leaves = [get_by_path(ul_children, clicked_li_path)]

    # Determine whether to click or unclick all leaves
    # If all leaves are clicked, unclick them.  Otherwise, make them all clicked regardless of current status
    checked_status = get_leaves_checked_status(leaves)
    new_status = not all(checked_status)

    # Apply new status to leaves
    for leaf in leaves:
        leaf['props']['className'] = set_className_entry(leaf['props']['className'], "checked", new_status)

    return ul_children


def get_leaves_checked_status(leaves):
    checked_status = []
    for leaf in leaves:
        this_className = leaf['props']['className']
        checked_status.append(get_className_state(this_className, "checked"))
    return checked_status


def get_leaf_ids(leaves):
    return [leaf['props']['id']['id'] for leaf in leaves]


def get_checked_leaves(leaves):
    checked_status = get_leaves_checked_status(leaves)
    leaf_ids = get_leaf_ids(leaves)
    return [leaf_id for leaf_id, checked in zip(leaf_ids, checked_status) if checked]


@app.callback(
    Output("checked-items-p", "children"),
    [Input("sidebar-ul", "children")],
)
def watch_sidebar_children(ul_children):
    leaves = get_leaves_below_sidebar_obj(ul_children, path_to_obj=tuple())
    checked_leaves = get_checked_leaves(leaves)
    return ", ".join(checked_leaves)


# Define the dash app layout
app.layout = html.Div(
    [
        html.Div(id="sidebar-div",
                 children=make_sidebar_ul(fake_data,
                                          "Top Level",
                                          )
                 ),
        html.Div(id='checked-items',
                 children=[
                     html.H1("Checked Items:"),
                     html.P(id="checked-items-p")
                 ]),
    ]
)

if __name__ == '__main__':
    # Hacky way to auto pick a port
    ports = range(8850, 8860, 1)
    for port in ports:
        try:
            print(f"TRYING PORT {port}")
            app.run_server(debug=True, port=port)
        except OSError:
            continue
        break

1 Like