allow functions as props#580
Conversation
|
Here is an example for the 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
);
}
|
|
Test Environment for snehilvj/dash-mantine-components-580 |
|
Here is an example of a 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)));
});
};
|
|
Here is an example of a nested function, Here a function that creates a custom tooltip is passed to Recharts using the 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 });
|
|
@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? |
| return prop | ||
| } | ||
|
|
||
| function resolveVariable(prop, context){ |
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
Some other ideas, I have not had a chance to flesh them out, so I am thinking out loud here:
|
|
@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. |
|
Thanks to you both for your feedback! If the function can't be found or if there is a syntax error, the following message will show in dev tools: Could probably improve the message, but it does work nicely with the dash devtools. |
|
|
||
| // If it's not there, raise an error. | ||
| if(variable === undefined){ | ||
| throw new Error("No match for [" + prop.function + "] in the global window object.") |
There was a problem hiding this comment.
Nice! Can we say "in window.dashMantineFunctions"?
There was a problem hiding this comment.
Yes that's the default. But it also allows you to create other namespaces. You can use them like this:
{"function": "myNamespce.myFunction"
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| // Check if the prop should be resolved as an arrow function. | ||
| if (prop.arrow){ | ||
| return (...args) => prop.arrow | ||
| } |
There was a problem hiding this comment.
Seems like this is actually resolving as a constant, isn't it? Should we call this prop.constant?
There was a problem hiding this comment.
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.)
|
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}`;
};""" |
|
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
|
… to parse each individual prop
alexcjohnson
left a comment
There was a problem hiding this comment.
💃 LGTM! You're still intending to remove assets/dashMantineFunctions.js, right?
|
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 🙂 |




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
.jsfile within theassetsfolder using thedashMantineFunctionsnamespace.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.functionNamein 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
Example Usage
JavaScript (in
/assets)See the docs PR for more examples snehilvj/dmc-docs#201
The following props now allow functions:
TODO