From 100923a939c8766487775de2a2ec9f9ddfffe7b9 Mon Sep 17 00:00:00 2001 From: Michael Hill Date: Wed, 1 Sep 2021 23:22:13 -0400 Subject: [PATCH] Fixed compatibility with `Flask`'s built-in url type-converters (#146) * 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 --- CHANGES.rst | 2 + docs/macros.rst | 17 ++-- .../templates/bootstrap/pagination.html | 17 +--- .../templates/bootstrap/table.html | 83 ++++++++++++++----- .../templates/bootstrap/utils.html | 10 +++ tests/test_render_table.py | 47 +++++++---- 6 files changed, 120 insertions(+), 56 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 9dcf3f02..9aafd59f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -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 `__). 1.7.0 diff --git a/docs/macros.rst b/docs/macros.rst index 3845c0c6..486f5944 100644 --- a/docs/macros.rst +++ b/docs/macros.rst @@ -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,\ @@ -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. diff --git a/flask_bootstrap/templates/bootstrap/pagination.html b/flask_bootstrap/templates/bootstrap/pagination.html index 5ef40eb9..f332f0b9 100644 --- a/flask_bootstrap/templates/bootstrap/pagination.html +++ b/flask_bootstrap/templates/bootstrap/pagination.html @@ -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='', @@ -24,16 +25,6 @@ {%- 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=('«')|safe, @@ -55,7 +46,7 @@ {# prev and next are only show if a symbol has been passed. #} {% if prev != None -%}
  • - {{ prev }} + {{ prev }}
  • {%- endif -%} @@ -63,7 +54,7 @@ {% if page %} {% if page != pagination.page %}
  • - {{ page }} + {{ page }}
  • {% else %}
  • @@ -77,7 +68,7 @@ {% if next != None -%}
  • - {{ next }} + {{ next }}
  • {%- endif -%} diff --git a/flask_bootstrap/templates/bootstrap/table.html b/flask_bootstrap/templates/bootstrap/table.html index a21deaee..2a3d1932 100644 --- a/flask_bootstrap/templates/bootstrap/table.html +++ b/flask_bootstrap/templates/bootstrap/table.html @@ -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, @@ -15,6 +35,7 @@ header_classes=None, responsive=False, responsive_class='table-responsive', + model=None, show_actions=False, actions_title='Actions', custom_actions=None, @@ -40,9 +61,11 @@ {% endfor %} {% if show_actions %} {{ actions_title }} - {% if new_url %} + {% if new_url %} + {{ render_icon('plus-circle-fill') }} - {% endif %} + + {% endif %} {% endif %} @@ -61,43 +84,61 @@ {% 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 %} {{ render_icon(action_icon) }} {% 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 %} - - {{ render_icon('eye-fill') }} - + + {{ render_icon('eye-fill') }} + {% 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 %} - - {{ render_icon('pencil-fill') }} - - {% endif %} + + {{ render_icon('pencil-fill') }} + + {%- 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 %} -
    + {% 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 %} diff --git a/tests/test_render_table.py b/tests/test_render_table.py index f6a1bb8a..2606f5c2 100644 --- a/tests/test_render_table.py +++ b/tests/test_render_table.py @@ -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//resend') - def test_resend_message(message_id): - return 'Re-sending {}'.format(message_id) + @app.route('/table///resend') + def test_resend_message(recipient, message_id): + return 'Re-sending {} to {}'.format(message_id, recipient) - @app.route('/table//view') - def test_view_message(message_id): - return 'Viewing {}'.format(message_id) + @app.route('/table///view') + def test_view_message(sender, message_id): + return 'Viewing {} from {}'.format(message_id, sender) @app.route('/table/new-message') def test_create_message(): @@ -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) @@ -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): @@ -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)