Using Dash Mantine Components dmc.Select as an AG Grid cell editor

dashboard.py

import dash_mantine_components as dmc
from dash import Dash, html, dcc, _dash_renderer
_dash_renderer._set_react_version("18.2.0")
import dash_ag_grid as dag
import pandas as pd

app = Dash(__name__, external_stylesheets=dmc.styles.ALL)

df = pd.DataFrame({
    'Column 1': [1, 2, 3, 4, 5, 6],
    'TagsInput': [['A']] *6
})

columnDefs = [
    { "field": "Column 1"},
    {
        "field": "TagsInput",
        'cellEditor': {'function': 'AllFunctionalComponentEditors'},
        'cellEditorParams': {'component': dmc.TagsInput(data=["A", "B", "C"])},
        'cellEditorPopup': True,
    }
]

grid = html.Div([
    html.H1('Grid Page'),
    dag.AgGrid(
        id='grid_id',
        columnDefs=columnDefs,
        columnSize="autoSize",
        rowData=df.to_dict('records'),
        defaultColDef={'editable': True},
        className="ag-theme-alpine-dark",
    ),
])

app.layout = dmc.MantineProvider(
    grid,
    forceColorScheme="dark"
    )

@app.callback(
    Output("grid_id", "columnDefs"),
    [
        Input("grid_id", "cellValueChanged"),
        State("grid_id", "rowData"),
        State("grid_id", "columnDefs"),
    ],
)
def update_column_defs(_, data, columnDefs):
    df = pd.DataFrame(data)

    # Check for new user input and add to original options
    new_options = set(
        [x for list in df["label"].values for x in list if x != ""] + ["A", "B", "C"]
    )

    columnDefs_copy = columnDefs.copy()

    columnDefs[1] = {
        "field": "TagsInput",
        "cellEditor": {"function": "AllFunctionalComponentEditors"},
        "cellEditorParams": {"component": dmc.TagsInput(data=new_options)},
        "cellEditorPopup": True,
    }

    return columnDefs

if __name__ == "__main__":
    app.run(debug=True)

I think I solved the problem already, I was able to replace “dmc.TagsInput” by a dictionary, now my columnDefs definition is:

columnDefs = [
    { "field": "Column 1"},
    {
        "field": "TagsInput",
        'cellEditor': {'function': 'AllFunctionalComponentEditors'},
        "cellEditorParams": {
            "component": {
                "props": {
                    "data": ["A", "B", "C"],
                },
                "type": "TagsInput",
                "namespace": "dash_mantine_components",
            }
        },
        'cellEditorPopup': True,
    },
]
1 Like

I’ve created a DMC select dropdown that I think works better, by accepting both labels and values as options:


// Function to convert values back to labels for display
dagfuncs.getDisplayLabel = function (params) {
  // If no value, return empty string
  if (
    params.value === undefined ||
    params.value === null ||
    params.value === ""
  ) {
    return "";
  }

  // Get options from cell editor params
  const options = params.colDef.cellEditorParams.options;
  if (!options || !Array.isArray(options)) {
    return params.value;
  }

  // Find the option with matching value and return its label
  const option = options.find((opt) => opt.value === params.value);
  return option ? option.label : params.value;
};

dagfuncs.SelectDMC = class {
  // gets called once before the renderer is used
  init(params) {
    // store the params
    this.params = params;

    // Store the key-value pairs for the options as label: value objects
    this.options = {};
    params.options.forEach((option) => {
      this.options[option.label] = option.value;
    });

    // Flag to prevent multiple focus cycles
    this.isActivating = false;

    // Function for when Dash component sends props back to the component / server
    const setProps = (props) => {
      console.log("Mantine setProps called with:", props);
      // if (typeof props.value != typeof undefined) {
      if (props.searchValue !== undefined) {
        // When searchValue changes (user selects a label), find the corresponding value
        const selectedValue = this.options[props.searchValue];
        console.log(
          `Selected "${props.searchValue}" with value "${selectedValue}"`
        );

        // Update the value
        this.value = selectedValue;

        // re-enables keyboard event
        delete params.colDef.suppressKeyboardEvent;

        // tells the grid to stop editing the cell
        params.api.stopEditing();

        // Return focus to grid
        if (this.prevFocus) {
          this.prevFocus.focus();
        }
      }
    };

    // create an element for the editor
    this.eInput = document.createElement("div");

    // create the root for rendering the React component
    this.root = ReactDOM.createRoot(this.eInput);

    // Get MantineProvider and Select from window
    const MantineProvider = window.dash_mantine_components.MantineProvider;
    const Select = window.dash_mantine_components.Select;

    // Get global theme, styles, and other settings if available
    const globalMantineConfig = {
      theme: window.dash_mantine_components.mantineTheme || {},
      styles: window.dash_mantine_components.mantineStyles || {},
      colorScheme: window.dash_mantine_components.mantineColorScheme || "light",
      emotionCache: window.dash_mantine_components.mantineEmotionCache || null,
      withGlobalStyles: true,
      withNormalizeCSS: true,
    };

    // Find the label that corresponds to the current value
    const currentLabel =
      Object.keys(this.options).find(
        (label) => this.options[label] === params.value
      ) || "";

    console.log(
      `Initializing with value: ${params.value}, label: ${currentLabel}`
    );

    // Render the Select component wrapped in the MantineProvider
    this.root.render(
      React.createElement(
        MantineProvider,
        globalMantineConfig,
        React.createElement(Select, {
          data: params.options,
          value: currentLabel, // Use the label, not the value
          searchValue: currentLabel,
          setProps,
          style: {
            position: "fixed",
            width: params.column.actualWidth - 2,
            zIndex: 1000, // Ensure dropdown appears above other elements
            ...params.style,
          },
          allowDeselect: params.allowDeselect,
          checkIconPosition: params.checkIconPosition,
          className: params.className,
          classNames: params.classNames,
          clearButtonProps: params.clearButtonProps,
          // clearable: params.clearable,
          clearable: params.clearable !== false,
          comboboxProps: params.comboboxProps,
          darkHidden: params.darkHidden,
          description: params.description,
          descriptionProps: params.descriptionProps,
          disabled: params.disabled,
          dropdownOpened: params.dropdownOpened,
          error: params.error,
          errorProps: params.errorProps,
          hiddenFrom: params.hiddenFrom,
          hiddenInputProps: params.hiddenInputProps,
          inputWrapperOrder: params.inputWrapperOrder,
          label: params.label,
          labelProps: params.labelProps,
          leftSection: params.leftSection,
          leftSectionPointerEvents: params.leftSectionPointerEvents,
          leftSectionProps: params.leftSectionProps,
          leftSectionWidth: params.leftSectionWidth,
          lightHidden: params.lightHidden,
          limit: params.limit,
          loading_state: params.loading_state,
          maxDropdownHeight: params.maxDropdownHeight || 280,
          mod: params.mod,
          name: params.name,
          nothingFoundMessage: params.nothingFoundMessage,
          persisted_props: params.persisted_props,
          persistence: params.persistence,
          persistence_type: params.persistence_type,
          placeholder: params.placeholder || "Select...",
          pointer: params.pointer,
          radius: params.radius,
          readOnly: params.readOnly,
          required: params.required,
          rightSection: params.rightSection,
          rightSectionPointerEvents: params.rightSectionPointerEvents,
          rightSectionProps: params.rightSectionProps,
          rightSectionWidth: params.rightSectionWidth,
          scrollAreaProps: params.scrollAreaProps,
          searchValue: params.searchValue,
          searchable: params.searchable,
          selectFirstOptionOnChange: params.selectFirstOptionOnChange,
          size: params.size,
          styles: params.styles,
          tabIndex: params.tabIndex,
          variant: params.variant,
          visibleFrom: params.visibleFrom,
          withAsterisk: params.withAsterisk,
          withCheckIcon: params.withCheckIcon,
          withErrorStyles: params.withErrorStyles,
          withScrollArea: params.withScrollArea,
          wrapperProps: params.wrapperProps,
        })
      )
    );

    // allow focus event
    this.eInput.tabIndex = "0";

    // set editor value to the value from the cell
    this.value = params.value;
  }

  // gets called once when grid is ready to insert the element
  getGui() {
    return this.eInput;
  }

  focusChild() {
    console.log("focusChild called");

    // Prevent multiple activations
    if (this.isActivating) return;
    this.isActivating = true;

    // Use a sequence of timed actions to ensure proper dropdown behavior
    setTimeout(() => {
      if (!this.eInput) {
        console.log("eInput not available");
        this.isActivating = false;
        return;
      }

      // Find the input element
      const input = this.eInput.querySelector(".mantine-Select-input");
      if (!input) {
        console.log("Mantine input not found");
        this.isActivating = false;
        return;
      }

      // Make the input focusable
      input.tabIndex = "1";

      // Disable keyboard events in the grid while editing
      this.params.colDef.suppressKeyboardEvent = (params) => {
        return params.editing;
      };

      // Focus the input
      input.focus();
      console.log("Input focused");

      // Click the input to open the dropdown after a delay
      setTimeout(() => {
        input.click();
        console.log("Input clicked to open dropdown");

        // Reset activation flag after dropdown should be fully visible
        setTimeout(() => {
          this.isActivating = false;
          console.log("Dropdown should now be visible");
        }, 100);
      }, 150);
    }, 100);
  }

  // focus and select can be done after the GUI is attached
  afterGuiAttached() {
    console.log("afterGuiAttached called");

    // Store the currently active element for later focus restoration
    this.prevFocus = document.activeElement;

    // PROPERLY REGISTER the event handler - this is critical!
    this.eInput.addEventListener("focus", this.focusChild);

    // trigger focus event
    this.eInput.focus();

    // Also call the handler directly to ensure dropdown opens
    // without relying solely on the focus event
    this.focusChild();
  }

  // returns the new value after editing
  getValue() {
    console.log(`Returning value: ${this.value}`);
    return this.value;
  }

  // safely unmount the React component
  destroy() {
    console.log("destroy called");

    // Remove event listener to prevent memory leaks
    this.eInput.removeEventListener("focus", this.focusChild);

    // Delay the unmounting to avoid race conditions during render
    setTimeout(() => {
      if (this.root) {
        // unmount the component and clean up
        this.root.unmount();
        this.root = null; // clear reference to avoid further operations
      }

      // set focus back to the grid's previously active cell
      if (this.prevFocus) {
        this.prevFocus.focus();
      }
    }, 0); // ensure unmount happens after rendering is complete
  }
};

here are the column definitions in Python:


    columnDefs = [
        {
            "field": "city",
            "cellEditor": {"function": "SelectDMC"},
            "cellEditorParams": {
                "options": [
                    {"label": "New York City", "value": "NYC"},
                    {"label": "Seattle", "value": "SEA"},
                    {"label": "San Francisco", "value": "SFO", "disabled": True},
                ],
                "placeholder": "Select a City",
                "maxDropdownHeight": 280,
                "searchable": True,
                "clearable": True,
            },
            "valueFormatter": {"function": "getDisplayLabel(params)"},
            "cellEditorPopup": True,
            "singleClickEdit": True,
        },
    ]
1 Like

What happens if the user doesn’t type a full name in the search value?

The search value exists for being able to match and the base component to take over. If you want to use the search value, you can test if there is an exact match. If so, then you can exit.

My guess is that you are encountering console errors with the way you have it currently.

I could be wrong though. Haha.

Okay here’s my final solution, which is working really great now:

dagfuncs.SelectDMC = class {
  // Called when the editor is first created and before it's used
  init(params) {
    // Store the grid parameters for later use
    this.params = params;

    // Flag to prevent multiple activations of the dropdown
    // This prevents race conditions when rapid events occur
    this.isActivating = false;

    // Define how to handle data coming back from the Mantine component
    // This function is called when the user selects an option from the dropdown
    const setProps = (props) => {
      // Only proceed if a value property exists in the props
      if (typeof props.value !== typeof undefined) {
        // Update our internal value with the selected option
        this.value = props.value;

        // Re-enable keyboard events in the grid that were disabled during editing
        delete params.colDef.suppressKeyboardEvent;

        // Tell the grid to exit edit mode for this cell
        params.api.stopEditing();

        // Return focus to the previously active element (usually the grid cell)
        if (this.prevFocus) this.prevFocus.focus();
      }
    };

    // Create a DOM element to host our React component
    this.eInput = document.createElement("div");

    // Create a React root for rendering into our DOM element
    this.root = ReactDOM.createRoot(this.eInput);

    // Get Mantine components from the global window object
    // These are provided by the dash_mantine_components package
    const MantineProvider = window.dash_mantine_components.MantineProvider;
    const Select = window.dash_mantine_components.Select;

    // Get global Mantine theme configuration if it exists
    // This ensures our dropdown matches the rest of the application's styling
    const globalMantineConfig = {
      theme: window.dash_mantine_components.mantineTheme || {},
      styles: window.dash_mantine_components.mantineStyles || {},
      colorScheme: window.dash_mantine_components.mantineColorScheme || "light",
      emotionCache: window.dash_mantine_components.mantineEmotionCache || null,
      withGlobalStyles: true,
      withNormalizeCSS: true,
    };

    // Log initialization value for debugging
    console.log(`Initializing with value: ${params.value}`);

    // Render the Mantine Select component inside a MantineProvider
    this.root.render(
      React.createElement(
        MantineProvider,
        globalMantineConfig,
        React.createElement(Select, {
          // Data options for the dropdown
          data: params.options,
          value: params.value,
          setProps,

          // Basic styling - using the grid column's width
          style: {
            width: params.column.actualWidth - 2, // Slight adjustment to fit cell
            zIndex: 1000, // Ensure dropdown appears above other elements
          },

          // *** CRITICAL DROPDOWN POSITIONING SETTINGS ***
          // Keep the dropdown in the natural DOM flow instead of using a portal
          // This is key to fixing the positioning issue
          withinPortal: false,

          // Position dropdown below the input and aligned with left edge
          dropdownPosition: "bottom-start",

          // Visual and functional settings
          shadow: "md", // Medium shadow for visual distinction
          clearable: params.clearable !== false, // Allow clearing selection
          searchable: true, // Enable searching within options
          placeholder: params.placeholder || "Select...", // Default placeholder
          maxDropdownHeight: params.maxDropdownHeight || 280, // Control dropdown height
          size: params.size, // Use size parameter if provided

          // Advanced positioning settings for the combobox
          comboboxProps: {
            position: "bottom-start", // Consistent positioning
            middleware: {
              shift: true, // Allow horizontal shifting to keep in viewport
              flip: true, // Allow flipping to above input if no room below
            },
            withinPortal: false, // Consistent with parent setting
          },
        })
      )
    );

    // Make our container element focusable
    this.eInput.tabIndex = "0";

    // Initialize internal value to match the cell's value
    this.value = params.value;
  }

  // Return the DOM element that will be inserted into the grid
  getGui() {
    return this.eInput;
  }

  // Handle focusing and activating the dropdown
  focusChild() {
    // Prevent multiple simultaneous activation attempts
    if (this.isActivating) return;
    this.isActivating = true;

    // Delay to ensure the component has fully rendered
    setTimeout(() => {
      // Safety check - ensure our element still exists
      if (!this.eInput) {
        this.isActivating = false;
        return;
      }

      // Find the input element using multiple possible selectors for compatibility
      const input =
        this.eInput.querySelector(".mantine-Select-input") ||
        this.eInput.querySelector("input");

      // If we can't find the input, abort the operation
      if (!input) {
        this.isActivating = false;
        return;
      }

      // Make the input focusable with tab navigation
      input.tabIndex = "1";

      // Disable grid keyboard events while editing to prevent conflicts
      this.params.colDef.suppressKeyboardEvent = (params) => {
        return params.editing;
      };

      // Focus the input element to prepare for dropdown activation
      input.focus();

      // Delay slightly to ensure focus is complete before clicking
      setTimeout(() => {
        // First try to find and click the dropdown indicator button
        // This is more reliable than clicking the input itself
        const dropdownButton = this.eInput.querySelector(
          ".mantine-Select-rightSection"
        );
        if (dropdownButton) {
          // If found, click the dropdown button to open options
          dropdownButton.click();
        } else {
          // Fallback: click the input element itself
          input.click();
        }

        // Reset activation flag after all attempts are complete
        setTimeout(() => {
          this.isActivating = false;
        }, 100);
      }, 150);
    }, 100);
  }

  // Called by AG Grid after the editor is attached to the DOM
  afterGuiAttached() {
    // Store reference to currently focused element for later restoration
    this.prevFocus = document.activeElement;

    // Create a properly bound version of focusChild that preserves 'this' context
    // This is critical to ensure the method has access to class properties
    const boundFocusChild = this.focusChild.bind(this);

    // Attach the event listener to our element
    this.eInput.addEventListener("focus", boundFocusChild);

    // Store reference to bound function for proper cleanup later
    this._boundFocusChild = boundFocusChild;

    // Trigger focus on our container element
    this.eInput.focus();

    // Explicitly call focusChild to ensure dropdown activates
    // This ensures dropdown opens even if focus event doesn't trigger properly
    this.focusChild();
  }

  // Return the current value to AG Grid when editing is complete
  getValue() {
    return this.value;
  }

  // Clean up resources when the editor is removed
  destroy() {
    // Remove event listeners to prevent memory leaks
    // Using the stored bound function reference ensures proper removal
    if (this._boundFocusChild) {
      this.eInput.removeEventListener("focus", this._boundFocusChild);
    }

    // Use setTimeout to ensure clean unmounting after current execution
    setTimeout(() => {
      // Properly unmount the React component
      if (this.root) {
        this.root.unmount();
        this.root = null; // Clear reference to prevent memory leaks
      }

      // Restore focus to previously active element
      if (this.prevFocus) {
        this.prevFocus.focus();
      }
    }, 0); // Zero timeout ensures it happens after current execution cycle
  }
};

And a delete button for the row:


// Delete button for AG Grid
// https://community.plotly.com/t/deleting-rows-in-dash-ag-grid/78700
dagcomponentfuncs.DeleteButton = function (props) {
  function onClick() {
    props.api.applyTransaction({ remove: [props.node.data] });
  }

  // Red color? "#ff0000"
  colorWanted = props.colorWanted || "";
  
  return React.createElement(
    "span", // Using span instead of button for less default styling
    {
      onClick,
      style: {
        cursor: "pointer", // Show pointer cursor on hover
        color: colorWanted, 
        fontSize: "16px", // Larger font size
        fontWeight: "bold", // Make it bold
        display: "flex", // Center content
        justifyContent: "center", // Center horizontally
        alignItems: "center", // Center vertically
        width: "100%", // Take full width of the cell
        height: "100%", // Take full height of the cell
        transition: "color 0.2s", // Smooth color transition on hover
      },
      onMouseOver: (e) => (e.currentTarget.style.color = "#cc0000"), // Darker red on hover
      onMouseOut: (e) => (e.currentTarget.style.color = colorWanted), // Restore original color
      title: "Delete row", // Tooltip on hover
    },
    "×" // Using the multiplication symbol which looks nicer than "X"
  );
};

and the function to allow the dropdown to extend beyond the grid, in case the grid table area is small:

dagfuncs.setBody = () => {
  return document.querySelector("body");
};
1 Like