flash messages etc
This commit is contained in:
@@ -15,11 +15,13 @@ serde_json = "1"
|
||||
log = "0.4.14"
|
||||
simple-logging = "2.0.2"
|
||||
actix-web = "3"
|
||||
actix-session = "0.4.0"
|
||||
parking_lot = "0.11.1"
|
||||
include_dir = "0.6.0"
|
||||
tera = "1.6.1"
|
||||
actix-web-static-files = "3.0"
|
||||
once_cell = "1.5.2"
|
||||
rand = "0.8.3"
|
||||
|
||||
tokio = { version="0.2.6", features=["full"] }
|
||||
|
||||
|
||||
@@ -205,3 +205,18 @@ textarea:focus,
|
||||
li {
|
||||
padding-bottom: .5rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
border: 1px solid black;
|
||||
border-radius: 5px;
|
||||
padding: .5rem;
|
||||
margin: .5rem 0;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-color: #dc143c;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
border-color: #32cd32;
|
||||
}
|
||||
|
||||
@@ -2,17 +2,30 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% block title %}{% endblock title %} • YOPA</title>
|
||||
<title>{% block title -%}{%- endblock title %} • YOPA</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/taggle.css">
|
||||
<script src="/static/taggle.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="top-nav">
|
||||
{% block nav %}{% endblock %}
|
||||
{%- block nav -%}{%- endblock -%}
|
||||
</nav>
|
||||
<div class="content">
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
{%- if flash_error -%}
|
||||
<div class="toast error">
|
||||
{{ flash_error }}
|
||||
</div>
|
||||
{%- endif -%}
|
||||
|
||||
{%- if flash_success -%}
|
||||
<div class="toast success">
|
||||
{{ flash_success }}
|
||||
</div>
|
||||
{%- endif -%}
|
||||
|
||||
{%- block content -%}{%- endblock -%}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{% macro describe_property(prop) %}
|
||||
{{prop.name}}, {{prop.data_type}}
|
||||
{%- if prop.default -%}
|
||||
, default: "{{prop.default | print_typed_value}}"
|
||||
{%- endif -%}
|
||||
{%- if prop.optional %}, OPTIONAL{% endif %}
|
||||
{%- if prop.multiple %}, MULTIPLE{% endif %}
|
||||
{% endmacro input %}
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "_layout" %}
|
||||
{% import "_macros" as macros %}
|
||||
|
||||
{% block title -%}
|
||||
Index
|
||||
@@ -26,12 +27,7 @@
|
||||
<ul>
|
||||
{% for prop in model.properties %}
|
||||
<li>
|
||||
{{prop.name}}, {{prop.data_type}}
|
||||
{%- if prop.default -%}
|
||||
, default: "{{prop.default | print_typed_value}}"
|
||||
{%- endif -%}
|
||||
{%- if prop.optional %}, OPTIONAL{% endif %}
|
||||
{%- if prop.multiple %}, MULTIPLE{% endif %}
|
||||
{{ macros::describe_property(prop=prop) }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -44,19 +40,17 @@
|
||||
<ul>
|
||||
{% for rel in model.relations %}
|
||||
<li>
|
||||
<span title="{{rel.model.id}}">"{{rel.model.name}}", pointing to: <i>{{rel.related_name}}</i></span><br>
|
||||
<span title="{{rel.model.id}}">"{{rel.model.name}}", pointing to: <i>{{rel.related_name}}</i></span>
|
||||
{%- if rel.model.optional %}, OPTIONAL{% endif %}
|
||||
{%- if rel.model.multiple %}, MULTIPLE{% endif %}
|
||||
<br>
|
||||
|
||||
{% if rel.properties %}
|
||||
Properties:
|
||||
<ul>
|
||||
{% for prop in rel.properties %}
|
||||
<li title="{{prop.id}}">
|
||||
{{prop.name}}, {{prop.data_type}}
|
||||
{%- if prop.default -%}
|
||||
, default: "{{prop.default | print_typed_value}}"
|
||||
{%- endif -%}
|
||||
{%- if prop.optional %}, OPTIONAL{% endif %}
|
||||
{%- if prop.multiple %}, MULTIPLE{% endif %}
|
||||
{{ macros::describe_property(prop=prop) }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -13,14 +13,6 @@ Define object
|
||||
<h1>Define new object model</h1>
|
||||
|
||||
<form action="/model/object/create" method="POST">
|
||||
<label for="parent">Parent:</label>
|
||||
<select name="parent" id="parent">
|
||||
<option value="">No parent</option>
|
||||
{%- for model in all_models %}
|
||||
<option value="{{model.id}}">{{model.name}}</option>
|
||||
{%- endfor %}
|
||||
</select><br>
|
||||
|
||||
<label for="name">Name:</label>
|
||||
<input type="text" id="name" name="name"><br>
|
||||
|
||||
|
||||
+118
-85
@@ -18,9 +18,13 @@ use std::borrow::Borrow;
|
||||
use std::ops::Deref;
|
||||
use yopa::{Storage, TypedValue};
|
||||
use std::collections::HashMap;
|
||||
use actix_session::CookieSession;
|
||||
use rand::Rng;
|
||||
use actix_web_static_files::ResourceFiles as StaticFiles;
|
||||
|
||||
mod tera_ext;
|
||||
mod routes;
|
||||
mod session_ext;
|
||||
|
||||
// Embed static files
|
||||
include!(concat!(env!("OUT_DIR"), "/static_files.rs"));
|
||||
@@ -30,7 +34,7 @@ static TEMPLATES: include_dir::Dir = include_dir::include_dir!("./resources/temp
|
||||
|
||||
pub(crate) static TERA : Lazy<Tera> = Lazy::new(|| {
|
||||
let mut tera = Tera::default();
|
||||
tera.add_include_dir_templates(&TEMPLATES);
|
||||
tera.add_include_dir_templates(&TEMPLATES).unwrap();
|
||||
|
||||
// Special filter for the TypedValue map
|
||||
use serde_json::Value;
|
||||
@@ -46,6 +50,16 @@ pub(crate) static TERA : Lazy<Tera> = Lazy::new(|| {
|
||||
Err(tera::Error::msg("Expected nonenmpty object"))
|
||||
});
|
||||
|
||||
// TODO need to inject HttpRequest::url_for() into tera context, but it then can't be accessed by the functions.
|
||||
// tera.register_function("url_for", |args: HashMap<String, Value>| -> tera::Result<Value> {
|
||||
// match args.get("name") {
|
||||
// Some(Value::String(s)) => {
|
||||
// let r =
|
||||
// },
|
||||
// _ => Err("Expected string argument".into()),
|
||||
// }
|
||||
// });
|
||||
|
||||
tera
|
||||
});
|
||||
|
||||
@@ -58,98 +72,117 @@ async fn main() -> std::io::Result<()> {
|
||||
// Ensure the lazy ref is initialized early (to catch template bugs ASAP)
|
||||
let _ = TERA.deref();
|
||||
|
||||
let database : YopaStoreWrapper = {
|
||||
let mut store = Storage::new();
|
||||
let yopa_store: YopaStoreWrapper = init_yopa();
|
||||
|
||||
// Seed the store with some dummy data for view development
|
||||
use yopa::model;
|
||||
use yopa::DataType;
|
||||
let mut session_key = [0u8; 32];
|
||||
rand::thread_rng().fill(&mut session_key);
|
||||
|
||||
let id_recipe = store.define_object(model::ObjectModel {
|
||||
id: Default::default(),
|
||||
name: "Recipe".to_string(),
|
||||
parent: None
|
||||
}).unwrap();
|
||||
let id_book = store.define_object(model::ObjectModel {
|
||||
id: Default::default(),
|
||||
name: "Book".to_string(),
|
||||
parent: None
|
||||
}).unwrap();
|
||||
let id_ing = store.define_object(model::ObjectModel {
|
||||
id: Default::default(),
|
||||
name: "Ingredient".to_string(),
|
||||
parent: None
|
||||
}).unwrap();
|
||||
|
||||
store.define_property(model::PropertyModel {
|
||||
id: Default::default(),
|
||||
object: id_recipe,
|
||||
name: "name".to_string(),
|
||||
optional: false,
|
||||
multiple: true,
|
||||
data_type: DataType::String,
|
||||
default: None
|
||||
}).unwrap();
|
||||
|
||||
store.define_property(model::PropertyModel {
|
||||
id: Default::default(),
|
||||
object: id_book,
|
||||
name: "title".to_string(),
|
||||
optional: false,
|
||||
multiple: false,
|
||||
data_type: DataType::String,
|
||||
default: None
|
||||
}).unwrap();
|
||||
|
||||
store.define_property(model::PropertyModel {
|
||||
id: Default::default(),
|
||||
object: id_book,
|
||||
name: "author".to_string(),
|
||||
optional: true,
|
||||
multiple: true,
|
||||
data_type: DataType::String,
|
||||
default: Some(TypedValue::String("Pepa Novák".into()))
|
||||
}).unwrap();
|
||||
|
||||
let rel_book_id = store.define_relation(model::RelationModel {
|
||||
id: Default::default(),
|
||||
object: id_recipe,
|
||||
name: "book reference".to_string(),
|
||||
optional: true,
|
||||
multiple: true,
|
||||
related: id_book
|
||||
}).unwrap();
|
||||
|
||||
store.define_property(model::PropertyModel {
|
||||
id: Default::default(),
|
||||
object: rel_book_id,
|
||||
name: "page".to_string(),
|
||||
optional: true,
|
||||
multiple: false,
|
||||
data_type: DataType::Integer,
|
||||
default: None
|
||||
}).unwrap();
|
||||
|
||||
store.define_relation(model::RelationModel {
|
||||
id: Default::default(),
|
||||
object: id_recipe,
|
||||
name: "related recipe".to_string(),
|
||||
optional: true,
|
||||
multiple: true,
|
||||
related: id_recipe
|
||||
}).unwrap();
|
||||
|
||||
web::Data::new(tokio::sync::RwLock::new(store))
|
||||
};
|
||||
debug!("Session key: {:?}", session_key);
|
||||
|
||||
HttpServer::new(move || {
|
||||
let static_files = actix_web_static_files::ResourceFiles::new("/static", included_static_files())
|
||||
let static_files = StaticFiles::new("/static", included_static_files())
|
||||
.do_not_resolve_defaults();
|
||||
|
||||
App::new()
|
||||
.app_data(database.clone())
|
||||
/* Middlewares */
|
||||
.wrap(
|
||||
CookieSession::signed(&session_key)
|
||||
.secure(false)
|
||||
)
|
||||
|
||||
/* Bind shared objects */
|
||||
.app_data(yopa_store.clone())
|
||||
|
||||
/* Routes */
|
||||
.service(routes::index)
|
||||
.service(routes::object_model_create_form)
|
||||
.service(routes::object_model_create)
|
||||
.service(static_files)
|
||||
.default_service(web::to(|| HttpResponse::NotFound().body("Not found")))
|
||||
})
|
||||
.bind("127.0.0.1:8080")?.run().await
|
||||
.bind("127.0.0.1:8080")?
|
||||
.run().await
|
||||
}
|
||||
|
||||
fn init_yopa() -> YopaStoreWrapper {
|
||||
let mut store = Storage::new();
|
||||
|
||||
// Seed the store with some dummy data for view development
|
||||
use yopa::model;
|
||||
use yopa::DataType;
|
||||
|
||||
let id_recipe = store.define_object(model::ObjectModel {
|
||||
id: Default::default(),
|
||||
name: "Recipe".to_string(),
|
||||
}).unwrap();
|
||||
|
||||
let id_book = store.define_object(model::ObjectModel {
|
||||
id: Default::default(),
|
||||
name: "Book".to_string(),
|
||||
}).unwrap();
|
||||
|
||||
let id_ing = store.define_object(model::ObjectModel {
|
||||
id: Default::default(),
|
||||
name: "Ingredient".to_string(),
|
||||
}).unwrap();
|
||||
|
||||
store.define_property(model::PropertyModel {
|
||||
id: Default::default(),
|
||||
object: id_recipe,
|
||||
name: "name".to_string(),
|
||||
optional: false,
|
||||
multiple: true,
|
||||
data_type: DataType::String,
|
||||
default: None
|
||||
}).unwrap();
|
||||
|
||||
store.define_property(model::PropertyModel {
|
||||
id: Default::default(),
|
||||
object: id_book,
|
||||
name: "title".to_string(),
|
||||
optional: false,
|
||||
multiple: false,
|
||||
data_type: DataType::String,
|
||||
default: None
|
||||
}).unwrap();
|
||||
|
||||
store.define_property(model::PropertyModel {
|
||||
id: Default::default(),
|
||||
object: id_book,
|
||||
name: "author".to_string(),
|
||||
optional: true,
|
||||
multiple: true,
|
||||
data_type: DataType::String,
|
||||
default: Some(TypedValue::String("Pepa Novák".into()))
|
||||
}).unwrap();
|
||||
|
||||
let rel_book_id = store.define_relation(model::RelationModel {
|
||||
id: Default::default(),
|
||||
object: id_recipe,
|
||||
name: "book reference".to_string(),
|
||||
optional: true,
|
||||
multiple: true,
|
||||
related: id_book
|
||||
}).unwrap();
|
||||
|
||||
store.define_property(model::PropertyModel {
|
||||
id: Default::default(),
|
||||
object: rel_book_id,
|
||||
name: "page".to_string(),
|
||||
optional: true,
|
||||
multiple: false,
|
||||
data_type: DataType::Integer,
|
||||
default: None
|
||||
}).unwrap();
|
||||
|
||||
store.define_relation(model::RelationModel {
|
||||
id: Default::default(),
|
||||
object: id_recipe,
|
||||
name: "related recipe".to_string(),
|
||||
optional: true,
|
||||
multiple: true,
|
||||
related: id_recipe
|
||||
}).unwrap();
|
||||
|
||||
web::Data::new(tokio::sync::RwLock::new(store))
|
||||
}
|
||||
|
||||
+54
-5
@@ -1,9 +1,12 @@
|
||||
use actix_web::{web, HttpRequest, Responder};
|
||||
use actix_web::{web, HttpRequest, Responder, HttpResponse};
|
||||
use crate::TERA;
|
||||
use crate::tera_ext::TeraExt;
|
||||
use yopa::Storage;
|
||||
use serde::Serialize;
|
||||
use yopa::model::{PropertyModel, RelationModel};
|
||||
use yopa::{Storage, StorageError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use yopa::model::{PropertyModel, RelationModel, ObjectModel};
|
||||
use std::ops::DerefMut;
|
||||
use actix_session::Session;
|
||||
use crate::session_ext::SessionExt;
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
struct ObjectModelDisplay<'a> {
|
||||
@@ -21,7 +24,7 @@ struct RelationModelDisplay<'a> {
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
pub(crate) async fn index(req: HttpRequest, store : crate::YopaStoreWrapper) -> actix_web::Result<impl Responder> {
|
||||
pub(crate) async fn index(session : Session, store : crate::YopaStoreWrapper) -> actix_web::Result<impl Responder> {
|
||||
|
||||
let rg = store.read().await;
|
||||
|
||||
@@ -63,6 +66,52 @@ pub(crate) async fn index(req: HttpRequest, store : crate::YopaStoreWrapper) ->
|
||||
|
||||
let mut ctx = tera::Context::new();
|
||||
ctx.insert("models", &models);
|
||||
session.render_flash(&mut ctx);
|
||||
|
||||
TERA.build_response("index", &ctx)
|
||||
}
|
||||
|
||||
#[get("/model/object/create")]
|
||||
pub(crate) async fn object_model_create_form(session : Session) -> actix_web::Result<impl Responder> {
|
||||
let mut context = tera::Context::new();
|
||||
session.render_flash(&mut context);
|
||||
|
||||
TERA.build_response("model_create", &context)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ObjectModelCreate {
|
||||
pub name : String,
|
||||
}
|
||||
|
||||
#[post("/model/object/create")]
|
||||
pub(crate) async fn object_model_create(
|
||||
form : web::Form<ObjectModelCreate>,
|
||||
store : crate::YopaStoreWrapper,
|
||||
session : Session
|
||||
) -> actix_web::Result<impl Responder> {
|
||||
let mut wg = store.write().await;
|
||||
let form = form.into_inner();
|
||||
match wg.define_object(ObjectModel {
|
||||
id: Default::default(),
|
||||
name: form.name.clone()
|
||||
}) {
|
||||
Ok(_id) => {
|
||||
debug!("Object created, redirecting to root");
|
||||
session.flash_success(format!("Object model \"{}\" created.", form.name));
|
||||
|
||||
Ok(HttpResponse::SeeOther()
|
||||
.header("location", "/")
|
||||
.finish())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Error creating model: {}", e);
|
||||
session.flash_error(e.to_string());
|
||||
|
||||
// Redirect back
|
||||
Ok(HttpResponse::SeeOther()
|
||||
.header("location", "/model/object/create")
|
||||
.finish())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
use serde::de::DeserializeOwned;
|
||||
use actix_session::Session;
|
||||
|
||||
pub trait SessionExt {
|
||||
/// Get a `value` from the session.
|
||||
fn take<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, actix_web::error::Error>;
|
||||
|
||||
fn render_flash(&self, context : &mut tera::Context);
|
||||
|
||||
fn flash_error(&self, msg : impl AsRef<str>);
|
||||
|
||||
fn flash_success(&self, msg : impl AsRef<str>);
|
||||
}
|
||||
|
||||
impl SessionExt for Session {
|
||||
fn take<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, actix_web::error::Error> {
|
||||
let val = self.get(key);
|
||||
self.remove(key); // Always remove, even if parsing failed
|
||||
Ok(val?)
|
||||
}
|
||||
|
||||
fn render_flash(&self, context : &mut tera::Context) {
|
||||
if let Ok(Some(msg)) = self.take::<String>("flash_error") {
|
||||
context.insert("flash_error", &msg);
|
||||
}
|
||||
|
||||
if let Ok(Some(msg)) = self.take::<String>("flash_success") {
|
||||
context.insert("flash_success", &msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn flash_error(&self, msg : impl AsRef<str>) {
|
||||
self.set("flash_error", msg.as_ref()).unwrap();
|
||||
}
|
||||
|
||||
fn flash_success(&self, msg : impl AsRef<str>) {
|
||||
self.set("flash_success", msg.as_ref()).unwrap();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user