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)
|
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))
|
||||||
|
}
|
||||||
|
|
|
@ -113,9 +113,10 @@ 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 =
|
||||||
.await?
|
web::block(move || manage::item::update(&mut pool.get().unwrap(), id, data.into_inner()))
|
||||||
.map_err(error::ErrorInternalServerError)?;
|
.await?
|
||||||
|
.map_err(error::ErrorInternalServerError)?;
|
||||||
|
|
||||||
Ok(web::Redirect::to("/item/".to_owned() + &item.id.to_string()).see_other())
|
Ok(web::Redirect::to("/item/".to_owned() + &item.id.to_string()).see_other())
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>>>()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -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>,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
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>
|
</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>
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
Loading…
Reference in a new issue