diff --git a/examples/peewee_simple/main.py b/examples/peewee_simple/main.py index 037e3172e..5399a7efa 100644 --- a/examples/peewee_simple/main.py +++ b/examples/peewee_simple/main.py @@ -1,3 +1,5 @@ +import datetime +import random import uuid import peewee @@ -152,4 +154,21 @@ def index(): BookCategory.get_or_create(isbn=book1, category=cat1) BookCategory.get_or_create(isbn=book2, category=cat2) + # Seed additional users and posts for list pagination verification + users = [] + for idx in range(1, 21): + user, _ = User.get_or_create( + username=f"user{idx}", + defaults={"email": f"user{idx}@example.com"}, + ) + users.append(user) + + for idx in range(1, 51): + Post.create( + title=f"Sample Post {idx}", + text=f"Lorem ipsum dolor sit amet, generated entry {idx}.", + date=datetime.datetime.utcnow() - datetime.timedelta(days=idx), + user=random.choice(users), + ) + app.run(debug=True) diff --git a/flask_admin/static/admin/css/bootstrap4/admin.css b/flask_admin/static/admin/css/bootstrap4/admin.css index 0683a3b3b..32f931dab 100644 --- a/flask_admin/static/admin/css/bootstrap4/admin.css +++ b/flask_admin/static/admin/css/bootstrap4/admin.css @@ -23,7 +23,8 @@ /* List View - fix checkbox column width */ .list-checkbox-column { - width: 14px; + width: auto; + min-width: 40px; } /* List View - fix overlapping border between actions and table */ @@ -43,31 +44,166 @@ white-space: nowrap; } +/* List control tabs */ +.nav-tabs.nav-list-controls { + flex-wrap: wrap; + align-items: center; +} + +.nav-tabs.nav-list-controls .nav-item { + margin-right: 0.5rem; + margin-bottom: 0.4rem; +} + +.nav-tabs.nav-list-controls .nav-link { + white-space: nowrap; +} + +.nav-item.nav-search { + order: 1; + margin-bottom: 0; + margin-left: auto; +} + +.nav-item.nav-search .nav-search-form { + width: auto; +} + +.nav-search-form { + width: auto; + min-width: 240px; + max-width: 320px; +} + +.nav-search-form .search-controls { + width: auto; + gap: 0.5rem; + flex-wrap: nowrap; + align-items: center; + flex-direction: row; +} + +.nav-search-form .search-controls .input-group { + flex: 1 1 auto; + min-width: 0; +} + +.nav-search-form .search-controls button { + white-space: nowrap; +} + +.nav-search-form .search-controls .input-group { + flex: 1 1 auto; + min-width: 0; +} + +.nav-search-form .search-controls input.form-control { + min-width: 0; +} + +.nav-search-form .search-controls button { + flex: 0 0 auto; +} + +@media (min-width: 576px) { + .nav-item.nav-search { + max-width: 360px; + } + + .nav-tabs.nav-list-controls .nav-item { + margin-bottom: 0.25rem; + } +} + +@media (max-width: 991px) and (min-width: 577px) { + .nav-item.nav-search { + margin-left: 0; + order: 0; + max-width: none; + } +} + +@media (max-width: 576px) { + .nav-item.nav-search { + flex: 1 1 100%; + margin-left: 0; + max-width: none; + } + + .nav-item.nav-search .nav-search-form { + width: 100%; + } + + .nav-search-form { + width: 100%; + } + + .nav-search-form .search-controls { + width: 100%; + flex-direction: column; + } + + .nav-search-form .search-controls button { + width: 100%; + } +} + /* Filters */ -table.filters { - border-collapse: collapse; - border-spacing: 4px; +.filters { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin: 12px 0 20px 0; +} + +.filters:empty { + margin: 0; +} + +.filter-item { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 0.5rem 1rem; + padding: 0.75rem 1rem; + border: 1px solid #dee2e6; + border-radius: 0.25rem; + background-color: #fff; +} + +.filter-label { + flex: 0 0 auto; + min-width: 160px; + display: flex; + align-items: center; + gap: 0.5rem; } -/* prevents gap between table and actions while there are no filters set */ -table.filters:not(:empty) { - margin: 12px 0px 20px 0px; +.filter-operation { + flex: 0 0 auto; + min-width: 120px; } -/* spacing between filter X button, operation, and value field */ -/* uses tables instead of form classes for bootstrap2-3 compatibility */ -table.filters tr td { - padding-right: 5px; - padding-bottom: 3px; +.filter-field { + flex: 1 1 auto; + min-width: 150px; } -/* Filters - Select2 Boxes */ .filters .filter-op { - width: 130px; + width: auto; + min-width: 120px; } .filters .filter-val { - width: 220px; + width: auto; + min-width: 200px; + flex: 1 1 auto; +} + +.filter-label .remove-filter { + display: inline-flex; + align-items: center; + gap: 0.35rem; } /* Image thumbnails */ @@ -83,6 +219,24 @@ div.container > .admin-form { margin-top: 35px; } +/* Submit rows */ +.submit-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; +} + +@media (max-width: 576px) { + .submit-row { + justify-content: center; + } + .submit-row .btn { + flex: 1 1 auto; + min-width: 120px; + } +} + /* Form Field Description - Appears when field has 'description' attribute */ /* Test with: form_args = {'name':{'description': 'test'}} */ /* prevents awkward gap after help-block - This is default for bootstrap2 */ @@ -108,3 +262,71 @@ body.modal-open { { overflow-x: auto; } + +@media (max-width: 576px) { + .table-responsive-mobile .table thead { + display: none; + } + + .table-responsive-mobile .table tbody tr { + display: block; + margin-bottom: 1rem; + border: 1px solid #dee2e6; + border-radius: 0.25rem; + padding: 0.5rem; + } + + .table-responsive-mobile .table tbody td { + display: flex; + justify-content: space-between; + padding: 0.5rem 0; + border: none; + border-bottom: 1px solid #dee2e6; + } + + .table-responsive-mobile .table tbody td:last-child { + border-bottom: none; + } + + .table-responsive-mobile .table tbody td::before { + content: attr(data-label); + font-weight: 600; + margin-right: 0.5rem; + flex: 1 1 40%; + } + + .filters { + gap: 0.5rem; + margin: 0; + } + + .filter-item { + flex-direction: column; + padding: 0.5rem; + } + + .filter-label, + .filter-operation, + .filter-field { + width: 100%; + } + + .list-buttons-column { + white-space: normal; + width: 100%; + } + + .form-control { + width: 100%; + } + + .hide-mobile { + display: none !important; + } +} + +@media (min-width: 577px) and (max-width: 991px) { + .filters .filter-op { + min-width: 100px; + } +} diff --git a/flask_admin/static/admin/js/bs4_filters.js b/flask_admin/static/admin/js/bs4_filters.js index 248b7b65d..f974c266f 100644 --- a/flask_admin/static/admin/js/bs4_filters.js +++ b/flask_admin/static/admin/js/bs4_filters.js @@ -3,6 +3,14 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters var $container = $('.filters', $root); var lastCount = 0; + function updateActionVisibility() { + if ($container.find('.filter-item').length === 0) { + $('button', $root).addClass('d-none'); + } else { + $('button', $root).removeClass('d-none'); + } + } + function getCount(name) { var idx = name.indexOf('_'); @@ -20,40 +28,34 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters } function removeFilter() { - $(this).closest('tr').remove(); - if($('.filters tr').length == 0) { - $('button', $root).addClass('d-none'); - $('.filters tbody').remove(); - } else { - $('button', $root).removeClass('d-none'); - } - + $(this).closest('.filter-item').remove(); + updateActionVisibility(); return false; } - // triggered when the filter operation (equals, not equals, etc) is changed - function changeOperation(subfilters, $el, filter, $select) { - // get the filter_group subfilter based on the index of the selected option - var selectedFilter = subfilters[$select.select2('data').element[0].index]; - var $inputContainer = $el.find('td').last(); + function changeOperation(subfilters, $el, $select) { + var selectData = $select.select2('data'); + var selectedIndex = selectData.length ? selectData[0].element[0].index : 0; + var selectedFilter = subfilters[selectedIndex]; + var $inputContainer = $el.find('.filter-field').first(); - // recreate and style the input field (turn into date range or select2 if necessary) var $field = createFilterInput($inputContainer, null, selectedFilter); styleFilterInput(selectedFilter, $field); $('button', $root).removeClass('d-none'); } - // generate HTML for filter input - allows changing filter input type to one with options or tags function createFilterInput(inputContainer, filterValue, filter) { + inputContainer.empty(); + var $field; + if (filter.type == "select2-tags") { - var $field = $('').attr('name', makeName(filter.arg)); + $field = $('').attr('name', makeName(filter.arg)); $field.val(filterValue); } else if (filter.options) { - var $field = $('').attr('name', makeName(filter.arg)); + $field = $('').attr('name', makeName(filter.arg)); $(filter.options).each(function() { - // for active filter inputs with options, add "selected" if there is a matching active filter if (filterValue && (filterValue == this[0])) { $field.append($('') .val(this[0]).text(this[1]).attr('selected', true)); @@ -63,12 +65,12 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters } }); } else { - var $field = $('').attr('name', makeName(filter.arg)); + $field = $('').attr('name', makeName(filter.arg)); $field.val(filterValue); } - inputContainer.replaceWith($('