Compare commits

..

3 Commits

Author SHA1 Message Date
gabatxo1312
3454fb503d
Add paginate 2026-01-29 18:01:46 +01:00
gabatxo1312
374d318dea
Add few comments 2026-01-29 16:52:35 +01:00
gabatxo1312
673fd2c58a
Add filter on book index 2026-01-29 00:36:23 +01:00
3 changed files with 200 additions and 42 deletions

View File

@ -1,4 +1,5 @@
use sea_orm::ActiveValue::Set; use sea_orm::ActiveValue::Set;
use sea_orm::Condition;
use sea_orm::DeleteResult; use sea_orm::DeleteResult;
use sea_orm::QueryOrder; use sea_orm::QueryOrder;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
@ -6,6 +7,7 @@ use snafu::ResultExt;
use snafu::prelude::*; use snafu::prelude::*;
use crate::routes::book::BookForm; use crate::routes::book::BookForm;
use crate::routes::book::IndexQuery;
use crate::state::AppState; use crate::state::AppState;
use crate::state::error::BookSnafu; use crate::state::error::BookSnafu;
@ -38,15 +40,16 @@ impl ActiveModelBehavior for ActiveModel {}
#[derive(Debug, Snafu)] #[derive(Debug, Snafu)]
#[snafu(visibility(pub))] #[snafu(visibility(pub))]
pub enum BookError { pub enum BookError {
// #[snafu(display("The Content Folder (Path: {path}) does not exist"))] /// Db Error from SeaOrm
// NotFound { path: String },
#[snafu(display("Database error"))] #[snafu(display("Database error"))]
DB { source: sea_orm::DbErr }, DB { source: sea_orm::DbErr },
/// When Book with Id is not found
#[snafu(display("Book with id {id} not found"))] #[snafu(display("Book with id {id} not found"))]
NotFound { id: i32 }, NotFound { id: i32 },
} }
#[derive(Debug)] #[derive(Debug)]
/// Operator for the CRUD on Book Model
pub struct BookOperator { pub struct BookOperator {
pub state: AppState, pub state: AppState,
} }
@ -59,10 +62,14 @@ pub struct BooksPaginate {
} }
impl BookOperator { impl BookOperator {
/// Creates a new `BookOperator` with the given application state.
pub fn new(state: AppState) -> Self { pub fn new(state: AppState) -> Self {
Self { state } Self { state }
} }
/// Lists all books matching the optional query filters.
///
/// Results are ordered by ID in descending order (newest first).
pub async fn list(&self) -> Result<Vec<Model>, BookError> { pub async fn list(&self) -> Result<Vec<Model>, BookError> {
Entity::find() Entity::find()
.order_by_desc(Column::Id) .order_by_desc(Column::Id)
@ -71,13 +78,37 @@ impl BookOperator {
.context(DBSnafu) .context(DBSnafu)
} }
pub async fn list_paginate(&self, page: u64) -> Result<BooksPaginate, BookError> { pub async fn list_paginate(
&self,
page: u64,
query: Option<IndexQuery>,
) -> Result<BooksPaginate, BookError> {
let page = if page > 0 { page } else { 1 }; // keep 1-indexed let page = if page > 0 { page } else { 1 }; // keep 1-indexed
let page_0indexed = page - 1; // convert for SeaORM (0-based index) let page_0indexed = page - 1; // convert for SeaORM (0-based index)
let mut conditions = Condition::all();
if let Some(book_query) = query {
if let Some(title) = book_query.title {
conditions = conditions.add(Column::Title.contains(&title));
}
if let Some(authors) = book_query.authors {
conditions = conditions.add(Column::Authors.contains(&authors));
}
if let Some(owner_id) = book_query.owner_id {
conditions = conditions.add(Column::OwnerId.eq(owner_id));
}
if let Some(current_holder_id) = book_query.current_holder_id {
conditions = conditions.add(Column::CurrentHolderId.eq(current_holder_id));
}
}
let book_pages = Entity::find() let book_pages = Entity::find()
.filter(conditions)
.order_by_desc(Column::Id) .order_by_desc(Column::Id)
.paginate(&self.state.db, 100); .paginate(&self.state.db, 1);
let books = book_pages let books = book_pages
.fetch_page(page_0indexed) .fetch_page(page_0indexed)
@ -92,6 +123,10 @@ impl BookOperator {
}) })
} }
/// Finds a book by its ID.
///
/// # Errors
/// Returns `BookError::NotFound` if no book exists with the given ID.
pub async fn find_by_id(&self, id: i32) -> Result<Model, BookError> { pub async fn find_by_id(&self, id: i32) -> Result<Model, BookError> {
let book_by_id = Entity::find_by_id(id) let book_by_id = Entity::find_by_id(id)
.one(&self.state.db) .one(&self.state.db)
@ -105,6 +140,7 @@ impl BookOperator {
} }
} }
/// Creates a new book from the given form data.
pub async fn create(&self, form: BookForm) -> Result<Model, BookError> { pub async fn create(&self, form: BookForm) -> Result<Model, BookError> {
let book = ActiveModel { let book = ActiveModel {
title: Set(form.title.clone()), title: Set(form.title.clone()),
@ -119,6 +155,10 @@ impl BookOperator {
book.insert(&self.state.db).await.context(DBSnafu) book.insert(&self.state.db).await.context(DBSnafu)
} }
/// Update a book (find with ID) from the given form data
///
/// # Error
/// Returns BookError::NotFound if id is not found in database
pub async fn update(&self, id: i32, form: BookForm) -> Result<Model, BookError> { pub async fn update(&self, id: i32, form: BookForm) -> Result<Model, BookError> {
let book_by_id = Self::find_by_id(&self, id).await.context(BookSnafu); let book_by_id = Self::find_by_id(&self, id).await.context(BookSnafu);
@ -138,6 +178,7 @@ impl BookOperator {
} }
} }
/// Delete a book (find with ID)
pub async fn delete(&self, id: i32) -> Result<DeleteResult, BookError> { pub async fn delete(&self, id: i32) -> Result<DeleteResult, BookError> {
let book: Option<Model> = Entity::find_by_id(id) let book: Option<Model> = Entity::find_by_id(id)
.one(&self.state.db) .one(&self.state.db)

View File

@ -22,14 +22,6 @@ use crate::{
}, },
}; };
#[derive(Template, WebTemplate)]
#[template(path = "index.html")]
struct BookIndexTemplate {
books_with_user: Vec<BookWithUser>,
current_page: u64,
total_page: u64,
}
// Book list with the owner and the current holder inside // Book list with the owner and the current holder inside
struct BookWithUser { struct BookWithUser {
pub book: BookModel, pub book: BookModel,
@ -37,53 +29,102 @@ struct BookWithUser {
pub current_holder: Option<UserModel>, pub current_holder: Option<UserModel>,
} }
#[derive(Deserialize)] /// Query for filter search query
pub struct Pagination { #[serde_as]
#[derive(Deserialize, Clone, Debug)]
pub struct IndexQuery {
pub title: Option<String>,
pub page: Option<usize>, pub page: Option<usize>,
pub authors: Option<String>,
#[serde(default)]
#[serde_as(as = "NoneAsEmptyString")]
pub owner_id: Option<i32>,
#[serde(default)]
#[serde_as(as = "NoneAsEmptyString")]
pub current_holder_id: Option<i32>,
}
#[derive(Template, WebTemplate)]
#[template(path = "index.html")]
struct BookIndexTemplate {
books_with_user: Vec<BookWithUser>,
query: IndexQuery,
users: Vec<UserModel>,
current_page: u64,
total_page: u64,
base_query: String,
} }
pub async fn index( pub async fn index(
State(state): State<AppState>, State(state): State<AppState>,
Query(pagination): Query<Pagination>, Query(query): Query<IndexQuery>,
) -> Result<impl axum::response::IntoResponse, AppStateError> { ) -> Result<impl axum::response::IntoResponse, AppStateError> {
let page: u64 = pagination let page: u64 = query
.page .page
.map(|p| p.max(1) as u64) // Minimum 1 .map(|p| p.max(1) as u64) // Minimum 1
.unwrap_or(1); .unwrap_or(1);
// Get all Users
let users = UserOperator::new(state.clone()) let users = UserOperator::new(state.clone())
.list() .list()
.await .await
.context(UserSnafu)?; .context(UserSnafu)?;
// Get all Book filtered with query
let books_paginate = BookOperator::new(state) let books_paginate = BookOperator::new(state)
.list_paginate(page) .list_paginate(page, Some(query.clone()))
.await .await
.context(BookSnafu)?; .context(BookSnafu)?;
let user_by_id: HashMap<i32, UserModel> = // Mapping between an user_id and user used in result to
users.into_iter().map(|user| (user.id, user)).collect(); // get easily user with his id
let user_by_id: HashMap<i32, UserModel> = users
.clone()
.into_iter()
.map(|user| (user.id, user))
.collect();
let mut result: Vec<BookWithUser> = Vec::with_capacity(books_paginate.books.len()); // Build object of Book with his relation Owner (User) and current_holder (User)
let result: Vec<BookWithUser> = books_paginate
.books
.into_iter()
.filter_map(|book| {
let owner = user_by_id.get(&book.owner_id).cloned()?;
let current_holder = book
.current_holder_id
.and_then(|id| user_by_id.get(&id).cloned());
for book in books_paginate.books { Some(BookWithUser {
let owner = user_by_id.get(&book.owner_id).cloned().unwrap(); book,
let current_holder = if let Some(current_holder_id) = book.current_holder_id { owner,
user_by_id.get(&current_holder_id).cloned() current_holder,
} else { })
None })
}; .collect();
result.push(BookWithUser { // build original search to be sure to keep
book, // search when we change page
owner, let mut base_query = String::new();
current_holder, if let Some(title) = &query.title {
}); base_query.push_str(&format!("title={}&", title));
}
if let Some(authors) = &query.authors {
base_query.push_str(&format!("authors={}&", authors));
}
if let Some(owner_id) = &query.owner_id {
base_query.push_str(&format!("owner_id={}&", owner_id));
}
if let Some(current_holder_id) = &query.current_holder_id {
base_query.push_str(&format!("current_holder_id={}&", current_holder_id));
} }
Ok(BookIndexTemplate { Ok(BookIndexTemplate {
books_with_user: result, books_with_user: result,
query,
users,
current_page: books_paginate.current_page, current_page: books_paginate.current_page,
total_page: books_paginate.total_page, total_page: books_paginate.total_page,
base_query,
}) })
} }
@ -128,6 +169,7 @@ pub async fn show(
}) })
} }
/// Form to build a new book or an update
#[serde_as] #[serde_as]
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct BookForm { pub struct BookForm {

View File

@ -6,6 +6,81 @@
{% block main %} {% block main %}
{{ typography::heading("All books") }} {{ typography::heading("All books") }}
{% call cards::card() %}
<form method="get">
<div class="row">
<div class="col-md-3">
<label for="title" class="form-label">Name</label>
{% match query.title %}
{% when Some with (value) %}
<input type="text" name="title" value="{{ value }}" class="form-control" placeholder="Ex: La liberte ou rien">
{% when None %}
<input type="text" name="title" class="form-control" placeholder="Ex: La liberte ou rien">
{% endmatch %}
</div>
<div class="col-md-3">
<label for="authors" class="form-label">Authors</label>
{% match query.authors %}
{% when Some with (value) %}
<input type="text" name="authors" value="{{ value }}" class="form-control" placeholder="Ex: Emma Goldmann">
{% when None %}
<input type="text" name="authors" class="form-control" placeholder="Ex: Emma Goldmann">
{% endmatch %}
</div>
<div class="col-md-2">
<label for="owner_id" class="form-label">Owner</label>
<select name="owner_id" class="form-control">
<option></option>
{% match query.owner_id %}
{% when Some with (owner_id) %}
{% for user in users %}
{% if *owner_id == user.id %}
<option value="{{ user.id }}" selected>{{ user.name }}</option>
{% else %}
<option value="{{ user.id }}">{{ user.name }}</option>
{% endif %}
{% endfor %}
{% when None %}
{% for user in users %}
<option value="{{ user.id }}">{{ user.name }}</option>
{% endfor %}
{% endmatch %}
</select>
</div>
<div class="col-md-2">
<label for="current_holder_id" class="form-label">Current Holder</label>
<select name="current_holder_id" class="form-control">
<option></option>
{% match query.current_holder_id %}
{% when Some with (current_holder_id) %}
{% for user in users %}
{% if *current_holder_id == user.id %}
<option value="{{ user.id }}" selected>{{ user.name }}</option>
{% else %}
<option value="{{ user.id }}">{{ user.name }}</option>
{% endif %}
{% endfor %}
{% when None %}
{% for user in users %}
<option value="{{ user.id }}">{{ user.name }}</option>
{% endfor %}
{% endmatch %}
</select>
</div>
<div class="col-md-1 d-flex align-items-end">
<input type="submit" value="Search" class="btn btn-info btn">
</div>
</div>
</form>
{% endcall %}
{% call cards::card() %} {% call cards::card() %}
<table class="table table-hover"> <table class="table table-hover">
<thead> <thead>
@ -19,14 +94,14 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for book_with_user in books_with_user %} {% for book_user in books_with_user %}
<tr> <tr>
<th scope="row">{{ book_with_user.book.id }}</th> <th scope="row">{{ book_user.book.id }}</th>
<td>{{ book_with_user.book.title }}</td> <td>{{ book_user.book.title }}</td>
<td>{{ book_with_user.book.authors }}</td> <td>{{ book_user.book.authors }}</td>
<td>{{ book_with_user.owner.name }}</td> <td>{{ book_user.owner.name }}</td>
<td> <td>
{% match book_with_user.current_holder %} {% match book_user.current_holder %}
{% when Some with (current_holder) %} {% when Some with (current_holder) %}
{{ current_holder.name }} {{ current_holder.name }}
{% when None %} {% when None %}
@ -34,7 +109,7 @@
{% endmatch %} {% endmatch %}
</td> </td>
<td> <td>
{{ dropdown::book_dropdown_button(book_with_user.book) }} {{ dropdown::book_dropdown_button(book_user.book) }}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -45,19 +120,19 @@
<nav aria-label="Page navigation"> <nav aria-label="Page navigation">
<ul class="pagination"> <ul class="pagination">
<li class="page-item {% if current_page <= 1 %}disabled{% endif %}"> <li class="page-item {% if current_page <= 1 %}disabled{% endif %}">
<a class="page-link" href="/?page={% if current_page > 1 %}{{ current_page - 1 }}{% else %}1{% endif %}">Prev</a> <a class="page-link" href="/?{{ base_query }}page={% if current_page > 1 %}{{ current_page - 1 }}{% else %}1{% endif %}">Prev</a>
</li> </li>
{% for page in 1..(total_page + 1) %} {% for page in 1..(total_page + 1) %}
{% if page >= current_page - 1 && page <= current_page + 1 %} {% if page >= current_page - 1 && page <= current_page + 1 %}
<li class="page-item {% if page == current_page %}active{% endif %}"> <li class="page-item {% if page == current_page %}active{% endif %}">
<a class="page-link" href="/?page={{ page }}">{{ page }}</a> <a class="page-link" href="/?{{ base_query }}page={{ page }}">{{ page }}</a>
</li> </li>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<li class="page-item {% if current_page == total_page %}disabled{% endif %}"> <li class="page-item {% if current_page == total_page %}disabled{% endif %}">
<a class="page-link" href="/?page={{ current_page + 1 }}">Next</a> <a class="page-link" href="/?{{ base_query }}page={{ current_page + 1 }}">Next</a>
</li> </li>
</ul> </ul>
</nav> </nav>