Skip to content

Commit

Permalink
Allow using loop variables in ReactiveHTML templates
Browse files Browse the repository at this point in the history
  • Loading branch information
philippjfr committed May 3, 2021
1 parent 799132f commit 85a9606
Show file tree
Hide file tree
Showing 3 changed files with 55 additions and 11 deletions.
8 changes: 4 additions & 4 deletions examples/user_guide/Custom_Components.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@
"Instead of declaring explicit DOM events Python callbacks can also be declared inline, e.g.:\n",
"\n",
"```html\n",
" _html = '<input id=\"input\" onchange=\"${_input_change}\"></input>'\n",
" _template = '<input id=\"input\" onchange=\"${_input_change}\"></input>'\n",
"```\n",
"\n",
"will look for an `_input_change` method on the `ReactiveHTML` component and call it when the event is fired.\n",
Expand All @@ -164,7 +164,7 @@
"In addition to declaring callbacks in Python it is also possible to declare Javascript callbacks to execute when any sync attribute changes. Let us say we have declared an input element with a synced value parameter:\n",
"\n",
"```html\n",
" _html = '<input id=\"input\" value=\"${value}\"></input>'\n",
" _template = '<input id=\"input\" value=\"${value}\"></input>'\n",
"```\n",
"\n",
"We can now declare a set of `_scripts`, which will fire whenever the value updates:\n",
Expand Down Expand Up @@ -236,7 +236,7 @@
"source": [
"#### Child templates\n",
"\n",
"If we want to provide a template for the children of an HTML node we have to use Jinja2 to loop over the parameter and use the `{{ loop.index0 }}` variable to enumerate the child `id`s and index into the options:"
"If we want to provide a template for the children of an HTML node we have to use Jinja2 to loop over the parameter and use the `{{ loop.index0 }}` variable to enumerate the child `id`s:"
]
},
{
Expand All @@ -254,7 +254,7 @@
" _template = \"\"\"\n",
" <select id=\"select\" value=\"${value}\">\n",
" {% for obj in options %}\n",
" <option id=\"option-{{ loop.index0 }}\">${options[{{ loop.index0 }}]}</option>\n",
" <option id=\"option-{{ loop.index0 }}\">${obj}</option>\n",
" {% endfor %}\n",
" </select>\n",
" \"\"\"\n",
Expand Down
40 changes: 34 additions & 6 deletions panel/models/reactive_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ def __init__(self, cls):
self._template_re = re.compile('\$\{[^}]+\}')
self._current_node = None
self._node_stack = []
self._open_for = False
self.loop_map = {}

def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
Expand Down Expand Up @@ -58,8 +60,28 @@ def handle_data(self, data):
'%s}]}' % match if match.endswith('.index0 }') else match
for match in self._template_re.findall(data)
]

# Detect templated for loops
if '{% for ' in data:
start_idx = data.index('{% for ')
end_idx = start_idx + data[start_idx:].index('%}')
objs = data[start_idx+7:end_idx].split(' ')
for_string = data[start_idx:end_idx]
if len(objs) < 3:
raise SyntaxError(f"Template for loop '{for_string}' malformed in:\n {data}.")
if ',' in objs[0] and '.items()' in for_string:
var, obj = objs[1], objs[3]
else:
var, obj = objs[0], objs[2]
obj = obj.split('.')[0]
self.loop_map[var] = obj
self._open_for = True
elif '{% endfor %}' in data:
self._open_for = False

if not (self._current_node and matches):
return

if len(matches) == 1:
match = matches[0][2:-1]
else:
Expand All @@ -68,13 +90,19 @@ def handle_data(self, data):
if mode != 'template':
raise ValueError(f"Cannot match multiple variables in '{mode}' mode.")
match = None
if match and '[' in match:
match, num = match.split('[')

# Handle looped variables
if match:
dom_id = dom_id.replace('-{{ loop.index0 }}', '')
num = num.rstrip(']')
self.looped.append((dom_id, match))
else:
num = None
loop = False
if match in self.loop_map:
matches[matches.index('${%s}' % match)] = '${%s}' % self.loop_map[match]
match = self.loop_map[match]
self.looped.append((dom_id, match))
elif '[' in match:
match, _ = match.split('[')
self.looped.append((dom_id, match))

mode = self.cls._child_config.get(match, 'model')
if match in self.cls.param and mode != 'template':
self.children[dom_id] = match
Expand Down
18 changes: 17 additions & 1 deletion panel/reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,9 @@ def __init__(mcs, name, bases, dict_):

mcs._parser = ReactiveHTMLParser(mcs)
mcs._parser.feed(mcs._template)
if mcs._parser._open_for:
raise ValueError("Template contains for loop without closing {% endfor %} statement.")

mcs._attrs, mcs._node_callbacks = {}, {}
mcs._inline_callbacks = []
for node, attrs in mcs._parser.attrs.items():
Expand Down Expand Up @@ -1273,13 +1276,26 @@ def _get_children(self, doc, root, model, comm, old_children=None):

def _get_template(self):
import jinja2
template = jinja2.Template(self._template)

# Replace loop variables with indexed child parameter e.g.:
# {% for obj in objects %} ${obj} {% endfor %}
# becomes:
# {% for obj in objects %} ${objects[{{ loop.index0 }}]} {% endfor %}
template_string = self._template
for var, obj in self._parser.loop_map.items():
template_string = template_string.replace(
'${%s}' % var, '${%s[{{ loop.index0 }}]}' % obj)

# Render Jinja template
template = jinja2.Template(template_string)
context = {'param': self.param, '__doc__': self.__original_doc__}
for parameter, value in self.param.get_param_values():
context[parameter] = value
if parameter in self._child_names:
context[f'{parameter}_names'] = self._child_names[parameter]
html = template.render(context)

# Parse templated HTML and replace names
parser = ReactiveHTMLParser(self.__class__)
parser.feed(html)
for name in list(parser.nodes):
Expand Down

0 comments on commit 85a9606

Please sign in to comment.