implement column sorting

This commit is contained in:
Manuel Gugger 2023-01-13 21:11:42 +01:00
parent dcdc8a1737
commit 4eafe6e40a
14 changed files with 172 additions and 95 deletions

View File

@ -68,8 +68,8 @@ pub fn derive_actix_admin_view_model(input: proc_macro::TokenStream) -> proc_mac
#[actix_admin::prelude::async_trait(?Send)]
impl ActixAdminViewModelTrait for Entity {
async fn list(db: &DatabaseConnection, page: u64, entities_per_page: u64, search: &String) -> Result<(u64, Vec<ActixAdminModel>), ActixAdminError> {
let entities = Entity::list_model(db, page, entities_per_page, search).await;
async fn list(db: &DatabaseConnection, page: u64, entities_per_page: u64, search: &str, sort_by: &str, sort_order: &SortOrder) -> Result<(u64, Vec<ActixAdminModel>), ActixAdminError> {
let entities = Entity::list_model(db, page, entities_per_page, search, sort_by, sort_order).await;
entities
}
@ -155,6 +155,7 @@ pub fn derive_actix_admin_model(input: proc_macro::TokenStream) -> proc_macro::T
let fields_type_path = get_actix_admin_fields_type_path_string(&fields);
let fields_textarea = get_actix_admin_fields_textarea(&fields);
let fields_file_upload = get_actix_admin_fields_file_upload(&fields);
let fields_match_name_to_columns = get_match_name_to_column(&fields);
let expanded = quote! {
actix_admin::prelude::lazy_static! {
@ -236,14 +237,25 @@ pub fn derive_actix_admin_model(input: proc_macro::TokenStream) -> proc_macro::T
#[actix_admin::prelude::async_trait]
impl ActixAdminModelTrait for Entity {
async fn list_model(db: &DatabaseConnection, page: u64, posts_per_page: u64, search: &String) -> Result<(u64, Vec<ActixAdminModel>), ActixAdminError> {
async fn list_model(db: &DatabaseConnection, page: u64, posts_per_page: u64, search: &str, sort_by: &str, sort_order: &SortOrder) -> Result<(u64, Vec<ActixAdminModel>), ActixAdminError> {
use sea_orm::{ query::* };
let paginator = Entity::find()
let sort_column = match sort_by {
#(#fields_match_name_to_columns)*
_ => panic!("Unknown column")
};
let query = if sort_order.eq(&SortOrder::Asc) {
Entity::find().order_by_asc(sort_column)
} else {
Entity::find().order_by_desc(sort_column)
};
let paginator = query
.filter(
Condition::any()
#(#fields_searchable)*
)
.order_by_asc(Column::Id)
.paginate(db, posts_per_page);
let num_pages = paginator.num_pages().await?;
let mut model_entities = Vec::new();

View File

@ -17,7 +17,15 @@ pub fn get_fields_for_tokenstream(input: proc_macro::TokenStream) -> std::vec::V
}
fn capitalize_first_letter(s: &str) -> String {
s[0..1].to_uppercase() + &s[1..]
if s.len() > 0 {
s[0..1].to_uppercase() + &s[1..]
} else {
String::new()
}
}
fn to_camelcase(s: &str) -> String {
s.split("_").fold(String::new(), |a, b| capitalize_first_letter(&a) + &capitalize_first_letter(b))
}
pub fn filter_fields(fields: &Fields) -> Vec<ModelField> {
@ -208,12 +216,26 @@ pub fn get_actix_admin_fields_file_upload(fields: &Vec<ModelField>) -> Vec<Token
.collect::<Vec<_>>()
}
pub fn get_match_name_to_column(fields: &Vec<ModelField>) -> Vec<TokenStream> {
fields
.iter()
.map(|model_field| {
let column_name = model_field.ident.to_string();
let column_name_capitalized = to_camelcase(&column_name);
let column_ident = Ident::new(&column_name_capitalized, Span::call_site());
quote! {
#column_name => Column::#column_ident,
}
})
.collect::<Vec<_>>()
}
pub fn get_actix_admin_fields_searchable(fields: &Vec<ModelField>) -> Vec<TokenStream> {
fields
.iter()
.filter(|model_field| model_field.searchable)
.map(|model_field| {
let column_name = format!("{}", capitalize_first_letter(&model_field.ident.to_string()));
let column_name = capitalize_first_letter(&model_field.ident.to_string());
let column_ident = Ident::new(&column_name, Span::call_site());
quote! {
.add(Column::#column_ident.contains(&search))

View File

@ -146,7 +146,7 @@ impl ActixAdminBuilderTrait for ActixAdminBuilder {
if add_to_menu {
let menu_element = ActixAdminMenuElement {
name: menu_element_name.to_string(),
link: format!("{}", path.replacen("/", "", 1)),
link: path.replacen("/", "", 1),
is_custom_handler: true,
};
let category = self.actix_admin.entity_names.get_mut("");

View File

@ -6,7 +6,7 @@
use lazy_static::lazy_static;
use sea_orm::DatabaseConnection;
use serde::Serialize;
use serde::{Serialize };
use std::collections::HashMap;
use tera::{Tera, Result, to_value, try_get_value };
use std::{ hash::BuildHasher};
@ -31,7 +31,7 @@ pub mod prelude {
pub use actix_admin_macros::{ DeriveActixAdmin, DeriveActixAdminModel, DeriveActixAdminViewModel, DeriveActixAdminEnumSelectList, DeriveActixAdminModelSelectList };
pub use crate::{ ActixAdminError, ActixAdminAppDataTrait, ActixAdmin, ActixAdminConfiguration };
pub use crate::{ hashmap, ActixAdminSelectListTrait };
pub use crate::routes::{ create_or_edit_post, get_admin_ctx };
pub use crate::routes::{ create_or_edit_post, get_admin_ctx, SortOrder };
pub use crate::{ TERA };
pub use itertools::izip;
pub use lazy_static::lazy_static;

View File

@ -1,3 +1,4 @@
use crate::routes::SortOrder;
use crate::{ActixAdminError, ActixAdminViewModelField};
use actix_multipart::{Multipart, MultipartError};
use actix_web::web::Bytes;
@ -17,7 +18,9 @@ pub trait ActixAdminModelTrait {
db: &DatabaseConnection,
page: u64,
posts_per_page: u64,
search: &String,
search: &str,
sort_by: &str,
sort_order: &SortOrder
) -> Result<(u64, Vec<ActixAdminModel>), ActixAdminError>;
fn get_fields() -> &'static [ActixAdminViewModelField];
fn validate_model(model: &mut ActixAdminModel);

View File

@ -57,7 +57,7 @@ pub async fn delete_many<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>
session: Session,
_req: HttpRequest,
data: web::Data<T>,
form: web::Form<Vec<(String, i32)>>,
form: web::Form<Vec<(String, String)>>,
) -> Result<HttpResponse, Error> {
let actix_admin = data.get_actix_admin();
let entity_name = E::get_entity_name();
@ -74,7 +74,7 @@ pub async fn delete_many<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>
let db = &data.get_db();
let entity_name = E::get_entity_name();
let ids: Vec<i32> = form.iter().map(|el| el.1).collect();
let ids: Vec<i32> = form.iter().filter(|el| el.0 == "ids").map(|el| el.1.parse::<i32>().unwrap()).collect();
// TODO: implement delete_many
for id in ids {
@ -104,11 +104,32 @@ pub async fn delete_many<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>
}
}
let entities_per_page = form.iter()
.find(|el| el.0 == "entities_per_page")
.map(|e| e.1.to_string())
.unwrap_or("10".to_string());
let search = form.iter()
.find(|el| el.0 == "search")
.map(|e| e.1.to_string())
.unwrap_or_default();
let sort_by = form.iter()
.find(|el| el.0 == "sort_by")
.map(|e| e.1.to_string())
.unwrap_or("id".to_string());
let sort_order = form.iter()
.find(|el| el.0 == "sort_order")
.map(|e| e.1.to_string())
.unwrap_or("Asc".to_string());
let page = form.iter()
.find(|el| el.0 == "page")
.map(|e| e.1.to_string())
.unwrap_or("1".to_string());
match errors.is_empty() {
true => Ok(HttpResponse::SeeOther()
.append_header((
header::LOCATION,
format!("/admin/{}/list?render_partial=true", entity_name),
format!("/admin/{}/list?render_partial=true&entities_per_page={}&search={}&sort_by={}&sort_order={}&page={}", entity_name, entities_per_page, search, sort_by, sort_order, page),
))
.finish()),
false => Ok(HttpResponse::InternalServerError().finish()),

View File

@ -2,7 +2,7 @@ use actix_web::{error, web, Error, HttpRequest, HttpResponse};
use serde::Serialize;
use serde::{Deserialize};
use tera::{Context};
use crate::prelude::*;
use crate::{prelude::*};
use crate::ActixAdminViewModelTrait;
use crate::ActixAdminViewModel;
@ -14,12 +14,6 @@ use super::{ add_auth_context, user_can_access_page, render_unauthorized};
const DEFAULT_ENTITIES_PER_PAGE: u64 = 10;
#[derive(Debug, Serialize, Deserialize)]
pub enum SortOrder {
Asc,
Desc,
}
#[derive(Debug, Deserialize)]
pub struct Params {
page: Option<u64>,
@ -30,6 +24,12 @@ pub struct Params {
sort_order: Option<SortOrder>
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum SortOrder {
Asc,
Desc,
}
pub async fn list<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>(
session: Session,
req: HttpRequest,
@ -61,7 +61,8 @@ pub async fn list<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>(
let db = data.get_db();
let sort_by = params.sort_by.clone().unwrap_or(view_model.primary_key.to_string());
let sort_order = params.sort_order.as_ref().unwrap_or(&SortOrder::Asc);
let result = E::list(db, page, entities_per_page, &search).await;
let result = E::list(db, page, entities_per_page, &search, &sort_by, &sort_order).await;
match result {
Ok(res) => {
let entities = res.1;
@ -101,8 +102,6 @@ pub async fn list<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>(
ctx.insert("search", &search);
ctx.insert("sort_by", &sort_by);
ctx.insert("sort_order", &sort_order);
ctx.insert("sort_order_asc", &SortOrder::Asc);
ctx.insert("sort_order_desc", &SortOrder::Desc);
let body = TERA
.render("list.html", &ctx)

View File

@ -8,7 +8,7 @@ mod index;
pub use index::{ index, not_found, get_admin_ctx };
mod list;
pub use list::{ list };
pub use list::{ list, SortOrder };
mod show;
pub use show::show;

View File

@ -2,7 +2,7 @@ use async_trait::async_trait;
use sea_orm::DatabaseConnection;
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
use crate::ActixAdminModel;
use crate::{ActixAdminModel, SortOrder};
use actix_session::{Session};
use std::convert::From;
use crate::ActixAdminError;
@ -13,7 +13,9 @@ pub trait ActixAdminViewModelTrait {
db: &DatabaseConnection,
page: u64,
entities_per_page: u64,
search: &String
search: &str,
sort_by: &str,
sort_order: &SortOrder
) -> Result<(u64, Vec<ActixAdminModel>), ActixAdminError>;
// TODO: Replace return value with proper Result Type containing Ok or Err

View File

@ -31,14 +31,14 @@ function checkAll(bx) {
}
function sort_by(column) {
document.getElementById("sort_by").value = column;
current_sort_order = document.getElementById("sort_order").value;
if (current_sort_order == "{{ sort_order_asc }}") {
document.getElementById("sort_order").value = "{{ sort_order_desc }}";
current_sort_order = document.getElementsByName("sort_order")[0].value;
if (current_sort_order == "Asc") {
document.getElementsByName("sort_order").forEach((e) => e.value = "Desc");
} else {
document.getElementById("sort_order").value = "{{ sort_order_asc }}";
document.getElementsByName("sort_order").forEach((e) => e.value = "Asc");
}
document.getElementById('search_form').submit();
document.getElementsByName("sort_by").forEach((e) => e.value = column);
document.getElementById('table_form').requestSubmit();
}
document.addEventListener('DOMContentLoaded', () => {

View File

@ -24,7 +24,7 @@
{% endfor %}
{% endif %}
</div>
<div>
<div id="content">
{% block content %}
{% endblock content %}
</div>

View File

@ -8,7 +8,7 @@
<div class="buttons">
<a class="button is-primary" href="create" hx-boost="true" hx-indicator="#loading">Create</a>
<div hx-include="#checked-rows" hx-target="#{{ entity_name }}table" class="dropdown is-hoverable">
<div class="dropdown is-hoverable">
<div class="dropdown-trigger">
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu4">
<span>With selected</span>
@ -20,38 +20,35 @@
<div class="dropdown-menu" id="dropdown-menu4">
<div class="dropdown-content">
<div class="dropdown-item">
<a href="#" hx-indicator="#loading" hx-confirm="Are you sure?" hx-delete="delete">Delete</a>
<a hx-include="#table_form" hx-target="#{{ entity_name }}table" href="#"
hx-indicator="#loading" hx-confirm="Are you sure?" hx-delete="delete">Delete</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="column is-narrow">
{% if view_model.show_search %}
<div class="field">
<p class="control has-icons-left has-icons-right">
<input class="input is-rounded" type="search" id="search" value="{{ search }}" name="search"
placeholder="Search"
hx-get="/admin/{{ entity_name }}/list?render_partial=true&entities_per_page={{ entities_per_page }}&page={{ page }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
hx-trigger="keyup changed delay:500ms, search" hx-target="#{{ entity_name }}table"
hx-indicator="#loading">
<span class="icon is-small is-left">
<i class="fas fa-search"></i>
</span>
</p>
</div>
{% endif %}
</div>
<div class="column is-narrow">
<div>
<form id="search_form" hx-boost="true" hx-indicator="#loading">
<input type="hidden" value="{{ search }}" name="search">
<input type="hidden" id="sort_by" name="sort_by" value="{{ sort_by}}">
<input type="hidden" id="sort_order" name="sort_order" value="{{ sort_order }}">
<form id="search_form" action="/admin/{{ entity_name }}/list" hx-boost="true" hx-indicator="#loading"
hx-target="#{{ entity_name }}table" hx-trigger="reload_table from:#entities_per_page"
hx-vals='{ "render_partial" : "true" }'>
<input type="hidden" id="sort_by" name="sort_by" value="{{ sort_by }}">
<input type="hidden" id="sort_order" name="sort_order" value="{{ sort_order }}">
<div class="column is-narrow">
<div class="field is-horizontal">
{% if view_model.show_search %}
<p class="control has-icons-left has-icons-right">
<input class="input is-rounded" type="search" id="search" value="{{ search }}" name="search"
placeholder="Search" hx-get="/admin/{{ entity_name }}/list"
hx-trigger="keyup changed delay:500ms, search">
<span class="icon is-small is-left">
<i class="fas fa-search"></i>
</span>
</p>
{% endif %}
<div class="select">
<div class="control has-icons-left has-icons-right">
<select class="select" name="entities_per_page" onchange="this.form.submit()">
<div class="ml-1 control has-icons-left has-icons-right">
<select id="entities_per_page" class="select" name="entities_per_page"
onchange="this.dispatchEvent(new Event('reload_table'));">
{% for a in [10,20,50,100,] %}
<option {% if entities_per_page==a %}selected{% endif %} value="{{ a }}">{{ a }}</option>
{% endfor %}
@ -59,19 +56,24 @@
<p class="help">Entities per Page</p>
</div>
</div>
</form>
</div>
</div>
</div>
</form>
</div>
{% endif %}
<div id="{{ entity_name }}table">
<form id="checked-rows">
<div class="is-relative">
{% include "loader.html" %}
<table class="table is-relative is-fullwidth is-hoverable is-striped">
<thead>
<div class="is-relative">
{% include "loader.html" %}
<form id="table_form" hx-indicator="#loading" hx-get="/admin/{{ entity_name }}/list"
hx-vals='{ "render_partial" : "true" }' hx-target="#{{ entity_name }}table">
<input type="hidden" id="sort_by" name="sort_by" value="{{ sort_by }}">
<input type="hidden" id="sort_order" name="sort_order" value="{{ sort_order }}">
<input type="hidden" name="entities_per_page" value="{{ entities_per_page }}">
<input type="hidden" name="search" value="{{ search }}">
<input type="hidden" name="page" value="{{ page }}">
<table class="table is-relative is-fullwidth is-hoverable is-striped">
<thead>
<tr>
<th>
<input type="checkbox" onclick="checkAll(this)">
@ -79,9 +81,9 @@
<th onclick="sort_by('{{ view_model.primary_key }}');" class="is-clickable">{{
view_model.primary_key | title }}
{% if sort_by == view_model.primary_key %}
{% if sort_order == "{{ sort_order_asc }}" %}
{% if sort_order == "Asc" %}
<i class="ml-1 fa-solid fa-caret-up"></i>
{% elif sort_order == "{{ sort_order_desc }}" %}
{% elif sort_order == "Desc" %}
<i class="ml-1 fa-solid fa-caret-down"></i>
{% endif %}
{% endif %}
@ -90,9 +92,9 @@
<th onclick="sort_by('{{ model_field.field_name }}');" class="is-clickable">{{
model_field.field_name | split(pat="_") | join(sep=" ") | title }}
{% if sort_by == model_field.field_name %}
{% if sort_order == "{{ sort_order_asc }}" %}
{% if sort_order == "Asc" %}
<i class="ml-1 fa-solid fa-caret-up"></i>
{% elif sort_order == "{{ sort_order_desc }}" %}
{% elif sort_order == "Desc" %}
<i class="ml-1 fa-solid fa-caret-down"></i>
{% endif %}
{% endif %}
@ -103,8 +105,9 @@
<!-- Delete Action -->
</th>
</tr>
</thead>
<tbody hx-confirm="Are you sure?" hx-target="closest tr" hx-indicator="#loading" hx-swap="outerHTML">
</form>
</thead>
<tbody hx-indicator="#loading" hx-boost="true">
{% for entity in entities -%}
<tr>
<td><input type="checkbox" name="ids" value="{{ entity.primary_key }}"></td>
@ -125,37 +128,47 @@
{% endif %}
{%- endfor %}
<td>
<a href="edit/{{ entity.primary_key }}"><i class="fa-solid fa-pen-to-square"></i></a>
<a hx-delete="delete/{{ entity.primary_key }}"><i class="fa-solid fa-trash"></i></a>
<a hx-target="body" href="edit/{{ entity.primary_key }}"><i class="fa-solid fa-pen-to-square"></i></a>
<a hx-target="closest tr" hx-confirm="Are you sure?"
hx-delete="delete/{{ entity.primary_key }}"><i class="fa-solid fa-trash"></i></a>
</td>
</tr>
{%- endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="{{ view_model.fields | length + 3 }}">
</td>
</tr>
</tfoot>
</table>
</div>
</tbody>
<tfoot>
<tr>
<td colspan="{{ view_model.fields | length + 3 }}">
</td>
</tr>
</tfoot>
</table>
</form>
<nav hx-boost="true" hx-indicator="#loading" class="pagination is-rounded is-centered" role="pagination"
</div>
<nav hx-boost="true"
hx-push-url="true"
hx-target="#{{ entity_name }}table"
hx-vals='{
"entities_per_page" : "{{ entities_per_page }}",
"search" : "{{ search }}",
"sort_by" : "{{ sort_by }}",
"sort_order" : "{{ sort_order }}",
"render_partial" : "true"
}' hx-indicator="#loading" class="pagination is-rounded is-centered" role="pagination"
aria-label="pagination">
{% if page > 1 %}
<a href="?page={{ page - 1 }}&entities_per_page={{ entities_per_page }}&search={{ search }}"
<a href="?&page={{ page - 1 }}"
class="pagination-previous left-arrow-click"><i class="fa-solid fa-arrow-left"></i>
</a>
{% endif %}
{% if page < num_pages %} <a
href="?page={{ page + 1 }}&entities_per_page={{ entities_per_page }}&search={{ search }}"
href="?page={{ page + 1 }}"
class="pagination-next right-arrow-click"><i class="fa-solid fa-arrow-right"></i>
</a>
{% endif %}
<ul class="pagination-list">
<li>
<a class="pagination-link {% if page == 1 %}is-current{% endif %}"
href="?page={{ 1 }}&entities_per_page={{ entities_per_page }}&search={{ search }}"
href="?page={{ 1 }}"
aria-label="Goto page 1">1</a>
</li>
<li>
@ -164,14 +177,14 @@
{% for i in range(start=min_show_page,end=max_show_page) %}
<li><a class="pagination-link {% if page == i+1 %}is-current{% endif %}"
aria-label="Goto page {{ i + 1 }}"
href="?page={{ i + 1 }}&entities_per_page={{ entities_per_page }}&search={{ search }}">{{
href="?page={{ i + 1 }}">{{
i + 1 }}</a></li>
{%- endfor %}
<li>
<span class="pagination-ellipsis">&hellip;</span>
</li>
<li>
<a href="?page={{ num_pages }}&entities_per_page={{ entities_per_page }}&search={{ search }}"
<a href="?page={{ num_pages }}"
class="pagination-link is-rounded {% if page == num_pages %}is-current{% endif %}"
aria-label="Goto page {{ num_pages }}">{{ num_pages }} </a>
</li>

View File

@ -4,14 +4,14 @@
Actix Admin
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbar">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu">
<div id="navbar" class="navbar-menu">
<div class="navbar-start">
{% for category, entities in entity_names %}
{% if category == "" %}

View File

@ -78,8 +78,13 @@ mod get_request_is_success {
let page_size = 20; // Verify with default size in list.rs
let url = format!("/admin/{}/list?page={}&entities_per_page={}", crate::Comment::get_entity_name(), page, page_size);
let entities = crate::Comment::find()
.order_by_asc(crate::comment::Column::Id)
let query = if page_size == 5 {
crate::Comment::find().order_by_asc(crate::comment::Column::Id)
} else {
crate::Comment::find().order_by_asc(crate::comment::Column::Id)
};
let entities = query
.paginate(&db, page_size)
.fetch_page(page-1)
.await