Skip to content

Commit

Permalink
Do not require explicitly providing index
Browse files Browse the repository at this point in the history
  • Loading branch information
philippjfr committed May 4, 2021
1 parent 85a9606 commit 1631f8b
Show file tree
Hide file tree
Showing 4 changed files with 126 additions and 26 deletions.
4 changes: 2 additions & 2 deletions examples/user_guide/Custom_Components.ipynb
Original file line number Diff line number Diff line change
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:"
"If we want to provide a template for the children of an HTML node we have to use Jinja2 syntax to loop over the parameter. The component will automatically assign each `<option>` tag a unique id and insert the loop variable `obj` into each of the tags:"
]
},
{
Expand All @@ -254,7 +254,7 @@
" _template = \"\"\"\n",
" <select id=\"select\" value=\"${value}\">\n",
" {% for obj in options %}\n",
" <option id=\"option-{{ loop.index0 }}\">${obj}</option>\n",
" <option id=\"option\">${obj}</option>\n",
" {% endfor %}\n",
" </select>\n",
" \"\"\"\n",
Expand Down
47 changes: 27 additions & 20 deletions panel/models/reactive_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,17 @@
from bokeh.events import ModelEvent


endfor = '{% endfor %}'
list_iter_re = '{% for (\s*[A-Za-z_]\w*\s*) in (\s*[A-Za-z_]\w*\s*) %}'
items_iter_re = '{% for \s*[A-Za-z_]\w*\s*, (\s*[A-Za-z_]\w*\s*) in (\s*[A-Za-z_]\w*\s*)\.items\(\) %}'
values_iter_re = '{% for \s*[A-Za-z_]\w*\s*, (\s*[A-Za-z_]\w*\s*) in (\s*[A-Za-z_]\w*\s*)\.values\(\) %}'


class ReactiveHTMLParser(HTMLParser):

def __init__(self, cls):
def __init__(self, cls, template=True):
super().__init__()
self.template = template
self.cls = cls
self.attrs = defaultdict(list)
self.children = {}
Expand Down Expand Up @@ -55,28 +62,30 @@ def handle_endtag(self, tag):
self._current_node = self._node_stack[-1][1] if self._node_stack else None

def handle_data(self, data):
if not self.template:
return

dom_id = self._current_node
matches = [
'%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]
# Detect templating for loops
list_loop = re.findall(list_iter_re, data)
values_loop = re.findall(values_iter_re, data)
items_loop = re.findall(items_iter_re, data)
nloops = len(list_loop) + len(values_loop) + len(items_loop)
if nloops > 1 and nloops and self._open_for:
raise ValueError('Nested for loops currently not supported in templates.')
elif nloops:
loop = [loop for loop in (list_loop, values_loop, items_loop)][0]
var, obj = loop[0]
self.loop_map[var] = obj

if '{% for ' in data:
self._open_for = True
elif '{% endfor %}' in data:
if endfor in data and (not nloops or data.index(endfor) > data.index('{% for ')):
self._open_for = False

if not (self._current_node and matches):
Expand All @@ -92,16 +101,14 @@ def handle_data(self, data):
match = None

# Handle looped variables
if match:
dom_id = dom_id.replace('-{{ loop.index0 }}', '')
loop = False
if match and (match in self.loop_map or '[' in match) and self._open_for:
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))
dom_id = dom_id.replace('-{{ loop.index0 }}', '')
self.looped.append((dom_id, match))

mode = self.cls._child_config.get(match, 'model')
if match in self.cls.param and mode != 'template':
Expand Down
26 changes: 22 additions & 4 deletions panel/reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -1278,14 +1278,28 @@ def _get_template(self):
import jinja2

# Replace loop variables with indexed child parameter e.g.:
# {% for obj in objects %} ${obj} {% endfor %}
# {% for obj in objects %}
# ${obj}
# {% endfor %}
# becomes:
# {% for obj in objects %} ${objects[{{ loop.index0 }}]} {% endfor %}
# {% 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)

# Add index to templated loop node ids
for dom_node, _ in self._parser.looped:
replacement = 'id="%s-{{ loop.index0 }}"' % dom_node
if f'id="{dom_node}"' in template_string:
template_string = template_string.replace(
f'id="{dom_node}"', replacement)
else:
template_string = template_string.replace(
f"id='{dom_node}'", replacement)

# Render Jinja template
template = jinja2.Template(template_string)
context = {'param': self.param, '__doc__': self.__original_doc__}
Expand All @@ -1295,15 +1309,19 @@ def _get_template(self):
context[f'{parameter}_names'] = self._child_names[parameter]
html = template.render(context)

# Parse templated HTML and replace names
parser = ReactiveHTMLParser(self.__class__)
# Parse templated HTML
parser = ReactiveHTMLParser(self.__class__, template=False)
parser.feed(html)

# Add node ids to all parsed nodes
for name in list(parser.nodes):
html = (
html
.replace(f"id='{name}'", f"id='{name}-${{id}}'")
.replace(f'id="{name}"', f'id="{name}-${{id}}"')
)

# Remove child node template syntax
for parent, child_name in self._parser.children.items():
if (parent, child_name) in self._parser.looped:
for i, _ in enumerate(getattr(self, child_name)):
Expand Down
75 changes: 75 additions & 0 deletions panel/tests/test_reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,10 +301,85 @@ class TestTemplatedChildren(ReactiveHTML):
widget = TextInput()
test = TestTemplatedChildren(children=[widget])
root = test.get_root()
assert root.looped == ['option']
assert root.children == {'option': [widget._models[root.ref['id']][0]]}

widget_new = TextInput()
test.children = [widget_new]
assert len(widget._models) == 0
assert root.children == {'option': [widget_new._models[root.ref['id']][0]]}



def test_reactive_html_templated_children_add_loop_id():

class TestTemplatedChildren(ReactiveHTML):

children = param.List(default=[])

_template = """
<select id="select">
{% for option in children %}
<option id="option">${children[{{ loop.index0 }}]}</option>
{% endfor %}
</select>
"""

assert TestTemplatedChildren._attrs == {}
assert TestTemplatedChildren._node_callbacks == {}
assert TestTemplatedChildren._inline_callbacks == []
assert TestTemplatedChildren._parser.children == {'option': 'children'}

test = TestTemplatedChildren(children=['A', 'B', 'C'])

assert test._get_template() == """
<select id="select-${id}">
<option id="option-0-${id}"></option>
<option id="option-1-${id}"></option>
<option id="option-2-${id}"></option>
</select>
"""

model = test.get_root()
assert model.looped == ['option']



def test_reactive_html_templated_children_add_loop_id_and_for_loop_var():

class TestTemplatedChildren(ReactiveHTML):

children = param.List(default=[])

_template = """
<select id="select">
{% for option in children %}
<option id="option">${option}</option>
{% endfor %}
</select>
"""

assert TestTemplatedChildren._attrs == {}
assert TestTemplatedChildren._node_callbacks == {}
assert TestTemplatedChildren._inline_callbacks == []
assert TestTemplatedChildren._parser.children == {'option': 'children'}

test = TestTemplatedChildren(children=['A', 'B', 'C'])

assert test._get_template() == """
<select id="select-${id}">
<option id="option-0-${id}"></option>
<option id="option-1-${id}"></option>
<option id="option-2-${id}"></option>
</select>
"""
model = test.get_root()
assert model.looped == ['option']

0 comments on commit 1631f8b

Please sign in to comment.