initial filter implementation

This commit is contained in:
Manuel Gugger 2023-06-16 16:17:28 +02:00
parent de5d3826ed
commit 3095929d76
15 changed files with 373 additions and 235 deletions

View File

@ -35,6 +35,7 @@ sea-orm = { version = "^0.11.3", features = [], default-features = false }
actix-admin-macros = { version = "0.4.0", path = "actix_admin_macros" }
derive_more = "0.99.17"
regex = "1.8.4"
urlencoding = "2.1.2"
[dev-dependencies]
sea-orm = { version = "^0.11.3", features = [ "sqlx-sqlite", "runtime-actix-native-tls", "macros" ], default-features = true }

View File

@ -66,15 +66,17 @@ pub fn derive_actix_admin_view_model(input: proc_macro::TokenStream) -> proc_mac
entity_name: entity.table_name().to_string(),
fields: Entity::get_fields(),
show_search: #has_searchable_fields,
user_can_access: None
user_can_access: None,
default_show_aside: Entity::get_filter().len() > 0
}
}
}
#[actix_admin::prelude::async_trait(?Send)]
impl ActixAdminViewModelTrait for Entity {
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;
async fn list(db: &DatabaseConnection, page: u64, entities_per_page: u64, viewmodel_filter: Vec<ActixAdminViewModelFilter>, search: &str, sort_by: &str, sort_order: &SortOrder) -> Result<(u64, Vec<ActixAdminModel>), ActixAdminError> {
let filter_values: HashMap<String, Option<String>> = viewmodel_filter.iter().map(|f| (f.name.to_string(), f.value.clone())).collect();
let entities = Entity::list_model(db, page, entities_per_page, filter_values, search, sort_by, sort_order).await;
entities
}
@ -96,6 +98,15 @@ pub fn derive_actix_admin_view_model(input: proc_macro::TokenStream) -> proc_mac
Ok(model)
}
async fn get_viewmodel_filter() -> HashMap<String, ActixAdminViewModelFilter> {
Entity::get_filter().iter().map(|f|
(f.name.to_string(), ActixAdminViewModelFilter {
name: f.name.to_string(),
value: None
})
).collect()
}
async fn get_entity(db: &DatabaseConnection, id: i32) -> Result<ActixAdminModel, ActixAdminError> {
// TODO: separate primary key from other keys
let entity = Entity::find_by_id(id).one(db).await?;
@ -281,7 +292,7 @@ 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: &str, sort_by: &str, sort_order: &SortOrder) -> Result<(u64, Vec<ActixAdminModel>), ActixAdminError> {
async fn list_model(db: &DatabaseConnection, page: u64, posts_per_page: u64, filter_values: HashMap<String, Option<String>>, search: &str, sort_by: &str, sort_order: &SortOrder) -> Result<(u64, Vec<ActixAdminModel>), ActixAdminError> {
let sort_column = match sort_by {
#(#fields_match_name_to_columns)*
_ => panic!("Unknown column")
@ -301,6 +312,13 @@ pub fn derive_actix_admin_model(input: proc_macro::TokenStream) -> proc_macro::T
)
}
let filters = Entity::get_filter();
for filter in filters {
let myfn = filter.filter;
let value = filter_values.get(&filter.name).unwrap_or_else(|| &None);
query = myfn(query, value.clone());
}
let paginator = query.paginate(db, posts_per_page);
let num_pages = paginator.num_pages().await?;

View File

@ -49,3 +49,5 @@ impl ActixAdminModelValidationTrait<ActiveModel> for Entity {
errors
}
}
impl ActixAdminModelFilterTrait<Entity> for Entity {}

View File

@ -68,3 +68,5 @@ impl FromStr for Tea {
}
impl ActixAdminModelValidationTrait<ActiveModel> for Entity {}
impl ActixAdminModelFilterTrait<Entity> for Entity {}

View File

@ -1,6 +1,6 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use actix_admin::prelude::*;
use actix_admin::{prelude::*};
use super::Post;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize, DeriveActixAdmin, DeriveActixAdminModel, DeriveActixAdminViewModel)]
@ -50,3 +50,23 @@ impl ActixAdminModelValidationTrait<ActiveModel> for Entity {
errors
}
}
impl ActixAdminModelFilterTrait<Entity> for Entity {
fn get_filter() -> Vec<ActixAdminModelFilter<Entity>> {
vec![
ActixAdminModelFilter::<Entity> {
name: "Id".to_string(),
filter: |q: sea_orm::Select<Entity>, v| -> sea_orm::Select<Entity> {
q.apply_if(v, | query, val: String| query.filter(Column::Id.eq(val)))
}
},
ActixAdminModelFilter::<Entity> {
name: "User".to_string(),
filter: |q: sea_orm::Select<Entity>, v| -> sea_orm::Select<Entity> {
q.apply_if(v, | query, val: String| query.filter(Column::User.eq(val)))
}
}
]
}
}

View File

@ -1,14 +1,15 @@
// setup
use sea_orm::sea_query::{ForeignKeyCreateStatement, ColumnDef, TableCreateStatement};
use sea_orm::{Set, EntityTrait, error::*, sea_query, ConnectionTrait, DbConn, ExecResult };
use sea_orm::sea_query::{ColumnDef, ForeignKeyCreateStatement, TableCreateStatement};
use sea_orm::{
error::*, sea_query, ConnectionTrait, DbConn, EntityTrait, ExecResult, Set};
pub mod comment;
pub mod post;
pub mod user;
use chrono::{Duration, DurationRound, Local};
pub use comment::Entity as Comment;
pub use post::Entity as Post;
pub use user::Entity as User;
use chrono::{Local, Duration, DurationRound};
use sea_orm::prelude::Decimal;
pub use user::Entity as User;
// setup
async fn create_table(db: &DbConn, stmt: &TableCreateStatement) -> Result<ExecResult, DbErr> {
@ -29,7 +30,11 @@ pub async fn create_post_table(db: &DbConn) -> Result<ExecResult, DbErr> {
)
.col(ColumnDef::new(post::Column::Title).string().not_null())
.col(ColumnDef::new(post::Column::Text).string().not_null())
.col(ColumnDef::new(post::Column::TeaMandatory).string().not_null())
.col(
ColumnDef::new(post::Column::TeaMandatory)
.string()
.not_null(),
)
.col(ColumnDef::new(post::Column::TeaOptional).string())
.col(ColumnDef::new(post::Column::InsertDate).date().not_null())
.col(ColumnDef::new(post::Column::Attachment).string())
@ -49,9 +54,21 @@ pub async fn create_post_table(db: &DbConn) -> Result<ExecResult, DbErr> {
)
.col(ColumnDef::new(comment::Column::Comment).string().not_null())
.col(ColumnDef::new(comment::Column::User).string().not_null())
.col(ColumnDef::new(comment::Column::InsertDate).date_time().not_null())
.col(ColumnDef::new(comment::Column::IsVisible).boolean().not_null())
.col(ColumnDef::new(comment::Column::MyDecimal).decimal().not_null())
.col(
ColumnDef::new(comment::Column::InsertDate)
.date_time()
.not_null(),
)
.col(
ColumnDef::new(comment::Column::IsVisible)
.boolean()
.not_null(),
)
.col(
ColumnDef::new(comment::Column::MyDecimal)
.decimal()
.not_null(),
)
.col(ColumnDef::new(comment::Column::PostId).integer())
.foreign_key(
ForeignKeyCreateStatement::new()
@ -97,8 +114,11 @@ pub async fn create_post_table(db: &DbConn) -> Result<ExecResult, DbErr> {
comment: Set(format!("Test {}", i)),
user: Set("me@home.com".to_string()),
my_decimal: Set(Decimal::new(105, 0)),
insert_date: Set(Local::now().naive_utc().duration_round(Duration::minutes(1)).unwrap()),
is_visible: Set(i%2 == 0),
insert_date: Set(Local::now()
.naive_utc()
.duration_round(Duration::minutes(1))
.unwrap()),
is_visible: Set(i % 2 == 0),
..Default::default()
};
let _res = Comment::insert(row).exec(db).await;
@ -110,7 +130,7 @@ pub async fn create_post_table(db: &DbConn) -> Result<ExecResult, DbErr> {
..Default::default()
};
let _res = User::insert(row).exec(db).await;
}
}
_res
}

View File

@ -1,3 +1,4 @@
use actix_admin::model::ActixAdminModelFilterTrait;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use actix_admin::prelude::*;
@ -72,3 +73,5 @@ impl FromStr for Tea {
}
impl ActixAdminModelValidationTrait<ActiveModel> for Entity {}
impl ActixAdminModelFilterTrait<Entity> for Entity {}

View File

@ -1,6 +1,6 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use actix_admin::prelude::*;
use actix_admin::{prelude::*};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize, DeriveActixAdmin, DeriveActixAdminModel, DeriveActixAdminViewModel)]
#[sea_orm(table_name = "user")]
@ -18,3 +18,5 @@ impl ActiveModelBehavior for ActiveModel {}
pub enum Relation {}
impl ActixAdminModelValidationTrait<ActiveModel> for Entity {}
impl ActixAdminModelFilterTrait<Entity> for Entity {}

View File

@ -24,11 +24,11 @@ pub mod view_model;
pub mod prelude {
pub use crate::builder::{ActixAdminBuilder, ActixAdminBuilderTrait};
pub use crate::model::{ActixAdminModel, ActixAdminModelTrait, ActixAdminModelValidationTrait};
pub use crate::model::{ActixAdminModel, ActixAdminModelTrait, ActixAdminModelValidationTrait, ActixAdminModelFilter, ActixAdminModelFilterTrait};
pub use crate::routes::{create_or_edit_post, get_admin_ctx, SortOrder};
pub use crate::view_model::{
ActixAdminViewModel, ActixAdminViewModelField, ActixAdminViewModelFieldType,
ActixAdminViewModelSerializable, ActixAdminViewModelTrait,
ActixAdminViewModelSerializable, ActixAdminViewModelTrait, ActixAdminViewModelFilter
};
pub use crate::{hashmap, ActixAdminSelectListTrait};
pub use crate::{ActixAdmin, ActixAdminAppDataTrait, ActixAdminConfiguration, ActixAdminError};

View File

@ -1,11 +1,12 @@
use crate::routes::SortOrder;
use crate::view_model::ActixAdminViewModelFilter;
use crate::{ActixAdminError, ActixAdminViewModelField};
use actix_multipart::{Multipart, MultipartError};
use actix_web::web::Bytes;
use async_trait::async_trait;
use chrono::{NaiveDate, NaiveDateTime};
use futures_util::stream::StreamExt as _;
use sea_orm::DatabaseConnection;
use sea_orm::{DatabaseConnection, EntityTrait};
use serde::Serialize;
use std::collections::HashMap;
use std::fs::File;
@ -18,6 +19,7 @@ pub trait ActixAdminModelTrait {
db: &DatabaseConnection,
page: u64,
posts_per_page: u64,
filter_values: HashMap<String, Option<String>>,
search: &str,
sort_by: &str,
sort_order: &SortOrder
@ -32,6 +34,26 @@ pub trait ActixAdminModelValidationTrait<T> {
}
}
pub struct ActixAdminModelFilter<E: EntityTrait> {
pub name: String,
pub filter: fn(sea_orm::Select<E>, Option<String>) -> sea_orm::Select<E>
}
pub trait ActixAdminModelFilterTrait<E: EntityTrait> {
fn get_filter() -> Vec<ActixAdminModelFilter<E>> {
Vec::new()
}
}
impl<T: EntityTrait> From<ActixAdminModelFilter<T>> for ActixAdminViewModelFilter {
fn from(filter: ActixAdminModelFilter<T>) -> Self {
ActixAdminViewModelFilter {
name: filter.name,
value: None
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct ActixAdminModel {
pub primary_key: Option<String>,

View File

@ -1,5 +1,5 @@
use std::fmt;
use urlencoding::decode;
use crate::prelude::*;
use actix_web::{error, web, Error, HttpRequest, HttpResponse};
use serde::Deserialize;
@ -81,7 +81,20 @@ pub async fn list<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>(
.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, &sort_by, &sort_order).await;
let decoded_querystring = decode(req.query_string()).unwrap();
let actixadminfilters: Vec<ActixAdminViewModelFilter> = decoded_querystring
.split("&")
.filter(|qf| qf.starts_with("filter_"))
.map(|f| {
let mut kv = f.split("=");
let af = ActixAdminViewModelFilter {
name: kv.next().unwrap().strip_prefix("filter_").unwrap_or_default().to_string(),
value: kv.next().map(|s| s.to_string()).filter(|f| !f.is_empty()),
};
af
}).collect();
let result = E::list(db, page, entities_per_page, actixadminfilters, &search, &sort_by, &sort_order).await;
match result {
Ok(res) => {
@ -130,6 +143,7 @@ pub async fn list<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>(
ctx.insert("notifications", &notifications);
ctx.insert("entities_per_page", &entities_per_page);
ctx.insert("render_partial", &render_partial);
ctx.insert("viewmodel_filter", &E::get_viewmodel_filter().await);
ctx.insert(
"view_model",
&ActixAdminViewModelSerializable::from(view_model.clone()),
@ -138,7 +152,8 @@ pub async fn list<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>(
ctx.insert("sort_by", &sort_by);
ctx.insert("sort_order", &sort_order);
let body = actix_admin.tera
let body = actix_admin
.tera
.render("list.html", &ctx)
.map_err(|err| error::ErrorInternalServerError(err))?;
Ok(http_response_code.content_type("text/html").body(body))

View File

@ -27,7 +27,7 @@
<div id="content">
<div class="columns">
<aside id="nav_aside" class="column is-2 is-hidden is-narrow-mobile is-fullheight is-hidden-mobile">
<aside id="nav_aside" class="column is-2 {% if not view_model.default_show_aside %}is-hidden{% endif %} is-narrow-mobile is-fullheight is-hidden-mobile">
{% block aside %}
{% endblock aside %}
</aside>

View File

@ -80,11 +80,13 @@
});
});
let error = "<div class=\"notification mb-4 is-light is-danger\"><button class=\"delete\" onclick=\"this.parentElement.remove()\"></button>An Error occurred</div>";
htmx.on("htmx:responseError", function () {
document.getElementById("notifications").insertAdjacentHTML(
"afterend",
"<div class=\"notification mb-4 is-light is-danger\"><button class=\"delete\" onclick=\"this.parentElement.remove()\"></button>An Error occurred</div>");
})
document.getElementById("notifications").insertAdjacentHTML("afterend", error)
});
htmx.on("htmx:sendError", function () {
document.getElementById("notifications").insertAdjacentHTML("afterend", error)
});
</script>
<style>

View File

@ -1,21 +1,43 @@
{% extends "base.html" %}
{% block aside %}
<p class="menu-label is-hidden-touch">Filter</p>
<p class="menu-label is-hidden-touch">Filter</p>
<form id="filter_form" hx-indicator="#loading" hx-get="/admin/{{ entity_name }}/list"
hx-target="#{{ entity_name }}table" hx-push-url="true" hx-include="[id='{{ entity_name }}table']">
<ul class="menu-list">
{% for key, value in viewmodel_filter %}
<li>
<div class="field">
<label class="label">{{key}}</label>
<div class="control">
<input class="input" type="text" placeholder="" name="filter_{{key}}">
</div>
</div>
</li>
{% endfor %}
<li>
<div class="field mt-4 is-grouped">
<div class="control">
<button class="button is-link">Apply</button>
</div>
</div>
</li>
</ul>
</form>
{% endblock aside %}
{% block content %}
{% if not render_partial or render_partial == false %}
<div class="column">
<div class="column">
<div class="columns">
<div class="column">
<div class="buttons">
<a class="button is-primary" href="/admin/{{ entity_name }}/create" hx-boost="true"
hx-indicator="#loading"><i class="fa-solid fa-circle-plus"></i></a>
{% if viewmodel_filter | length > 0 %}
<button class="button" onclick="toggle_aside()"><i class="fa-solid fa-filter"></i></button>
{% endif %}
<div class="dropdown mr-2 is-hoverable">
<div class="dropdown-trigger">
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu4">
@ -34,14 +56,14 @@
</div>
</div>
</div>
<button class="button" onclick="toggle_aside()"><i class="fa-solid fa-filter"></i></button>
</div>
</div>
<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-target="#{{ entity_name }}table" hx-trigger="reload_table from:#entities_per_page" hx-include="[id='filter_form']">
<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="search" value="{{ search }}">
<input type="hidden" name="page" value="{{ page }}">
<div class="column is-narrow">
<div class="field is-horizontal">
{% if view_model.show_search %}
@ -76,7 +98,7 @@
<div class="is-relative">
{% include "loader.html" %}
<form id="table_form" hx-indicator="#loading" hx-get="/admin/{{ entity_name }}/list"
hx-target="#{{ entity_name }}table">
hx-target="#{{ entity_name }}table" hx-include="[id='filter_form']">
<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 }}">
@ -181,8 +203,8 @@
"render_partial" : "true"
}' hx-indicator="#loading" class="pagination is-rounded is-centered" role="pagination" aria-label="pagination">
{% if page > 1 %}
<a href="/admin/{{ entity_name }}/list?&page={{ page - 1 }}"
class="pagination-previous left-arrow-click"><i class="fa-solid fa-arrow-left"></i>
<a href="/admin/{{ entity_name }}/list?&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="/admin/{{ entity_name }}/list?page={{ page + 1 }}"
@ -199,8 +221,7 @@
</li>
{% 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="/admin/{{ entity_name }}/list?page={{ i + 1 }}">{{
aria-label="Goto page {{ i + 1 }}" href="/admin/{{ entity_name }}/list?page={{ i + 1 }}">{{
i + 1 }}</a></li>
{%- endfor %}
<li>

View File

@ -14,6 +14,7 @@ pub trait ActixAdminViewModelTrait {
db: &DatabaseConnection,
page: u64,
entities_per_page: u64,
viewmodel_filter: Vec<ActixAdminViewModelFilter>,
search: &str,
sort_by: &str,
sort_order: &SortOrder
@ -25,6 +26,7 @@ pub trait ActixAdminViewModelTrait {
async fn get_entity(db: &DatabaseConnection, id: i32) -> Result<ActixAdminModel, ActixAdminError>;
async fn edit_entity(db: &DatabaseConnection, id: i32, model: ActixAdminModel) -> Result<ActixAdminModel, ActixAdminError>;
async fn get_select_lists(db: &DatabaseConnection) -> Result<HashMap<String, Vec<(String, String)>>, ActixAdminError>;
async fn get_viewmodel_filter() -> HashMap<String, ActixAdminViewModelFilter>;
fn validate_entity(model: &mut ActixAdminModel);
fn get_entity_name() -> String;
@ -40,7 +42,8 @@ pub struct ActixAdminViewModel {
pub primary_key: String,
pub fields: &'static[ActixAdminViewModelField],
pub show_search: bool,
pub user_can_access: Option<fn(&Session) -> bool>
pub user_can_access: Option<fn(&Session) -> bool>,
pub default_show_aside: bool
}
#[derive(Clone, Debug, Serialize)]
@ -48,7 +51,14 @@ pub struct ActixAdminViewModelSerializable {
pub entity_name: String,
pub primary_key: String,
pub fields: &'static [ActixAdminViewModelField],
pub show_search: bool
pub show_search: bool,
pub default_show_aside: bool
}
#[derive(Clone, Debug, Serialize)]
pub struct ActixAdminViewModelFilter {
pub name: String,
pub value: Option<String>
}
// TODO: better alternative to serialize only specific fields for ActixAdminViewModel
@ -58,12 +68,12 @@ impl From<ActixAdminViewModel> for ActixAdminViewModelSerializable {
entity_name: entity.entity_name,
primary_key: entity.primary_key,
fields: entity.fields,
show_search: entity.show_search
show_search: entity.show_search,
default_show_aside: entity.default_show_aside
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub enum ActixAdminViewModelFieldType {
Number,