Skip to content

Commit

Permalink
Merge pull request #151 from greyli/improve-table-url
Browse files Browse the repository at this point in the history
Improve table action URLs handling
  • Loading branch information
greyli authored Sep 4, 2021
2 parents 100923a + ca04468 commit e427ddd
Show file tree
Hide file tree
Showing 4 changed files with 202 additions and 49 deletions.
88 changes: 69 additions & 19 deletions docs/macros.rst
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,13 @@ Render a Bootstrap table with given data.
Example
~~~~~~~

.. code-block:: python
@app.route('/test')
def test():
data = Message.query.all()
return render_template('test.html', data=data)
.. code-block:: jinja
{% from 'bootstrap/table.html' import render_table %}
Expand All @@ -431,15 +438,14 @@ API
header_classes=None,\
responsive=False,\
responsive_class='table-responsive',\
model=None,\
show_actions=False,\
actions_title='Actions',\
model=None,\
custom_actions=None,\
view_url=None,\
edit_url=None,\
delete_url=None,\
new_url=None,\
action_pk_placeholder=':id')
new_url=None)
:param data: An iterable of data objects to render. Can be dicts or class objects.
:param titles: An iterable of tuples of the format (prop, label) e.g ``[('id', '#')]``, if not provided,
Expand All @@ -451,26 +457,70 @@ 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 model: The model used to build custom_action, view, edit, delete URLs.
: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()')
('Title Text displayed on hover', 'bootstrap icon name', 'URL tuple')
(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.
The old value (``:primary_key``) will be removed in version 2.0. Currently, you can't
use ``int`` converter on the URL variable of primary key.
:param view_url: URL string or URL tuple in ``('endpoint', [('url_parameter_name', ':db_model_fieldname')])``
to use for the view action.
:param edit_url: URL string or URL tuple in ``('endpoint', [('url_parameter_name', ':db_model_fieldname')])``
to use for the edit action.
:param delete_url: URL string or URL tuple in ``('endpoint', [('url_parameter_name', ':db_model_fieldname')])``
to use for the delete action.
:param new_url: URL string or endpoint to use for the create action (new in version 1.6.0).

To set the URLs for table actions, you will need to pass an URL tuple in the form of
``('endpoint', [('url_parameter_name', ':db_model_fieldname')])``:

- ``endpoint``: endpoint of the view, normally the name of the view function
- ``[('url_parameter_name', ':db_model_fieldname')]``: a list of two-element tuples, the tuple should contain the
URL parameter name and the corresponding field name in the database model (starts with a ``:`` mark to indicate
it's a variable, otherwise it will becomes a fixed value). `db_model_fieldname`` may also contain dots to access
relationships and their fields (e.g. ``user.name``).

Remember to set the ``model`` when setting this URLs, so that Bootstrap-Flask will know where to get the actual value
when building the URL.

For example, for the view below:

.. code-block:: python
class Message(Model):
id = Column(primary_key=True)
@app.route('/messages/<int:message_id>')
def view_message(message_id):
pass
To pass the URL point to this view for ``view_url``, the value will be: ``view_url=('view_message', [('message_id', ':id')])``.
Here is the full example:

.. code-block:: python
@app.route('/test')
def test():
data = Message.query.all()
return render_template('test.html', data=data, Message=Message)
.. code-block:: jinja
{% from 'bootstrap/table.html' import render_table %}
{{ render_table(data, model=Message, view_url=('view_message', [('message_id', ':id')])) }}
The following arguments are expect to accpet an URL tuple:

- ``custom_actions``
- ``view_url``
- ``edit_url``
- ``delete_url``

You can also pass a fiexd URL string, but use a primary key placeholder in the URL is deprecated and will be removed
in version 2.0.

The ``new_url`` expects a fixed URL string or an endpoint.


render_icon()
Expand Down
5 changes: 5 additions & 0 deletions flask_bootstrap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ def is_hidden_field_filter(field):
VERSION_POPPER = '1.14.0'


def raise_helper(message): # pragma: no cover
raise RuntimeError(message)


def get_table_titles(data, primary_key, primary_key_title):
"""Detect and build the table titles tuple from ORM object, currently only support SQLAlchemy.
Expand Down Expand Up @@ -58,6 +62,7 @@ def init_app(self, app):
app.jinja_env.globals['bootstrap_is_hidden_field'] = is_hidden_field_filter
app.jinja_env.globals['get_table_titles'] = get_table_titles
app.jinja_env.globals['warn'] = warnings.warn
app.jinja_env.globals['raise'] = raise_helper
app.jinja_env.add_extension('jinja2.ext.do')
# default settings
app.config.setdefault('BOOTSTRAP_SERVE_LOCAL', False)
Expand Down
62 changes: 35 additions & 27 deletions flask_bootstrap/templates/bootstrap/table.html
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
{% 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.') }}
{% macro deprecate_placeholder_url() %}
{{ warn('Passing an URL with primary key palceholder for view_url/edit_url/delete_url/custom_actions is deprecated. You will need to pass an fixed URL or an URL tuple to URL arguments, see the API docs of render_table for more details. The support to URL string will be removed in version 2.0.') }}
{% endmacro %}

{% macro build_url(endpoint, model, pk, kwargs) %}
{% macro build_url(endpoint, model, pk, url_tuples) %}
{% if model == None %}
{{ raise("The model argument can't be None when setting action URLs.") }}
{% endif %}
{% 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]}) -%}
{% for url_parameter, db_field in url_tuples %}
{% if db_field.startswith(':') and '.' in db_field %}
{%- set db_field = db_field[1:].split('.') -%}
{%- do url_params.update({url_parameter: record[db_field[0]][db_field[1]]}) -%}
{% elif db_field.startswith(':') %}
{%- set db_field = db_field[1:] -%}
{%- do url_params.update({url_parameter: record[db_field]}) -%}
{% else %}
{%- do url_params.update({kwarg: value}) -%}
{%- do url_params.update({url_parameter: db_field}) -%}
{% endif %}
{% endfor %}
{% endwith -%}
Expand Down Expand Up @@ -62,8 +64,14 @@
{% 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'] }}">
{{ render_icon('plus-circle-fill') }}
<a class="action-icon text-decoration-none"
{% if new_url.startswith('/') %}
href="{{ new_url }}"
{% else %}
href="{{ url_for(new_url) }}"
{% endif %}
title="{{ config['BOOTSTRAP_TABLE_NEW_TITLE'] }}">
{{ render_icon('plus-circle-fill') }}
</a>
{% endif %}
</th>
Expand All @@ -86,7 +94,7 @@
{% for (action_name, action_icon, action_url) in custom_actions %}
{% if ':primary_key' in action_url | join('') %}
{% set action_pk_placeholder = ':primary_key' %}
{% set w = deprecate_old_pk_placeholder() %}
{% set _ = deprecate_placeholder_url() %}
{% endif %}
<a class="action-icon text-decoration-none"
{% if action_url is string %}
Expand All @@ -98,45 +106,45 @@
{% endfor %}
{% endif %}
{% if view_url %}
{% if ':primary_key' in view_url|join('') %}
{% if ':primary_key' in view_url | join('') %}
{% set action_pk_placeholder = ':primary_key' %}
{% set w = deprecate_old_pk_placeholder() %}
{% set _ = deprecate_placeholder_url() %}
{% endif %}
<a class="action-icon text-decoration-none"
{% if view_url is string %}
href="{{ view_url }}"
href="{{ view_url | replace(action_pk_placeholder, row[primary_key]) }}"
{% else %}
href="{{ build_url(view_url[0], model, row[primary_key], view_url[1])|trim }}"
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|join('') %}
{% if ':primary_key' in edit_url | join('') %}
{% set action_pk_placeholder = ':primary_key' %}
{% set w = deprecate_old_pk_placeholder() %}
{% set w = deprecate_placeholder_url() %}
{% endif %}
<a class="action-icon text-decoration-none"
{% if edit_url is string %}
href="{{ edit_url }}"
href="{{ edit_url | replace(action_pk_placeholder, row[primary_key]) }}"
{% else %}
href="{{ build_url(edit_url[0], model, row[primary_key], edit_url[1])|trim }}"
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|join('') %}
{% if ':primary_key' in delete_url | join('') %}
{% set action_pk_placeholder = ':primary_key' %}
{% set w = deprecate_old_pk_placeholder() %}
{% set _ = deprecate_placeholder_url() %}
{% endif %}
<form style="display:inline"
{% if delete_url is string %}
action="{{ delete_url }}"
action="{{ delete_url | replace(action_pk_placeholder, row[primary_key]) }}"
{% else %}
action="{{ build_url(delete_url[0], model, row[primary_key], delete_url[1])|trim }}"
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() }}"/>
Expand Down
96 changes: 93 additions & 3 deletions tests/test_render_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from flask_wtf import CSRFProtect


class TestPagination:
class TestRenderTable:
def test_render_simple_table(self, app, client):
db = SQLAlchemy(app)

Expand Down Expand Up @@ -142,7 +142,83 @@ def test():
response = client.get('/table')
assert response.status_code == 200

def test_render_table_with_actions(self, app, client):
def test_render_table_with_actions(self, app, client): # noqa: C901
app.jinja_env.globals['csrf_token'] = lambda: ''

db = SQLAlchemy(app)

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('/new-message')
def new_message():
return 'Create message'

@app.route('/messages/<message_id>/edit')
def edit_message(message_id):
return 'Editing message {}'.format(message_id)

@app.route('/messages/<message_id>/view')
def view_message(message_id):
return 'Viewing message {}'.format(message_id)

@app.route('/messages/<message_id>/delete')
def delete_message(message_id):
return 'Deleting message {}'.format(message_id)

@app.route('/messages/<message_id>/resend')
def resend_message(message_id):
return 'Re-sending message {}'.format(message_id)

@app.route('/table')
def test():
db.drop_all()
db.create_all()
for i in range(10):
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)
pagination = Message.query.paginate(page, per_page=10)
messages = pagination.items
titles = [('id', '#'), ('text', 'Message')]
return render_template_string('''
{% from 'bootstrap/table.html' import render_table %}
# URL arguments with URL string (deprecated (except new_url), will be removed in 2.0)
{{ render_table(messages, titles, show_actions=True,
custom_actions=[
(
'Resend',
'bootstrap-reboot',
url_for('resend_message', message_id=':id')
)
],
view_url=url_for('view_message', message_id=':id'),
delete_url=url_for('delete_message', message_id=':id'),
edit_url=url_for('edit_message', message_id=':id'),
new_url=url_for('new_message')
) }}
''', 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 'title="Resend">' in data
assert 'href="/messages/1/edit"' in data
assert 'href="/messages/1/view"' in data
assert 'action="/messages/1/delete"' in data
assert 'href="/new-message"' in data

def test_render_table_with_actions_and_url_tuple(self, app, client): # noqa: C901
app.jinja_env.globals['csrf_token'] = lambda: ''

db = SQLAlchemy(app)

class Message(db.Model):
Expand All @@ -159,6 +235,14 @@ def test_resend_message(recipient, message_id):
def test_view_message(sender, message_id):
return 'Viewing {} from {}'.format(message_id, sender)

@app.route('/table/<string:sender>/<int:message_id>/edit')
def test_edit_message(sender, message_id):
return 'Editing {} from {}'.format(message_id, sender)

@app.route('/table/<string:sender>/<int:message_id>/delete')
def test_delete_message(sender, message_id):
return 'Deleting {} from {}'.format(message_id, sender)

@app.route('/table/new-message')
def test_create_message():
return 'New message'
Expand All @@ -181,6 +265,7 @@ def test():
titles = [('id', '#'), ('text', 'Message')]
return render_template_string('''
{% from 'bootstrap/table.html' import render_table %}
# URL arguments with URL tuple
{{ render_table(messages, titles, model=model, show_actions=True,
custom_actions=[
(
Expand All @@ -190,7 +275,10 @@ def test():
)
],
view_url=('test_view_message', [('sender', ':sender'), ('message_id', ':id')]),
new_url=url_for('test_create_message')) }}
edit_url=('test_edit_message', [('sender', ':sender'), ('message_id', ':id')]),
delete_url=('test_delete_message', [('sender', ':sender'), ('message_id', ':id')]),
new_url=('test_create_message')
) }}
''', titles=titles, model=Message, messages=messages)

response = client.get('/table')
Expand All @@ -199,6 +287,8 @@ def test():
assert 'href="/table/john_doe/1/resend"' in data
assert 'title="Resend">' in data
assert 'href="/table/me/1/view"' in data
assert 'action="/table/me/1/delete"' in data
assert 'href="/table/me/1/edit"' in data
assert 'href="/table/new-message"' in data

def test_customize_icon_title_of_table_actions(self, app, client):
Expand Down

0 comments on commit e427ddd

Please sign in to comment.