From 8a6b54efa7c98ea4d2e2b5d82a95b04cef4ff3ab Mon Sep 17 00:00:00 2001 From: Nicko van Someren Date: Fri, 24 Mar 2023 12:35:55 -0600 Subject: [PATCH 1/3] Fix #268. Allow table actions when data is provided using non-SQLAlchemy objects or dicts. --- docs/macros.rst | 14 +++++++++----- flask_bootstrap/templates/base/table.html | 16 +++++++--------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/macros.rst b/docs/macros.rst index 0d8c578a..4b0184f5 100644 --- a/docs/macros.rst +++ b/docs/macros.rst @@ -519,7 +519,9 @@ API using ``|urlize``. Is overruled by ``safe_columns`` parameter. Default is ``None``. WARNING: Only use this for sanitized user data to prevent XSS attacks. :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 model: An optional model used to build custom_action, view, edit, + delete URLs. Set this if you need to pull the URL arguments from + a different SQLAlchemy class indexed with the same primary key. :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 tuple or fixed URL string') @@ -541,8 +543,10 @@ an URL tuple in the form of ``('endpoint', [('url_parameter_name', ':db_model_fi 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. +By default, Bootstrap-Flask will take the fields from the row data provided. +Alternatively, you may set the ``model``, in which case a record from that +model, indexed with the same primary key, will be used to get the actual +value when building the URL. For example, for the view below: @@ -563,13 +567,13 @@ Here is the full example: @app.route('/test') def test(): data = Message.query.all() - return render_template('test.html', data=data, Message=Message) + return render_template('test.html', data=data) .. code-block:: jinja {% from 'bootstrap4/table.html' import render_table %} - {{ render_table(data, model=Message, view_url=('view_message', [('message_id', ':id')])) }} + {{ render_table(data, view_url=('view_message', [('message_id', ':id')])) }} The following arguments are expect to accpet an URL tuple: diff --git a/flask_bootstrap/templates/base/table.html b/flask_bootstrap/templates/base/table.html index bf561a76..40751b31 100644 --- a/flask_bootstrap/templates/base/table.html +++ b/flask_bootstrap/templates/base/table.html @@ -1,14 +1,13 @@ {% from 'base/utils.html' import render_icon, arg_url_for %} -{% macro build_url(endpoint, model, pk, url_tuples) %} - {% if model == None %} - {{ raise("The model argument can't be None when setting action URLs.") }} +{% macro build_url(record, endpoint, url_tuples, model, pk_field) %} + {% if model != None %} + {% set record = model.query.get(record[pk_field]) %} {% 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 url_parameter, db_field in url_tuples %} {% if db_field.startswith(':') and '.' in db_field %} {%- set db_field = db_field[1:].split('.') -%} @@ -20,7 +19,6 @@ {%- do url_params.update({url_parameter: db_field}) -%} {% endif %} {% endfor %} - {% endwith -%} {{ arg_url_for(endpoint, url_params) }} {%- endwith %} {%- endmacro %} @@ -115,7 +113,7 @@ {% if action_url is string %} href="{{ action_url }}" {% else %} - href="{{ build_url(action_url[0], model, row[primary_key], action_url[1]) | trim }}" + href="{{ build_url(row, action_url[0], action_url[1], model, primary_key) | trim }}" {% endif %} title="{{ action_name }}">{{ render_icon(action_icon) }} {% endfor %} @@ -125,7 +123,7 @@ {% if view_url is string %} href="{{ view_url }}" {% else %} - href="{{ build_url(view_url[0], model, row[primary_key], view_url[1]) | trim }}" + href="{{ build_url(row, view_url[0], view_url[1], model, primary_key) | trim }}" {% endif %} title="{{ config['BOOTSTRAP_TABLE_VIEW_TITLE'] }}"> {{ render_icon('eye-fill') }} @@ -136,7 +134,7 @@ {% if edit_url is string %} href="{{ edit_url }}" {% else %} - href="{{ build_url(edit_url[0], model, row[primary_key], edit_url[1]) | trim }}" + href="{{ build_url(row, edit_url[0], edit_url[1], model, primary_key) | trim }}" {% endif %} title="{{ config['BOOTSTRAP_TABLE_EDIT_TITLE'] }}"> {{ render_icon('pencil-fill') }} @@ -147,7 +145,7 @@ {% if delete_url is string %} action="{{ delete_url }}" {% else %} - action="{{ build_url(delete_url[0], model, row[primary_key], delete_url[1]) | trim }}" + action="{{ build_url(row, delete_url[0], delete_url[1], model, primary_key) | trim }}" {% endif %} method="post"> {% if csrf_token is undefined %} From aa3dd43a0a37b44513a33bd19b58c2384ec8baa7 Mon Sep 17 00:00:00 2001 From: Nicko van Someren Date: Mon, 3 Apr 2023 21:31:02 -0600 Subject: [PATCH 2/3] Added a variation of the table action test case to test actions when SQLAlchemy is not used. --- tests/test_bootstrap4/test_render_table.py | 63 ++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/test_bootstrap4/test_render_table.py b/tests/test_bootstrap4/test_render_table.py index 2efc87dd..f4b0f99f 100644 --- a/tests/test_bootstrap4/test_render_table.py +++ b/tests/test_bootstrap4/test_render_table.py @@ -288,6 +288,69 @@ def test(): assert 'href="/table/new-message"' in data +def test_render_table_with_actions_no_db(app, client): # noqa: C901 + app.jinja_env.globals['csrf_token'] = lambda: '' + + @app.route('/table///resend') + def test_resend_message(recipient, message_id): + return f'Re-sending {message_id} to {recipient}' + + @app.route('/table///view') + def test_view_message(sender, message_id): + return f'Viewing {message_id} from {sender}' + + @app.route('/table///edit') + def test_edit_message(sender, message_id): + return f'Editing {message_id} from {sender}' + + @app.route('/table///delete') + def test_delete_message(sender, message_id): + return f'Deleting {message_id} from {sender}' + + @app.route('/table/new-message') + def test_create_message(): + return 'New message' + + @app.route('/table') + def test(): + row_dicts = [{ + "id": i+1, + "text": f'Test message {i + 1}', + "sender": 'me', + "recipient": 'john_doe' + } for i in range(10)] + + messages = row_dicts + titles = [('id', '#'), ('text', 'Message')] + return render_template_string(''' + {% from 'bootstrap4/table.html' import render_table %} + # URL arguments with URL tuple + {{ render_table(messages, titles, show_actions=True, + custom_actions=[ + ( + 'Resend', + 'bootstrap-reboot', + ('test_resend_message', [('recipient', ':recipient'), ('message_id', ':id')]) + ) + ], + view_url=('test_view_message', [('sender', ':sender'), ('message_id', ':id')]), + 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, 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/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(app, client): app.config['BOOTSTRAP_TABLE_VIEW_TITLE'] = 'Read' From 1b850645ccff349e0ffc0706778c6bbbe33fcebb Mon Sep 17 00:00:00 2001 From: Grey Li Date: Tue, 4 Apr 2023 17:33:27 +0800 Subject: [PATCH 3/3] Improve tests & update changelog --- CHANGES.rst | 2 + tests/test_bootstrap4/test_render_table.py | 59 +++++----------------- 2 files changed, 15 insertions(+), 46 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index dd1141d2..af3ed75c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,6 +7,8 @@ Changelog Release date: - +- Support creating action URLs for dict data (`#268 `__). + 2.2.0 ----- diff --git a/tests/test_bootstrap4/test_render_table.py b/tests/test_bootstrap4/test_render_table.py index f4b0f99f..2211dd1a 100644 --- a/tests/test_bootstrap4/test_render_table.py +++ b/tests/test_bootstrap4/test_render_table.py @@ -244,7 +244,7 @@ def test_create_message(): return 'New message' @app.route('/table') - def test(): + def table(): db.drop_all() db.create_all() for i in range(10): @@ -277,42 +277,8 @@ def test(): ) }} ''', 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/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_render_table_with_actions_no_db(app, client): # noqa: C901 - app.jinja_env.globals['csrf_token'] = lambda: '' - - @app.route('/table///resend') - def test_resend_message(recipient, message_id): - return f'Re-sending {message_id} to {recipient}' - - @app.route('/table///view') - def test_view_message(sender, message_id): - return f'Viewing {message_id} from {sender}' - - @app.route('/table///edit') - def test_edit_message(sender, message_id): - return f'Editing {message_id} from {sender}' - - @app.route('/table///delete') - def test_delete_message(sender, message_id): - return f'Deleting {message_id} from {sender}' - - @app.route('/table/new-message') - def test_create_message(): - return 'New message' - - @app.route('/table') - def test(): + @app.route('/table-with-dict-data') + def dict_data_table(): row_dicts = [{ "id": i+1, "text": f'Test message {i + 1}', @@ -340,15 +306,16 @@ def test(): ) }} ''', titles=titles, 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/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 + for url in ['/table', '/table-with-dict-data']: + response = client.get(url) + data = response.get_data(as_text=True) + assert 'icons/bootstrap-icons.svg#bootstrap-reboot' in data + 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(app, client):