Skip to content

allow functions as props#580

Merged
AnnMarieW merged 49 commits intosnehilvj:masterfrom
AnnMarieW:functions-as-props
Jun 1, 2025
Merged

allow functions as props#580
AnnMarieW merged 49 commits intosnehilvj:masterfrom
AnnMarieW:functions-as-props

Conversation

@AnnMarieW
Copy link
Copy Markdown
Collaborator

@AnnMarieW AnnMarieW commented May 2, 2025

closes #356
closes #590

Support Custom Functions as Dash Props

This PR introduces support for passing JavaScript functions as props. Functions must be defined in a .js file within the assets folder using the dashMantineFunctions namespace.

How It Works

Props can now accept a function reference using a dictionary of the form:

{"function": "functionName"}

This tells the component to look for window.dashMantineFunctions.functionName in the browser context.

Nested function references are also supported:

{"content": {"function": "functionName"}}

Can pass additional options to the function:

{"function": "labelFormatter", "options": {"suffix": " °F"}},

Inline function strings are not supported.
For example, this will not work:

{"function": "(value) => `${value} °C`"}

Advantages

  • Safer: only executes functions that are defined before the app starts
  • Simple and dependency-free implementation
  • Prevents arbitrary code execution from end users

Example Usage

import dash_mantine_components as dmc
from dash import Dash

app = Dash()

app.layout = dmc.MantineProvider(
    dmc.Slider(value=25, p=100, label={"function": "myLabel"})
)

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

JavaScript (in /assets)

var dmcfuncs = window.dashMantineFunctions = window.dashMantineFunctions || {};

dmcfuncs.myLabel = (value) => `${value} °C`;

this also works when the function returns a React element, using React.createElement syntax (no JSX allowed). See examples below.

See the docs PR for more examples snehilvj/dmc-docs#201

The following props now allow functions:

Component Props
Slider label, scale
RangeSlider label, scale
Select renderOption, filter
MultiSelect renderOption, filter
TagsInput renderOption, filter
DatePickerInput disabledDates
DateInput disabledDates
DateTimePicker disabledDates
MonthPickerInput disabledDates
YearPickerInput disabledDates
BarChart getBarColor, valueFormatter, tooltipProps
AreaChart valueFormatter, tooltipProps
LineChart valueFormatter, tooltipProps
CompositeChart valueFormatter, tooltipProps
BubbleChart valueFormatter, tooltipProps
ScatterChart valueFormatter, tooltipProps

TODO

  • Add tests
  • Add documentation
  • Update changelog
  • Remove assets/dashMantineFunctions.js

@AnnMarieW
Copy link
Copy Markdown
Collaborator Author

Here is an example for the renderOption prop in the Select

import dash_mantine_components as dmc
from dash import Dash
from dash_iconify import DashIconify

app = Dash()

app.layout = dmc.MantineProvider([
    dmc.Select(
      label="Select with renderOption",
      placeholder="Select text align",
      data=[
        { "value": 'left', "label": 'Left' },
        { "value": 'center', "label": 'Center' },
        { "value": 'right', "label": 'Right' },
        { "value": 'justify', "label": 'Justify' },
      ],
      renderOption={"function": "renderSelectOption"}
    )

])

if __name__ == "__main__":
    app.run(debug=True)
var dmcfuncs = window.dashMantineFunctions = window.dashMantineFunctions || {};

const icons = {
    left: React.createElement(window.dash_iconify.DashIconify, { icon: "mdi:format-align-left", width: 24 }),
    center: React.createElement(window.dash_iconify.DashIconify, { icon: "mdi:format-align-center", width: 24 }),
    right: React.createElement(window.dash_iconify.DashIconify, { icon: "mdi:format-align-right", width: 24 }),
    justify: React.createElement(window.dash_iconify.DashIconify, { icon: "mdi:format-align-justify", width: 24 })
};
const checkedIcon = React.createElement(window.dash_iconify.DashIconify, { icon: "mdi:check", width: 24 })

dmcfuncs.renderSelectOption=  function ({option, checked}) {
    return React.createElement(
        window.dash_mantine_components.Group,
        { flex: "1", gap: "xs" },
        icons[option.value],
        option.label,
        checked && checkedIcon
    );
}

image

Repository owner deleted a comment from github-actions Bot May 3, 2025
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 3, 2025

Test Environment for snehilvj/dash-mantine-components-580
Updated on: 2025-05-04 20:17:24 UTC

@AnnMarieW
Copy link
Copy Markdown
Collaborator Author

AnnMarieW commented May 4, 2025

Here is an example of a Select component with a custom filter - allowing the use of a * wildcard

See it live on PyCafe

import dash_mantine_components as dmc
from dash import Dash

app = Dash()

app.layout = dmc.MantineProvider([
    
    dmc.Select(
        data=[
        'JavaScript',
        'TypeScript',
        'Python',
        'Rust',
        'Java',
        'Julia',
        'Shell Script',
        'PowerShell',
        'Go',
        'C++',
        'C#'
      ],
        label="Test Wildcard Filter",
        description="For example p* starts with p.  *script ends with script",
        searchable=True,
        filter={"function": "optionsFilter"}
    )

])

if __name__ == "__main__":
    app.run(debug=True)
var dmcfuncs = window.dashMantineFunctions = window.dashMantineFunctions || {};


const wildcardToRegex = (pattern) => {
  const escaped = pattern.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&');
  const regexPattern = pattern.includes('*')
    ? '^' + escaped.replace(/\*/g, '.*') // wildcard used
    : escaped; // no ^ anchor, match anywhere
  return new RegExp(regexPattern, 'i');
};

dmcfuncs.optionsFilter = function ({ options, search }) {
  const patterns = search.toLowerCase().trim().split(' ').map(wildcardToRegex);

  return options.filter((option) => {
    const label = typeof option === 'string' ? option : option.label;
    const words = label.toLowerCase().trim().split(/\s+/);
    return patterns.every((regex) => words.some((word) => regex.test(word)));
  });
};

@AnnMarieW
Copy link
Copy Markdown
Collaborator Author

AnnMarieW commented May 4, 2025

Here is an example of a nested function, Here a function that creates a custom tooltip is passed to Recharts using the tooltipProps property.

tooltipProps={"content":  {"function": "customChartTooltip"}}
import dash_mantine_components as dmc
from dash import Dash

app = Dash()
data = [
    {"month": "January", "Smartphones": 1200, "Laptops": 900, "Tablets": 200},
    {"month": "February", "Smartphones": 1900, "Laptops": 1200, "Tablets": 400},
    {"month": "March", "Smartphones": 400, "Laptops": 1000, "Tablets": 200},
    {"month": "April", "Smartphones": 1000, "Laptops": 200, "Tablets": 800},
    {"month": "May", "Smartphones": 800, "Laptops": 1400, "Tablets": 1200},
    {"month": "June", "Smartphones": 750, "Laptops": 600, "Tablets": 1000}
]

component = dmc.BarChart(
    h=300,
    dataKey="month",
    data=data,
    series=[
        {"name": "Smartphones", "color": "violet.6"},
        {"name": "Laptops", "color": "blue.6"},
        {"name": "Tablets", "color": "teal.6"}
    ],
    tickLine="y",
    gridAxis="y",
    withXAxis=True,
    withYAxis=True,
    valueFormatter={"function": "chartFormatter"},
    tooltipProps={"content":  {"function": "customChartTooltip"}}
)

app.layout = dmc.MantineProvider(
    component
)

if __name__ == "__main__":
    app.run(debug=True)
    
var dmcfuncs = window.dashMantineFunctions = window.dashMantineFunctions || {};

// bar chart value formatter

dmcfuncs.chartFormatter = (value) => new Intl.NumberFormat('en-US').format(value)


// bar chart custom tooltip

function ChartTooltip({ label, payload }) {
  if (!payload || payload.length === 0) return null;

  const dmc = window.dash_mantine_components;

  return dmc.Paper({
    px: "md",
    py: "sm",
    withBorder: true,
    shadow: "md",
    radius: "md",
    children: [
      dmc.Text({
        fw: 500,
        mb: 5,
        children: label,
      }),
      ...payload.map((item) =>
        dmc.Text({
          key: item.name,
          c: item.color,
          fz: "sm",
          children: `${item.name}: ${item.value}`,
        })
      ),
    ],
  });
}

dmcfuncs.customChartTooltip = ({ label, payload }) => ChartTooltip({ label, payload });

image

@AnnMarieW AnnMarieW mentioned this pull request May 4, 2025
4 tasks
@AnnMarieW
Copy link
Copy Markdown
Collaborator Author

@alexcjohnson This PR is still in draft but I'd love to get your input on what I have so far before applying it to all the components. Do you think it's OK to start with only allowing functions defined in assets?

Comment thread src/ts/utils/prop-functions.ts Outdated
return prop
}

function resolveVariable(prop, context){
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current resolveVariable function is limited, it can only pass the dynamic arguments when the function is called, plus the context:

if(isFunction(variable) && context){
    return (...args) => variable(...args, context)
}

This forces developers to create separate functions for each use case:

// Current approach - duplicate functions for each variant
window.dashMantineFunctions.celsiusFormatter = (value) => `${value}°C`;
window.dashMantineFunctions.fahrenheitFormatter = (value) => `${value}°F`;
window.dashMantineFunctions.kelvinFormatter = (value) => `${value}K`;

// Need separate functions for different currencies too
window.dashMantineFunctions.usdFormatter = (value) => `$${value.toFixed(2)}`;
window.dashMantineFunctions.eurFormatter = (value) => `€${value.toFixed(2)}`;

Modifying the resolveVariable function slightly can help it accept arguments:

if(isFunction(variable) && context){
    // New code - support for args
    if(prop.args) {
        return (...callArgs) => variable(...(Array.isArray(prop.args) ? prop.args : [prop.args]), ...callArgs, context)
    }
    return (...args) => variable(...args, context)
}

Here is an example of how the new way with reusable functions will look like:

// js file in assets folder
// Create one function that works for multiple cases
window.dashMantineFunctions.formatTemperature = (unit, value) => `${value}°${unit}`;
window.dashMantineFunctions.formatCurrency = (currency, value) => {
    const symbols = {"USD": "$", "EUR": "€", "GBP": "£"};
    return `${symbols[currency] || ""}${value.toFixed(2)}`;
};
# Python usage - same function, different args
dmc.Slider(value=25, label={"function": "formatTemperature", "args": "C"})
dmc.Slider(value=25, label={"function": "formatTemperature", "args": "F"})

# Multiple args as array
dmc.NumberInput(
    value=100, 
    formatter={"function": "formatCurrency", "args": "USD"},
    parser={"function": "parseCurrency", "args": ["USD", 2]}
)

Impact of adding function arguments:

  • Eliminate code duplication - Write one function that handles multiple cases instead of duplicating nearly identical functions
  • Centralized Logic - Fix bugs in one place instead of hunting down multiple functions
  • Better Python Readability - {"function": "formatTemp", "args": "C"} clearly shows intent
  • Reduced Maintenance - Update one function instead of 10+ variants when requirements change

would love some thoughts on if this seems feasible!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a cool idea! For extra args like this a very common JavaScript convention is to use an "options" object that comes last in the args list, so you only need one extra argument. Again though I think we can do this in a followup PR after the base functionality is merged.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To @AnnMarieW 's query "would it introduce vulnerabilities?" I think it's safe. It doesn't allow arbitrary code execution, just arbitrary data as arguments.

@omarirfa
Copy link
Copy Markdown

omarirfa commented May 5, 2025

Some other ideas, I have not had a chance to flesh them out, so I am thinking out loud here:

  1. Maybe add some error handling to say if a function is missing/misnamed in resolveVariable and perhaps would it be worthwhile adding a fallback? for example the function errors out since function name is incorrect, instead of erroring out it can resort to a fallback if defined by the user?
  2. It would be nice to have the option to memoize components especially if you have a large data set and for example you open a select menu showing 1000 items. Every time a component renders and since DMC components render frequently, the code will recreate the function and there will be a loss of referential equality. Memoization would also speed up performance in interaction heavy components and avoid potential rerenders.

@alexcjohnson
Copy link
Copy Markdown
Collaborator

@AnnMarieW I like it! Personally I'd be inclined to stay with only functions defined in assets until there's a real clear need to accept functions as strings.

@omarirfa lots of great ideas, thank you! Before we add any extra error handling we should see how the current implementation meshes with dash devtools - if that makes the error clear I'd be inclined to not implement a fallback, better to be explicit and prompt the dev to fix it. And re: memoization - this could certainly be important in some use cases, but we'll need to be careful about it so that we avoid both improperly returning a cached value and allowing the cache to explode in size. Let's get this merged and then make a followup PR to add this once we've collected a few use cases to test it against.

@AnnMarieW
Copy link
Copy Markdown
Collaborator Author

AnnMarieW commented May 5, 2025

Thanks to you both for your feedback!
@alexcjohnson What do you think about @omarirfa 's idea about passing args from the dash app to the js function? That would add a lot of nice functionality, but would it introduce vulnerabilities?

If the function can't be found or if there is a syntax error, the following message will show in dev tools:

image

Could probably improve the message, but it does work nicely with the dash devtools.

Comment thread src/ts/utils/prop-functions.ts Outdated

// If it's not there, raise an error.
if(variable === undefined){
throw new Error("No match for [" + prop.function + "] in the global window object.")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Can we say "in window.dashMantineFunctions"?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that's the default. But it also allows you to create other namespaces. You can use them like this:
{"function": "myNamespce.myFunction"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd also like to improve the message to include something about syntax errors. If there are any errors in the namespace then you will see this error message, which is a little confusing.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But it also allows you to create other namespaces.

Oh I missed that - seems a little risky actually to let people access any code on window, also a little confusing in that if you want something directly inside dashMantineFunctions you can omit the namespace but if you want something nested inside dashMantineFunctions you need to include it. I'd be more comfortable if this path always started at dashMantineFunctions.

Comment thread src/ts/utils/prop-functions.ts Outdated
Comment on lines +30 to +33
// Check if the prop should be resolved as an arrow function.
if (prop.arrow){
return (...args) => prop.arrow
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like this is actually resolving as a constant, isn't it? Should we call this prop.constant?

Copy link
Copy Markdown
Collaborator Author

@AnnMarieW AnnMarieW May 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is a placeholder for now. It might be better to just take it out. (This is all based on dash-extensions-js and it's how dash-leaflet passes functions as props.)

@AnnMarieW
Copy link
Copy Markdown
Collaborator Author

Added the ability to pass optional args to the function:

{"function": "labelFormatter", "options": {"suffix": " °F"}},
import dash_mantine_components as dmc
from dash import Dash
app = Dash()

app.layout = dmc.MantineProvider([
    dmc.Slider(value=25, p=50, label={"function": "labelFormatter", "options": {"suffix": " °F"}}),

])

if __name__ == "__main__":
    app.run(debug=True)
var dmcfuncs = window.dashMantineFunctions = window.dashMantineFunctions || {};

dmcfuncs.labelFormatter = function (value, opts = {}) {
  const prefix = opts.prefix || '';
  const suffix = opts.suffix || '';
  return `${prefix}${value}${suffix}`;
};

image

"""

@omarirfa
Copy link
Copy Markdown

omarirfa commented May 6, 2025

thoughts on adding type safety? I just assumed the types here in the sample implementation for prop-functions.ts

/**
 * Utility to allow passing a function as a prop from a dash app.
 * For example label={'function': 'myLabel'}
 * where 'myLabel' is a function defined in .js file in /assets
 */

// Define the structure of a function prop
interface FunctionProp {
  function: string;
  options?: Record<string, any>;
  args?: any[] | any;
}

// Define the structure of possible context objects
interface PropContext {
  [key: string]: any;
}

// Type guard to check if an object is a plain object
function isPlainObject(o: any): boolean {
  return !(o === null || o === undefined || 
           Array.isArray(o) || 
           typeof o === 'function' || 
           o.constructor === Date) && 
          (typeof o === 'object');
}

// Type guard to check if a value is a function
function isFunction(functionToCheck: any): functionToCheck is Function {
  return Boolean(functionToCheck) && 
         {}.toString.call(functionToCheck) === '[object Function]';
}

// Type guard to check if an object is a FunctionProp
function isFunctionProp(prop: any): prop is FunctionProp {
  return isPlainObject(prop) && 
         typeof prop.function === 'string';
}

export function resolveProp<T = any>(prop: any, context: PropContext = {}): T {
  // If it's not an object, just return
  if (!isPlainObject(prop)) {
    return prop as T;
  }
  
  // Check if the prop should be resolved as a variable
  if (isFunctionProp(prop)) {
    return resolveVariable(prop, context) as T;
  }
  
  // If none of the special properties are present, do nothing
  return prop as T;
}

function resolveVariable(prop: FunctionProp, context: PropContext): any {
  const options = prop.options || {};
  const variable = getDescendantProp(window, prop.function);

  // If it's not there, raise an error
  if (variable === undefined) {
    throw new Error(`No match for [${prop.function}] in window.dashMantineFunctions.`);
  }
  
  // If it's a function, add context
  if (isFunction(variable)) {
    if (prop.args) {
      return (...callArgs: any[]) => 
        variable(...(Array.isArray(prop.args) ? prop.args : [prop.args]), 
                 ...callArgs, options, context);
    }
    return (...args: any[]) => variable(...args, options, context);
  }
  
  // Otherwise, use the variable as-is
  return variable;
}

function getDescendantProp(obj: any, name: string): any {
  if (name.includes(".")) {
    throw new Error(
      `Function name "${name}" is invalid. Dotted paths are not allowed. Only functions in the "dashMantineFunctions" namespace are supported.`
    );
  }

  const ns = obj.dashMantineFunctions;
  return ns ? ns[name] : undefined;
}

export function resolveProps<T extends Record<string, any> = Record<string, any>>(
  props: T | null | undefined, 
  context: PropContext = {}
): T {
  if (!props || typeof props !== 'object') return {} as T;

  return Object.fromEntries(
    Object.entries(props).map(([key, value]) => [key, resolveProp(value, context)])
  ) as T;
}

Testing

  1. All functions still behave exactly the same at runtime
  2. Added proper type safety for compile-time checking

@AnnMarieW AnnMarieW changed the base branch from master to dash-3-0 May 24, 2025 20:28
@AnnMarieW AnnMarieW changed the base branch from dash-3-0 to master May 24, 2025 20:29
@AnnMarieW AnnMarieW changed the base branch from master to dev May 24, 2025 20:31
@AnnMarieW AnnMarieW changed the base branch from dev to master May 24, 2025 20:32
@AnnMarieW AnnMarieW marked this pull request as ready for review May 31, 2025 16:38
@AnnMarieW AnnMarieW requested a review from alexcjohnson May 31, 2025 16:39
Copy link
Copy Markdown
Collaborator

@alexcjohnson alexcjohnson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💃 LGTM! You're still intending to remove assets/dashMantineFunctions.js, right?

@AnnMarieW
Copy link
Copy Markdown
Collaborator Author

Yes, I'll remove assets/dashMantineFunctions.js before merging. Just wanted to keep it there during review to help run examples locally in the PR.

Thanks for the review! I'm excited about this new feature 🙂

@AnnMarieW AnnMarieW merged commit f11e90c into snehilvj:master Jun 1, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

how to apply dmc.style to cellRenderer , editorRenderer [Feature Request] Allow functions as props.

4 participants