Add parent to item

This commit is contained in:
Simon Bruder 2024-07-05 12:38:45 +02:00
parent f0037b13f8
commit ffddf2ba00
Signed by: simon
GPG key ID: 347FF8699CDA0776
12 changed files with 171 additions and 5 deletions

View file

@ -0,0 +1,10 @@
-- SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
--
-- SPDX-License-Identifier: AGPL-3.0-or-later
ALTER TABLE items
DROP parent;
DROP VIEW item_tree;
DROP FUNCTION check_item_cycle;

View file

@ -0,0 +1,50 @@
-- SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
--
-- SPDX-License-Identifier: AGPL-3.0-or-later
ALTER TABLE items
ADD parent UUID REFERENCES items(id);
CREATE RECURSIVE VIEW item_tree (id, parents) AS (
SELECT id, ARRAY[]::UUID[] AS parents FROM items WHERE parent IS NULL
UNION
SELECT items.id, item_tree.parents || items.parent FROM items, item_tree WHERE items.parent = item_tree.id
);
CREATE FUNCTION check_item_cycle()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.parent IS NULL THEN
RETURN NEW;
END IF;
IF NEW.id = NEW.parent THEN
RAISE EXCEPTION 'Cycle detected';
END IF;
IF (WITH RECURSIVE cte AS (
SELECT id, parent
FROM items
WHERE id = NEW.parent
UNION
SELECT items.id, items.parent
FROM items, cte
WHERE items.id = cte.parent
)
SELECT 1
FROM cte
WHERE parent = NEW.id
LIMIT 1) THEN
RAISE EXCEPTION 'Cycle detected';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER prevent_item_cycle
BEFORE INSERT OR UPDATE ON items
FOR EACH ROW
EXECUTE FUNCTION check_item_cycle();

View file

@ -13,7 +13,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(add) cfg.service(add)
.service(list) .service(list)
.service(show) .service(show)
.service(update); .service(update)
.service(parents);
} }
#[put("/item")] #[put("/item")]
@ -70,3 +71,17 @@ async fn update(
Ok(HttpResponse::Ok().json(item)) Ok(HttpResponse::Ok().json(item))
} }
#[get("/item/{id}/parents")]
async fn parents(
pool: web::Data<DbPool>,
path: web::Path<Uuid>,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let parents = web::block(move || manage::item::get_parents(&mut pool.get().unwrap(), id))
.await?
.map_err(error::ErrorInternalServerError)?;
Ok(HttpResponse::Ok().json(parents))
}

View file

@ -113,7 +113,8 @@ async fn edit_item_post(
) -> actix_web::Result<impl Responder> { ) -> actix_web::Result<impl Responder> {
let id = path.into_inner(); let id = path.into_inner();
let item = web::block(move || manage::item::update(&mut pool.get().unwrap(), id, data.into_inner())) let item =
web::block(move || manage::item::update(&mut pool.get().unwrap(), id, data.into_inner()))
.await? .await?
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;

View file

@ -2,6 +2,8 @@
// //
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::HashMap;
use diesel::pg::PgConnection; use diesel::pg::PgConnection;
use diesel::prelude::*; use diesel::prelude::*;
use uuid::Uuid; use uuid::Uuid;
@ -26,9 +28,43 @@ pub fn get_all(conn: &mut PgConnection) -> Result<Vec<Item>, diesel::result::Err
schema::items::table.select(Item::as_select()).load(conn) schema::items::table.select(Item::as_select()).load(conn)
} }
pub fn update(conn: &mut PgConnection, id: Uuid, modified_item: NewItem) -> Result<Item, diesel::result::Error> { pub fn update(
conn: &mut PgConnection,
id: Uuid,
modified_item: NewItem,
) -> Result<Item, diesel::result::Error> {
diesel::update(schema::items::table.filter(schema::items::id.eq(id))) diesel::update(schema::items::table.filter(schema::items::id.eq(id)))
.set(modified_item) .set(modified_item)
.returning(Item::as_returning()) .returning(Item::as_returning())
.get_result(conn) .get_result(conn)
} }
/// Helper type for querying parents of items
#[derive(Debug, Queryable, Selectable)]
#[diesel(table_name = schema::item_tree)]
#[diesel(check_for_backend(diesel::pg::Pg))]
struct ItemTreeMapping {
pub id: Uuid,
pub parents: Vec<Uuid>,
}
pub fn get_parents(conn: &mut PgConnection, id: Uuid) -> Result<Vec<Uuid>, diesel::result::Error> {
schema::item_tree::table
.filter(schema::item_tree::id.eq(id))
.select(ItemTreeMapping::as_select())
.first(conn)
.map(|itm| itm.parents)
}
pub fn get_all_parents(
conn: &mut PgConnection,
) -> Result<HashMap<Uuid, Vec<Uuid>>, diesel::result::Error> {
schema::item_tree::table
.select(ItemTreeMapping::as_select())
.load(conn)
.map(|itms| {
itms.into_iter()
.map(|ItemTreeMapping { id, parents }| (id, parents))
.collect::<HashMap<Uuid, Vec<Uuid>>>()
})
}

View file

@ -14,6 +14,7 @@ use crate::schema::*;
pub struct Item { pub struct Item {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
pub parent: Option<Uuid>,
} }
#[derive(Debug, Insertable, Deserialize, AsChangeset)] #[derive(Debug, Insertable, Deserialize, AsChangeset)]
@ -21,4 +22,6 @@ pub struct Item {
#[diesel(check_for_backend(diesel::pg::Pg))] #[diesel(check_for_backend(diesel::pg::Pg))]
pub struct NewItem { pub struct NewItem {
pub name: String, pub name: String,
#[serde(default)]
pub parent: Option<Uuid>,
} }

View file

@ -8,5 +8,13 @@ diesel::table! {
items (id) { items (id) {
id -> Uuid, id -> Uuid,
name -> Varchar, name -> Varchar,
parent -> Nullable<Uuid>,
}
}
diesel::table! {
item_tree (id) {
id -> Uuid,
parents -> Array<Uuid>,
} }
} }

14
static/app.js Normal file
View file

@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
(() => {
// Allows a form checkbox to toggle an input element.
// This requires the form to have a structure like described in the bootstrap documentation:
// https://getbootstrap.com/docs/5.3/forms/input-group/#checkboxes-and-radios
Array.from(document.getElementsByClassName("input-toggle")).forEach(el => {
el.addEventListener("change", e => {
e.target.parentElement.parentElement.querySelector("input:not(.input-toggle)").disabled = !e.target.checked;
})
})
})()

View file

@ -55,5 +55,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later
</footer> </footer>
<script src="/static/vendor/bootstrap.bundle.min.js"></script> <script src="/static/vendor/bootstrap.bundle.min.js"></script>
<script src="/static/app.js"></script>
{# TODO this is not the best way, but it works for now #}
{% block extra_scripts %}{% endblock %}
</body> </body>
</html> </html>

View file

@ -12,6 +12,15 @@ SPDX-License-Identifier: AGPL-3.0-or-later
<label for="name" class="form-label">Name</label> <label for="name" class="form-label">Name</label>
<input type="text" class="form-control" id="name" name="name" required{% if let Some(data) = data %} value="{{ data.name }}"{% endif %}> <input type="text" class="form-control" id="name" name="name" required{% if let Some(data) = data %} value="{{ data.name }}"{% endif %}>
</div> </div>
<div class="mb-3">
<label for="parent" class="form-label">Parent</label>
<div class="input-group">
<div class="input-group-text">
<input type="checkbox" class="form-check-input mt-0 input-toggle"{% if let Some (data) = data %}{% if data.parent.is_some() %} checked{% endif %}{% endif %}>
</div>
<input type="text" class="form-control" id="parent" name="parent"{% if let Some(data) = data %}{% if let Some(parent) = data.parent %} value="{{ parent }}"{% else %} disabled{% endif %}{% else %}disabled{% endif %}>
</div>
</div>
<button type="submit" class="btn btn-primary">Add</button> <button type="submit" class="btn btn-primary">Add</button>
</form> </form>
{% endblock %} {% endblock %}

View file

@ -28,6 +28,14 @@ SPDX-License-Identifier: AGPL-3.0-or-later
{{ item.name }} {{ item.name }}
</td> </td>
</tr> </tr>
<tr>
<th>
Parent
</th>
<td>
{% if let Some(parent) = item.parent %}{{ parent }}{% else %}-{% endif %}
</td>
</tr>
</tbody> </tbody>
</table> </table>
{% endblock %} {% endblock %}

View file

@ -16,6 +16,15 @@ SPDX-License-Identifier: AGPL-3.0-or-later
<label for="name" class="form-label">Name</label> <label for="name" class="form-label">Name</label>
<input type="text" class="form-control" id="name" name="name" required value="{{ item.name }}"> <input type="text" class="form-control" id="name" name="name" required value="{{ item.name }}">
</div> </div>
<div class="mb-3">
<label for="parent" class="form-label">Parent</label>
<div class="input-group">
<div class="input-group-text">
<input type="checkbox" class="form-check-input mt-0 input-toggle"{% if item.parent.is_some() %} checked{% endif %}>
</div>
<input type="text" class="form-control" id="parent" name="parent"{% if let Some(parent) = item.parent %} value="{{ parent }}"{% else %} disabled{% endif %}>
</div>
</div>
<button type="submit" class="btn btn-primary">Edit</button> <button type="submit" class="btn btn-primary">Edit</button>
</form> </form>
{% endblock %} {% endblock %}