Store theme explicitly in database. Propagate theme both ways between db and frontend
This commit is contained in:
parent
fe0011e757
commit
baaab6ebc8
6 changed files with 76 additions and 75 deletions
migrations/20180923074829_add_theme_to_article_revisions
src
templates
|
@ -0,0 +1,7 @@
|
|||
ALTER TABLE article_revisions ADD COLUMN theme TEXT NOT NULL CHECK (theme IN (
|
||||
'red', 'pink', 'purple', 'deep-purple', 'indigo', 'blue', 'light-blue',
|
||||
'cyan', 'teal', 'green', 'light-green', 'lime', 'yellow', 'amber',
|
||||
'orange', 'deep-orange', 'brown', 'gray', 'blue-gray'
|
||||
)) DEFAULT 'red';
|
||||
|
||||
UPDATE article_revisions SET theme=theme_from_str_hash(title);
|
|
@ -27,6 +27,7 @@ struct Template<'a> {
|
|||
title: &'a str,
|
||||
raw: &'a str,
|
||||
rendered: String,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl<'a> Template<'a> {
|
||||
|
@ -40,6 +41,7 @@ struct UpdateArticle {
|
|||
base_revision: i32,
|
||||
title: String,
|
||||
body: String,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
pub struct ArticleResource {
|
||||
|
@ -116,6 +118,7 @@ impl Resource for ArticleResource {
|
|||
title: &data.title,
|
||||
raw: &data.body,
|
||||
rendered: render_markdown(&data.body),
|
||||
theme: data.theme,
|
||||
},
|
||||
}.to_string()))
|
||||
}))
|
||||
|
@ -153,7 +156,7 @@ impl Resource for ArticleResource {
|
|||
.map_err(Into::into)
|
||||
})
|
||||
.and_then(move |update: UpdateArticle| {
|
||||
self.state.update_article(self.article_id, update.base_revision, update.title, update.body, identity)
|
||||
self.state.update_article(self.article_id, update.base_revision, update.title, update.body, identity, update.theme)
|
||||
})
|
||||
.and_then(|updated| match updated {
|
||||
UpdateResult::Success(updated) =>
|
||||
|
@ -222,7 +225,7 @@ impl Resource for ArticleResource {
|
|||
.map_err(Into::into)
|
||||
})
|
||||
.and_then(move |update: UpdateArticle| {
|
||||
self.state.update_article(self.article_id, update.base_revision, update.title, update.body, identity)
|
||||
self.state.update_article(self.article_id, update.base_revision, update.title, update.body, identity, update.theme)
|
||||
})
|
||||
.and_then(|updated| {
|
||||
match updated {
|
||||
|
@ -256,6 +259,7 @@ impl Resource for ArticleResource {
|
|||
title: &title,
|
||||
raw: &body,
|
||||
rendered: render_markdown(&body),
|
||||
theme,
|
||||
},
|
||||
}.to_string())
|
||||
)
|
||||
|
|
|
@ -10,7 +10,7 @@ use mimes::*;
|
|||
use rendering::render_markdown;
|
||||
use site::Layout;
|
||||
use state::State;
|
||||
use theme;
|
||||
use theme::{self, Theme};
|
||||
use web::{Resource, ResponseFuture};
|
||||
|
||||
const NEW: &str = "NEW";
|
||||
|
@ -35,6 +35,7 @@ struct CreateArticle {
|
|||
base_revision: String,
|
||||
title: String,
|
||||
body: String,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl NewArticleResource {
|
||||
|
@ -68,6 +69,7 @@ impl Resource for NewArticleResource {
|
|||
title: &'a str,
|
||||
raw: &'a str,
|
||||
rendered: &'a str,
|
||||
theme: Theme,
|
||||
}
|
||||
impl<'a> Template<'a> {
|
||||
fn script_js(&self) -> &'static str {
|
||||
|
@ -78,13 +80,15 @@ impl Resource for NewArticleResource {
|
|||
let title = self.slug.as_ref()
|
||||
.map_or("".to_owned(), |x| title_from_slug(x));
|
||||
|
||||
let theme = theme::theme_from_str_hash(&title);
|
||||
|
||||
Box::new(self.head()
|
||||
.and_then(move |head| {
|
||||
Ok(head
|
||||
.with_body(Layout {
|
||||
base: None, // Hmm, should perhaps accept `base` as argument
|
||||
title: &title,
|
||||
theme: theme::theme_from_str_hash(&title),
|
||||
theme,
|
||||
body: &Template {
|
||||
revision: NEW,
|
||||
last_updated: None,
|
||||
|
@ -97,6 +101,7 @@ impl Resource for NewArticleResource {
|
|||
title: &title,
|
||||
raw: "",
|
||||
rendered: EMPTY_ARTICLE_MESSAGE,
|
||||
theme,
|
||||
},
|
||||
}.to_string()))
|
||||
}))
|
||||
|
@ -138,7 +143,7 @@ impl Resource for NewArticleResource {
|
|||
if arg.base_revision != NEW {
|
||||
unimplemented!("Version update conflict");
|
||||
}
|
||||
self.state.create_article(self.slug.clone(), arg.title, arg.body, identity)
|
||||
self.state.create_article(self.slug.clone(), arg.title, arg.body, identity, arg.theme)
|
||||
})
|
||||
.and_then(|updated| {
|
||||
futures::finished(Response::new()
|
||||
|
@ -182,7 +187,7 @@ impl Resource for NewArticleResource {
|
|||
if arg.base_revision != NEW {
|
||||
unimplemented!("Version update conflict");
|
||||
}
|
||||
self.state.create_article(self.slug.clone(), arg.title, arg.body, identity)
|
||||
self.state.create_article(self.slug.clone(), arg.title, arg.body, identity, arg.theme)
|
||||
})
|
||||
.and_then(|updated| {
|
||||
futures::finished(Response::new()
|
||||
|
|
122
src/state.rs
122
src/state.rs
|
@ -39,6 +39,7 @@ struct NewRevision<'a> {
|
|||
body: &'a str,
|
||||
author: Option<&'a str>,
|
||||
latest: bool,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
|
@ -128,18 +129,6 @@ impl<'a> SyncState<'a> {
|
|||
Ok(article_revisions::table
|
||||
.filter(article_revisions::article_id.eq(article_id))
|
||||
.filter(article_revisions::revision.eq(revision))
|
||||
.select((
|
||||
article_revisions::sequence_number,
|
||||
article_revisions::article_id,
|
||||
article_revisions::revision,
|
||||
article_revisions::created,
|
||||
article_revisions::slug,
|
||||
article_revisions::title,
|
||||
article_revisions::body,
|
||||
article_revisions::latest,
|
||||
article_revisions::author,
|
||||
::db::sqlfunc::theme_from_str_hash(article_revisions::title),
|
||||
))
|
||||
.first::<models::ArticleRevision>(self.db_connection)
|
||||
.optional()?)
|
||||
}
|
||||
|
@ -163,7 +152,7 @@ impl<'a> SyncState<'a> {
|
|||
title,
|
||||
latest,
|
||||
author,
|
||||
::db::sqlfunc::theme_from_str_hash(title),
|
||||
theme,
|
||||
))
|
||||
.load(self.db_connection)?
|
||||
)
|
||||
|
@ -218,12 +207,12 @@ impl<'a> SyncState<'a> {
|
|||
})
|
||||
}
|
||||
|
||||
fn rebase_update(&self, article_id: i32, target_base_revision: i32, existing_base_revision: i32, title: String, body: String)
|
||||
fn rebase_update(&self, article_id: i32, target_base_revision: i32, existing_base_revision: i32, title: String, body: String, theme: Theme)
|
||||
-> Result<RebaseResult, Error>
|
||||
{
|
||||
let mut title_a = title;
|
||||
let mut body_a = body;
|
||||
let mut theme_a = ::theme::theme_from_str_hash(&title_a);
|
||||
let mut theme_a = theme;
|
||||
|
||||
// TODO: Improve this implementation.
|
||||
// Weakness: If the range of revisions is big, _one_ request from the
|
||||
|
@ -242,7 +231,7 @@ impl<'a> SyncState<'a> {
|
|||
.select((
|
||||
article_revisions::title,
|
||||
article_revisions::body,
|
||||
::db::sqlfunc::theme_from_str_hash(article_revisions::title),
|
||||
article_revisions::theme,
|
||||
))
|
||||
.load::<(String, String, Theme)>(self.db_connection)?;
|
||||
|
||||
|
@ -282,7 +271,7 @@ impl<'a> SyncState<'a> {
|
|||
Ok(RebaseResult::Clean { title: title_a, body: body_a, theme: theme_a })
|
||||
}
|
||||
|
||||
pub fn update_article(&self, article_id: i32, base_revision: i32, title: String, body: String, author: Option<String>)
|
||||
pub fn update_article(&self, article_id: i32, base_revision: i32, title: String, body: String, author: Option<String>, theme: Theme)
|
||||
-> Result<UpdateResult, Error>
|
||||
{
|
||||
if title.is_empty() {
|
||||
|
@ -309,7 +298,7 @@ impl<'a> SyncState<'a> {
|
|||
Err("This edit is based on a future version of the article")?;
|
||||
}
|
||||
|
||||
let rebase_result = self.rebase_update(article_id, latest_revision, base_revision, title, body)?;
|
||||
let rebase_result = self.rebase_update(article_id, latest_revision, base_revision, title, body, theme)?;
|
||||
|
||||
let (title, body, theme) = match rebase_result {
|
||||
RebaseResult::Clean { title, body, theme } => (title, body, theme),
|
||||
|
@ -337,30 +326,19 @@ impl<'a> SyncState<'a> {
|
|||
body: &body,
|
||||
author: author.as_ref().map(|x| &**x),
|
||||
latest: true,
|
||||
theme,
|
||||
})
|
||||
.execute(self.db_connection)?;
|
||||
|
||||
Ok(UpdateResult::Success(article_revisions::table
|
||||
.filter(article_revisions::article_id.eq(article_id))
|
||||
.filter(article_revisions::revision.eq(new_revision))
|
||||
.select((
|
||||
article_revisions::sequence_number,
|
||||
article_revisions::article_id,
|
||||
article_revisions::revision,
|
||||
article_revisions::created,
|
||||
article_revisions::slug,
|
||||
article_revisions::title,
|
||||
article_revisions::body,
|
||||
article_revisions::latest,
|
||||
article_revisions::author,
|
||||
::db::sqlfunc::theme_from_str_hash(article_revisions::title),
|
||||
))
|
||||
.first::<models::ArticleRevision>(self.db_connection)?
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_article(&self, target_slug: Option<String>, title: String, body: String, author: Option<String>)
|
||||
pub fn create_article(&self, target_slug: Option<String>, title: String, body: String, author: Option<String>, theme: Theme)
|
||||
-> Result<models::ArticleRevision, Error>
|
||||
{
|
||||
if title.is_empty() {
|
||||
|
@ -397,24 +375,13 @@ impl<'a> SyncState<'a> {
|
|||
body: &body,
|
||||
author: author.as_ref().map(|x| &**x),
|
||||
latest: true,
|
||||
theme,
|
||||
})
|
||||
.execute(self.db_connection)?;
|
||||
|
||||
Ok(article_revisions::table
|
||||
.filter(article_revisions::article_id.eq(article_id))
|
||||
.filter(article_revisions::revision.eq(new_revision))
|
||||
.select((
|
||||
article_revisions::sequence_number,
|
||||
article_revisions::article_id,
|
||||
article_revisions::revision,
|
||||
article_revisions::created,
|
||||
article_revisions::slug,
|
||||
article_revisions::title,
|
||||
article_revisions::body,
|
||||
article_revisions::latest,
|
||||
article_revisions::author,
|
||||
::db::sqlfunc::theme_from_str_hash(article_revisions::title),
|
||||
))
|
||||
.first::<models::ArticleRevision>(self.db_connection)?
|
||||
)
|
||||
})
|
||||
|
@ -510,16 +477,16 @@ impl State {
|
|||
self.execute(move |state| state.lookup_slug(slug))
|
||||
}
|
||||
|
||||
pub fn update_article(&self, article_id: i32, base_revision: i32, title: String, body: String, author: Option<String>)
|
||||
pub fn update_article(&self, article_id: i32, base_revision: i32, title: String, body: String, author: Option<String>, theme: Theme)
|
||||
-> CpuFuture<UpdateResult, Error>
|
||||
{
|
||||
self.execute(move |state| state.update_article(article_id, base_revision, title, body, author))
|
||||
self.execute(move |state| state.update_article(article_id, base_revision, title, body, author, theme))
|
||||
}
|
||||
|
||||
pub fn create_article(&self, target_slug: Option<String>, title: String, body: String, author: Option<String>)
|
||||
pub fn create_article(&self, target_slug: Option<String>, title: String, body: String, author: Option<String>, theme: Theme)
|
||||
-> CpuFuture<models::ArticleRevision, Error>
|
||||
{
|
||||
self.execute(move |state| state.create_article(target_slug, title, body, author))
|
||||
self.execute(move |state| state.create_article(target_slug, title, body, author, theme))
|
||||
}
|
||||
|
||||
pub fn search_query(&self, query_string: String, limit: i32, offset: i32, snippet_size: i32) -> CpuFuture<Vec<models::SearchResult>, Error> {
|
||||
|
@ -557,16 +524,17 @@ mod test {
|
|||
#[test]
|
||||
fn create_article() {
|
||||
init!(state);
|
||||
let article_revision = state.create_article(None, "Title".into(), "Body".into(), None).unwrap();
|
||||
let article_revision = state.create_article(None, "Title".into(), "Body".into(), None, Theme::Cyan).unwrap();
|
||||
assert_eq!("title", article_revision.slug);
|
||||
assert_eq!(true, article_revision.latest);
|
||||
assert_eq!(Theme::Cyan, article_revision.theme);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_article_when_empty_slug_then_empty_slug() {
|
||||
// Front page gets to keep its empty slug
|
||||
init!(state);
|
||||
let article_revision = state.create_article(Some("".into()), "Title".into(), "Body".into(), None).unwrap();
|
||||
let article_revision = state.create_article(Some("".into()), "Title".into(), "Body".into(), None, Theme::Cyan).unwrap();
|
||||
assert_eq!("", article_revision.slug);
|
||||
}
|
||||
|
||||
|
@ -574,9 +542,9 @@ mod test {
|
|||
fn update_article() {
|
||||
init!(state);
|
||||
|
||||
let article = state.create_article(None, "Title".into(), "Body".into(), None).unwrap();
|
||||
let article = state.create_article(None, "Title".into(), "Body".into(), None, Theme::Cyan).unwrap();
|
||||
|
||||
let new_revision = state.update_article(article.article_id, article.revision, article.title.clone(), "New body".into(), None).unwrap().unwrap();
|
||||
let new_revision = state.update_article(article.article_id, article.revision, article.title.clone(), "New body".into(), None, Theme::BlueGray).unwrap().unwrap();
|
||||
|
||||
assert_eq!(article.article_id, new_revision.article_id);
|
||||
|
||||
|
@ -589,46 +557,49 @@ mod test {
|
|||
assert_eq!(article.slug, new_revision.slug);
|
||||
|
||||
assert_eq!("New body", new_revision.body);
|
||||
assert_eq!(Theme::BlueGray, new_revision.theme);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_article_when_sequential_edits_then_last_wins() {
|
||||
init!(state);
|
||||
|
||||
let article = state.create_article(None, "Title".into(), "Body".into(), None).unwrap();
|
||||
let article = state.create_article(None, "Title".into(), "Body".into(), None, Theme::Cyan).unwrap();
|
||||
|
||||
let first_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "New body".into(), None).unwrap().unwrap();
|
||||
let second_edit = state.update_article(article.article_id, first_edit.revision, article.title.clone(), "Newer body".into(), None).unwrap().unwrap();
|
||||
let first_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "New body".into(), None, Theme::Blue).unwrap().unwrap();
|
||||
let second_edit = state.update_article(article.article_id, first_edit.revision, article.title.clone(), "Newer body".into(), None, Theme::Amber).unwrap().unwrap();
|
||||
|
||||
assert_eq!("Newer body", second_edit.body);
|
||||
assert_eq!(Theme::Amber, second_edit.theme);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_article_when_edit_conflict_then_merge() {
|
||||
init!(state);
|
||||
|
||||
let article = state.create_article(None, "Title".into(), "a\nb\nc\n".into(), None).unwrap();
|
||||
let article = state.create_article(None, "Title".into(), "a\nb\nc\n".into(), None, Theme::Cyan).unwrap();
|
||||
|
||||
let first_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "a\nx\nb\nc\n".into(), None).unwrap().unwrap();
|
||||
let second_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "a\nb\ny\nc\n".into(), None).unwrap().unwrap();
|
||||
let first_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "a\nx\nb\nc\n".into(), None, Theme::Blue).unwrap().unwrap();
|
||||
let second_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "a\nb\ny\nc\n".into(), None, Theme::Amber).unwrap().unwrap();
|
||||
|
||||
assert!(article.revision < first_edit.revision);
|
||||
assert!(first_edit.revision < second_edit.revision);
|
||||
|
||||
assert_eq!("a\nx\nb\ny\nc\n", second_edit.body);
|
||||
assert_eq!(Theme::Amber, second_edit.theme);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_article_when_edit_conflict_then_rebase_over_multiple_revisions() {
|
||||
init!(state);
|
||||
|
||||
let article = state.create_article(None, "Title".into(), "a\nb\nc\n".into(), None).unwrap();
|
||||
let article = state.create_article(None, "Title".into(), "a\nb\nc\n".into(), None, Theme::Cyan).unwrap();
|
||||
|
||||
let edit = state.update_article(article.article_id, article.revision, article.title.clone(), "a\nx1\nb\nc\n".into(), None).unwrap().unwrap();
|
||||
let edit = state.update_article(article.article_id, edit.revision, article.title.clone(), "a\nx1\nx2\nb\nc\n".into(), None).unwrap().unwrap();
|
||||
let edit = state.update_article(article.article_id, edit.revision, article.title.clone(), "a\nx1\nx2\nx3\nb\nc\n".into(), None).unwrap().unwrap();
|
||||
let edit = state.update_article(article.article_id, article.revision, article.title.clone(), "a\nx1\nb\nc\n".into(), None, article.theme).unwrap().unwrap();
|
||||
let edit = state.update_article(article.article_id, edit.revision, article.title.clone(), "a\nx1\nx2\nb\nc\n".into(), None, article.theme).unwrap().unwrap();
|
||||
let edit = state.update_article(article.article_id, edit.revision, article.title.clone(), "a\nx1\nx2\nx3\nb\nc\n".into(), None, article.theme).unwrap().unwrap();
|
||||
|
||||
let rebase_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "a\nb\ny\nc\n".into(), None).unwrap().unwrap();
|
||||
let rebase_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "a\nb\ny\nc\n".into(), None, article.theme).unwrap().unwrap();
|
||||
|
||||
assert!(article.revision < edit.revision);
|
||||
assert!(edit.revision < rebase_edit.revision);
|
||||
|
@ -640,10 +611,10 @@ mod test {
|
|||
fn update_article_when_title_edit_conflict_then_merge_title() {
|
||||
init!(state);
|
||||
|
||||
let article = state.create_article(None, "titlle".into(), "".into(), None).unwrap();
|
||||
let article = state.create_article(None, "titlle".into(), "".into(), None, Theme::Cyan).unwrap();
|
||||
|
||||
let first_edit = state.update_article(article.article_id, article.revision, "Titlle".into(), article.body.clone(), None).unwrap().unwrap();
|
||||
let second_edit = state.update_article(article.article_id, article.revision, "title".into(), article.body.clone(), None).unwrap().unwrap();
|
||||
let first_edit = state.update_article(article.article_id, article.revision, "Titlle".into(), article.body.clone(), None, article.theme).unwrap().unwrap();
|
||||
let second_edit = state.update_article(article.article_id, article.revision, "title".into(), article.body.clone(), None, article.theme).unwrap().unwrap();
|
||||
|
||||
assert!(article.revision < first_edit.revision);
|
||||
assert!(first_edit.revision < second_edit.revision);
|
||||
|
@ -655,20 +626,33 @@ mod test {
|
|||
fn update_article_when_merge_conflict() {
|
||||
init!(state);
|
||||
|
||||
let article = state.create_article(None, "Title".into(), "a".into(), None).unwrap();
|
||||
let article = state.create_article(None, "Title".into(), "a".into(), None, Theme::Cyan).unwrap();
|
||||
|
||||
let first_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "b".into(), None).unwrap().unwrap();
|
||||
let conflict_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "c".into(), None).unwrap();
|
||||
let first_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "b".into(), None, Theme::Blue).unwrap().unwrap();
|
||||
let conflict_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "c".into(), None, Theme::Amber).unwrap();
|
||||
|
||||
match conflict_edit {
|
||||
UpdateResult::Success(..) => panic!("Expected conflict"),
|
||||
UpdateResult::RebaseConflict(RebaseConflict { base_article, title, body, theme: _ }) => {
|
||||
UpdateResult::RebaseConflict(RebaseConflict { base_article, title, body, theme }) => {
|
||||
assert_eq!(first_edit.revision, base_article.revision);
|
||||
assert_eq!(title, merge::MergeResult::Clean(article.title.clone()));
|
||||
assert_eq!(body, merge::MergeResult::Conflicted(vec![
|
||||
merge::Output::Conflict(vec!["c"], vec!["a"], vec!["b"]),
|
||||
]).to_strings());
|
||||
assert_eq!(Theme::Amber, theme);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_article_when_theme_conflict_then_ignore_unchanged() {
|
||||
init!(state);
|
||||
|
||||
let article = state.create_article(None, "Title".into(), "a\nb\nc\n".into(), None, Theme::Cyan).unwrap();
|
||||
|
||||
let first_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "a\nx\nb\nc\n".into(), None, Theme::Blue).unwrap().unwrap();
|
||||
let second_edit = state.update_article(article.article_id, article.revision, article.title.clone(), "a\nb\ny\nc\n".into(), None, Theme::Cyan).unwrap().unwrap();
|
||||
|
||||
assert_eq!(Theme::Blue, second_edit.theme);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
<article>
|
||||
<p>
|
||||
<input autocomplete=off type=hidden name=theme value="{{theme}}">
|
||||
<input autocomplete=off type=hidden name=base_revision value="{{revision}}">
|
||||
<textarea autocomplete=off name=body placeholder="Article goes here">{{raw}}</textarea>
|
||||
<textarea autocomplete=off class="shadow-control"></textarea>
|
||||
|
|
Loading…
Reference in a new issue