Add parent to item
This commit is contained in:
parent
f0037b13f8
commit
ffddf2ba00
10
migrations/2024-07-03-122248_item_add_parent/down.sql
Normal file
10
migrations/2024-07-03-122248_item_add_parent/down.sql
Normal 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;
|
50
migrations/2024-07-03-122248_item_add_parent/up.sql
Normal file
50
migrations/2024-07-03-122248_item_add_parent/up.sql
Normal 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();
|
|
@ -13,7 +13,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||
cfg.service(add)
|
||||
.service(list)
|
||||
.service(show)
|
||||
.service(update);
|
||||
.service(update)
|
||||
.service(parents);
|
||||
}
|
||||
|
||||
#[put("/item")]
|
||||
|
@ -70,3 +71,17 @@ async fn update(
|
|||
|
||||
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))
|
||||
}
|
||||
|
|
|
@ -113,7 +113,8 @@ async fn edit_item_post(
|
|||
) -> actix_web::Result<impl Responder> {
|
||||
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?
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use diesel::pg::PgConnection;
|
||||
use diesel::prelude::*;
|
||||
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)
|
||||
}
|
||||
|
||||
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)))
|
||||
.set(modified_item)
|
||||
.returning(Item::as_returning())
|
||||
.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>>>()
|
||||
})
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ use crate::schema::*;
|
|||
pub struct Item {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub parent: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable, Deserialize, AsChangeset)]
|
||||
|
@ -21,4 +22,6 @@ pub struct Item {
|
|||
#[diesel(check_for_backend(diesel::pg::Pg))]
|
||||
pub struct NewItem {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub parent: Option<Uuid>,
|
||||
}
|
||||
|
|
|
@ -8,5 +8,13 @@ diesel::table! {
|
|||
items (id) {
|
||||
id -> Uuid,
|
||||
name -> Varchar,
|
||||
parent -> Nullable<Uuid>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
item_tree (id) {
|
||||
id -> Uuid,
|
||||
parents -> Array<Uuid>,
|
||||
}
|
||||
}
|
||||
|
|
14
static/app.js
Normal file
14
static/app.js
Normal 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;
|
||||
})
|
||||
})
|
||||
})()
|
|
@ -55,5 +55,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later
|
|||
</footer>
|
||||
|
||||
<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>
|
||||
</html>
|
||||
|
|
|
@ -12,6 +12,15 @@ SPDX-License-Identifier: AGPL-3.0-or-later
|
|||
<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 %}>
|
||||
</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>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
@ -28,6 +28,14 @@ SPDX-License-Identifier: AGPL-3.0-or-later
|
|||
{{ item.name }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
Parent
|
||||
</th>
|
||||
<td>
|
||||
{% if let Some(parent) = item.parent %}{{ parent }}{% else %}-{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
|
|
@ -16,6 +16,15 @@ SPDX-License-Identifier: AGPL-3.0-or-later
|
|||
<label for="name" class="form-label">Name</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required value="{{ item.name }}">
|
||||
</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>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
Loading…
Reference in a new issue