Skip to content

Commit

Permalink
Fixed compatibility with Flask's built-in url type-converters (#146)
Browse files Browse the repository at this point in the history
* Re-located _arg_url_for macro to utils.html and renamed to public function arg_url_for

* Fixed url-rendering with url_for by adding the ability to pass in a model to render_table. Added ability to supply url parameters to render_table by allowing passage of a list (first item is the route namespace, second item is a list of tuples mapping variables to placeholders representing fields present in the model)

* Added tests for fixed url-processors (string and int) and for dynamic placeholders representing fields of a model.

* Corrected flake8 errors

* Changed model parameter to query, so a subset query can be used. Also, updated tests to reflect change.

* Updated documentation and changelog

* Implemented requested changes/additions

* Reverted new parameter back to model

* Update arg name in docs

Co-authored-by: Grey Li <[email protected]>
  • Loading branch information
caffeinatedMike and greyli authored Sep 2, 2021
1 parent b63afdb commit 100923a
Show file tree
Hide file tree
Showing 6 changed files with 120 additions and 56 deletions.
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Release date: -
- Add configuration ``BOOTSTRAP_TABLE_VIEW_TITLE``, ``BOOTSTRAP_TABLE_EDIT_TITLE``,
``BOOTSTRAP_TABLE_DELETE_TITLE``, ``BOOTSTRAP_TABLE_NEW_TITLE`` to support changing
the icon title of table actions.
- Introduce a new and better way to pass table action URLs to support the usage of ``Flask``'s path converters
(`#146 <https://github.com/greyli/bootstrap-flask/pull/146>`__).


1.7.0
Expand Down
17 changes: 12 additions & 5 deletions docs/macros.rst
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ API
header_classes=None,\
responsive=False,\
responsive_class='table-responsive',\
model=None,\
show_actions=False,\
actions_title='Actions',\
custom_actions=None,\
Expand All @@ -450,15 +451,21 @@ API
:param header_classes: A string of classes to apply to the table header (e.g ``'thead-dark'``).
:param responsive: Whether to enable/disable table responsiveness.
:param responsive_class: The responsive class to apply to the table. Default is ``'table-responsive'``.
:param model: The model used to build custom_action, view, edit, delete, and new urls. This allows for proper
usage of Flask's default path converter types (i.e. ``str``, ``int``) (e.g. ``Model``). When using
this tuples are accepted in place of view, edit, delete, new, and custom_actions urls. Defaults to
``None`` to allow for backwards-compatibility. Tuple format:
``('route_name', [('db_model_fieldname', ':url_parameter_name')])``. ``db_model_fieldname`` may also
contain dots to access relationships and their fields (e.g. ``db_model_relationship_field.name``).
:param show_actions: Whether to display the actions column. Default is ``False``.
:param actions_title: Title for the actions column header. Default is ``'Actions'``.
:param custom_actions: A list of tuples for creating custom action buttons, where each tuple contains
('Title Text displayed on hover', 'bootstrap icon name', 'url_for()')
(e.g. ``[('Run', 'play-fill', url_for('run_report', report_id=':id'))]``).
:param view_url: URL to use for the view action.
:param edit_url: URL to use for the edit action.
:param delete_url: URL to use for the delete action.
:param new_url: URL to use for the create action (new in version 1.6.0).
(e.g. ``[('Run', 'play-fill', ('run_report', [('report_id', ':id')]))]``).
:param view_url: URL or tuple (see :param:`model`) to use for the view action.
:param edit_url: URL or tuple (see :param:`model`) to use for the edit action.
:param delete_url: URL or tuple (see :param:`model`) to use for the delete action.
:param new_url: URL or tuple (see :param:`model`) to use for the create action (new in version 1.6.0).
:param action_pk_placeholder: The placeholder which replaced by the primary key when build the action URLs. Default is ``':id'``.

.. tip:: The default value of ``action_pk_placeholder`` changed to ``:id`` in version 1.7.0.
Expand Down
17 changes: 4 additions & 13 deletions flask_bootstrap/templates/bootstrap/pagination.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{# This file was part of Flask-Bootstrap and was modified under the terms of
its BSD License. Copyright (c) 2013, Marc Brinkmann. All rights reserved. #}
{% from 'bootstrap/utils.html' import arg_url_for %}

{% macro render_pager(pagination,
fragment='',
Expand All @@ -24,16 +25,6 @@
</nav>
{%- endmacro %}

{% macro _arg_url_for(endpoint, base) %}
{# calls url_for() with a given endpoint and **base as the parameters,
additionally passing on all keyword_arguments (may overwrite existing ones)
#}
{%- with kargs = base.copy() -%}
{%- do kargs.update(kwargs) -%}
{{ url_for(endpoint, **kargs) }}
{%- endwith %}
{%- endmacro %}

{% macro render_pagination(pagination,
endpoint=None,
prev=('&laquo;')|safe,
Expand All @@ -55,15 +46,15 @@
{# prev and next are only show if a symbol has been passed. #}
{% if prev != None -%}
<li class="page-item {% if not pagination.has_prev %}disabled{% endif %}">
<a class="page-link" href="{{ _arg_url_for(endpoint, url_args, page=pagination.prev_num) if pagination.has_prev else '#' }}{{ fragment }}">{{ prev }}</a>
<a class="page-link" href="{{ arg_url_for(endpoint, url_args, page=pagination.prev_num) if pagination.has_prev else '#' }}{{ fragment }}">{{ prev }}</a>
</li>
{%- endif -%}

{%- for page in pagination.iter_pages() %}
{% if page %}
{% if page != pagination.page %}
<li class="page-item">
<a class="page-link" href="{{ _arg_url_for(endpoint, url_args, page=page) }}{{ fragment }}">{{ page }}</a>
<a class="page-link" href="{{ arg_url_for(endpoint, url_args, page=page) }}{{ fragment }}">{{ page }}</a>
</li>
{% else %}
<li class="page-item active">
Expand All @@ -77,7 +68,7 @@

{% if next != None -%}
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
<a class="page-link" href="{{ _arg_url_for(endpoint, url_args, page=pagination.next_num) if pagination.has_next else '#' }}{{ fragment }}">{{ next }}</a>
<a class="page-link" href="{{ arg_url_for(endpoint, url_args, page=pagination.next_num) if pagination.has_next else '#' }}{{ fragment }}">{{ next }}</a>
</li>
{%- endif -%}
</ul>
Expand Down
83 changes: 62 additions & 21 deletions flask_bootstrap/templates/bootstrap/table.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
{% from 'bootstrap/utils.html' import render_icon %}
{% from 'bootstrap/utils.html' import render_icon, arg_url_for %}

{% macro deprecate_old_pk_placeholder() %}
{{ warn('The default action primary key placeholder has changed to ":id", please update.
The support to the old value (":primary_key") will be removed in version 2.0.') }}
{% endmacro %}

{% macro build_url(endpoint, model, pk, kwargs) %}
{% with url_params = {} -%}
{%- do url_params.update(request.view_args if not endpoint else {}),
url_params.update(request.args if not endpoint else {}) -%}
{% with record = model.query.get(pk) %}
{% for kwarg, value in kwargs %}
{% if value.startswith(':') and '.' in value %}
{%- set value = value[1:].split('.') -%}
{%- do url_params.update({kwarg: record[value[0]][value[1]]}) -%}
{% elif value.startswith(':') %}
{%- set value = value[1:] -%}
{%- do url_params.update({kwarg: record[value]}) -%}
{% else %}
{%- do url_params.update({kwarg: value}) -%}
{% endif %}
{% endfor %}
{% endwith -%}
{{ arg_url_for(endpoint, url_params) }}
{%- endwith %}
{%- endmacro %}

{% macro render_table(data,
titles=None,
Expand All @@ -15,6 +35,7 @@
header_classes=None,
responsive=False,
responsive_class='table-responsive',
model=None,
show_actions=False,
actions_title='Actions',
custom_actions=None,
Expand All @@ -40,9 +61,11 @@
{% endfor %}
{% if show_actions %}
<th scope="col">{{ actions_title }}
{% if new_url %}<a class="action-icon text-decoration-none" href="{{ new_url }}" title="{{ config['BOOTSTRAP_TABLE_NEW_TITLE'] }}">
{% if new_url %}
<a class="action-icon text-decoration-none" href="{{ new_url }}" title="{{ config['BOOTSTRAP_TABLE_NEW_TITLE'] }}">
{{ render_icon('plus-circle-fill') }}
</a>{% endif %}
</a>
{% endif %}
</th>
{% endif %}
</tr>
Expand All @@ -61,43 +84,61 @@
<td>
{% if custom_actions %}
{% for (action_name, action_icon, action_url) in custom_actions %}
{% if ':primary_key' in action_url %}
{% if ':primary_key' in action_url | join('') %}
{% set action_pk_placeholder = ':primary_key' %}
{% set w = deprecate_old_pk_placeholder() %}
{% endif %}
<a class="action-icon text-decoration-none"
href="{{ action_url | replace(action_pk_placeholder, row[primary_key]) }}"
{% if action_url is string %}
href="{{ action_url | replace(action_pk_placeholder, row[primary_key]) }}"
{% else %}
href="{{ build_url(action_url[0], model, row[primary_key], action_url[1]) | trim }}"
{% endif %}
title="{{ action_name }}">{{ render_icon(action_icon) }}</a>
{% endfor %}
{% endif %}
{% if view_url %}
{% if ':primary_key' in view_url %}
{% if ':primary_key' in view_url|join('') %}
{% set action_pk_placeholder = ':primary_key' %}
{% set w = deprecate_old_pk_placeholder() %}
{% endif %}
<a class="action-icon text-decoration-none"
href="{{ view_url | replace(action_pk_placeholder, row[primary_key]) }}"
title="{{ config['BOOTSTRAP_TABLE_VIEW_TITLE'] }}">
{{ render_icon('eye-fill') }}
</a>
<a class="action-icon text-decoration-none"
{% if view_url is string %}
href="{{ view_url }}"
{% else %}
href="{{ build_url(view_url[0], model, row[primary_key], view_url[1])|trim }}"
{% endif %}
title="{{ config['BOOTSTRAP_TABLE_VIEW_TITLE'] }}">
{{ render_icon('eye-fill') }}
</a>
{% endif %}
{% if edit_url %}
{% if ':primary_key' in edit_url %}
{% if edit_url -%}
{% if ':primary_key' in edit_url|join('') %}
{% set action_pk_placeholder = ':primary_key' %}
{% set w = deprecate_old_pk_placeholder() %}
{% endif %}
<a class="action-icon text-decoration-none"
href="{{ edit_url | replace(action_pk_placeholder, row[primary_key]) }}"
title="{{ config['BOOTSTRAP_TABLE_EDIT_TITLE'] }}">
{{ render_icon('pencil-fill') }}
</a>
{% endif %}
<a class="action-icon text-decoration-none"
{% if edit_url is string %}
href="{{ edit_url }}"
{% else %}
href="{{ build_url(edit_url[0], model, row[primary_key], edit_url[1])|trim }}"
{% endif %}
title="{{ config['BOOTSTRAP_TABLE_EDIT_TITLE'] }}">
{{ render_icon('pencil-fill') }}
</a>
{%- endif %}
{% if delete_url %}
{% if ':primary_key' in delete_url %}
{% if ':primary_key' in delete_url|join('') %}
{% set action_pk_placeholder = ':primary_key' %}
{% set w = deprecate_old_pk_placeholder() %}
{% endif %}
<form style="display:inline" action="{{ delete_url | replace(action_pk_placeholder, row[primary_key]) }}" method="post">
<form style="display:inline"
{% if delete_url is string %}
action="{{ delete_url }}"
{% else %}
action="{{ build_url(delete_url[0], model, row[primary_key], delete_url[1])|trim }}"
{% endif %}
method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<a class="action-icon text-decoration-none"
href="javascript:{}"
Expand Down
10 changes: 10 additions & 0 deletions flask_bootstrap/templates/bootstrap/utils.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,13 @@
<use xlink:href="{{ url_for('bootstrap.static', filename='icons/bootstrap-icons.svg') }}#{{ name }}"/>
</svg>
{% endmacro %}

{% macro arg_url_for(endpoint, base) %}
{# calls url_for() with a given endpoint and **base as the parameters,
additionally passing on all keyword_arguments (may overwrite existing ones)
#}
{%- with kargs = base.copy() -%}
{%- do kargs.update(kwargs) -%}
{{ url_for(endpoint, **kargs) }}
{%- endwith %}
{%- endmacro %}
47 changes: 30 additions & 17 deletions tests/test_render_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,15 +147,17 @@ def test_render_table_with_actions(self, app, client):

class Message(db.Model):
id = db.Column(db.Integer, primary_key=True)
sender = db.Column(db.String(20))
recipient = db.Column(db.String(20))
text = db.Column(db.Text)

@app.route('/table/<message_id>/resend')
def test_resend_message(message_id):
return 'Re-sending {}'.format(message_id)
@app.route('/table/<string:recipient>/<int:message_id>/resend')
def test_resend_message(recipient, message_id):
return 'Re-sending {} to {}'.format(message_id, recipient)

@app.route('/table/<message_id>/view')
def test_view_message(message_id):
return 'Viewing {}'.format(message_id)
@app.route('/table/<string:sender>/<int:message_id>/view')
def test_view_message(sender, message_id):
return 'Viewing {} from {}'.format(message_id, sender)

@app.route('/table/new-message')
def test_create_message():
Expand All @@ -166,7 +168,11 @@ def test():
db.drop_all()
db.create_all()
for i in range(10):
m = Message(text='Test message {}'.format(i+1))
m = Message(
text='Test message {}'.format(i+1),
sender='me',
recipient='john_doe'
)
db.session.add(m)
db.session.commit()
page = request.args.get('page', 1, type=int)
Expand All @@ -175,20 +181,24 @@ def test():
titles = [('id', '#'), ('text', 'Message')]
return render_template_string('''
{% from 'bootstrap/table.html' import render_table %}
{{ render_table(messages, titles, show_actions=True,
{{ render_table(messages, titles, model=model, show_actions=True,
custom_actions=[
('Resend', 'bootstrap-reboot', url_for('test_resend_message', message_id=':id'))
(
'Resend',
'bootstrap-reboot',
('test_resend_message', [('recipient', ':recipient'), ('message_id', ':id')])
)
],
view_url=url_for('test_view_message', message_id=':id'),
view_url=('test_view_message', [('sender', ':sender'), ('message_id', ':id')]),
new_url=url_for('test_create_message')) }}
''', titles=titles, messages=messages)
''', titles=titles, model=Message, messages=messages)

response = client.get('/table')
data = response.get_data(as_text=True)
assert 'icons/bootstrap-icons.svg#bootstrap-reboot' in data
assert 'href="/table/1/resend"' in data
assert 'href="/table/john_doe/1/resend"' in data
assert 'title="Resend">' in data
assert 'href="/table/1/view"' in data
assert 'href="/table/me/1/view"' in data
assert 'href="/table/new-message"' in data

def test_customize_icon_title_of_table_actions(self, app, client):
Expand Down Expand Up @@ -217,10 +227,13 @@ def test():
pagination = Message.query.paginate(page, per_page=10)
messages = pagination.items
return render_template_string('''
{% from 'bootstrap/table.html' import render_table %}
{{ render_table(messages, show_actions=True, view_url='/view', edit_url='/edit',
delete_url='/delete', new_url='/new') }}
''', messages=messages)
{% from 'bootstrap/table.html' import render_table %}
{{ render_table(messages, model=model, show_actions=True,
view_url='/view',
edit_url='/edit',
delete_url='/delete',
new_url='/new') }}
''', model=Message, messages=messages)

response = client.get('/table')
data = response.get_data(as_text=True)
Expand Down

0 comments on commit 100923a

Please sign in to comment.