1
0
Fork 0
mirror of https://github.com/Nutomic/ibis.git synced 2024-11-25 12:51:10 +00:00

Handle conflicts in frontend

This commit is contained in:
Felix Ableitner 2024-02-14 16:09:24 +01:00
parent 8c03ec72b1
commit a15a42b977
9 changed files with 75 additions and 32 deletions

View file

@ -13,7 +13,7 @@ You can start by reading the main page which is rendered from Markdown. In the "
To continue, register a new account and you are logged in immediately without further confirmation. If you are admin of a newly created instance, login to the automatically created admin account instead. Login details are specified in the config file (by default user `ibis` and password also `ibis`). To continue, register a new account and you are logged in immediately without further confirmation. If you are admin of a newly created instance, login to the automatically created admin account instead. Login details are specified in the config file (by default user `ibis` and password also `ibis`).
On a new instance only the default Main Page will be shown. Use "Create Article" to create a new one. You have to enter the title, text and a summary of the edit. Afterwards press the submit button, and you are redirected to the new article. You can also make changes to existing articles with the "Edit" button at the top. For remote articles, there is additionally a "Fork" option under the "Actions" tab. This allows copying a remote article including the full change history to the local instance. It can be useful if the original instance is dead, or if there are disagreements how the article should be written. On a new instance only the default Main Page will be shown. Use "Create Article" to create a new one. You have to enter the title, text and a summary of the edit. Afterwards press the submit button, and you are redirected to the new article. You can also make changes to existing articles with the "Edit" button at the top. If multiple users attempt to edit an article at the same time, Ibis will attempt to merge the changes automatically. If this is unsuccessful, the user has to perform a manual merge (again like in git). For remote articles, there is additionally a "Fork" option under the "Actions" tab. This allows copying a remote article including the full change history to the local instance. It can be useful if the original instance is dead, or if there are disagreements how the article should be written.
To kickstart federation, paste the domain of a remote instance into the search field, eg `https://example.com`. This will fetch the instance data over Activitypub, and also fetch all articles to make them available locally. The search page will show a link to the instance details page. Here you can follow the instance, so that new articles and edits are automatically federated to your local instance. You can also fetch individual articles from remote instances by pasting the URL into the search field. To kickstart federation, paste the domain of a remote instance into the search field, eg `https://example.com`. This will fetch the instance data over Activitypub, and also fetch all articles to make them available locally. The search page will show a link to the instance details page. Here you can follow the instance, so that new articles and edits are automatically federated to your local instance. You can also fetch individual articles from remote instances by pasting the URL into the search field.

View file

@ -59,7 +59,8 @@ create table edit (
); );
create table conflict ( create table conflict (
id uuid primary key, id serial primary key,
hash uuid not null,
diff text not null, diff text not null,
summary text not null, summary text not null,
creator_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, creator_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,

View file

@ -119,7 +119,7 @@ pub(in crate::backend::api) async fn edit_article(
let previous_version = DbEdit::read(&edit_form.previous_version_id, &data.db_connection)?; let previous_version = DbEdit::read(&edit_form.previous_version_id, &data.db_connection)?;
let form = DbConflictForm { let form = DbConflictForm {
id: EditVersion::new(&patch.to_string()), hash: EditVersion::new(&patch.to_string()),
diff: patch.to_string(), diff: patch.to_string(),
summary: edit_form.summary.clone(), summary: edit_form.summary.clone(),
creator_id: user.local_user.id, creator_id: user.local_user.id,

View file

@ -23,7 +23,8 @@ use std::sync::Mutex;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Queryable, Selectable, Identifiable)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Queryable, Selectable, Identifiable)]
#[diesel(table_name = conflict, check_for_backend(diesel::pg::Pg), belongs_to(DbArticle, foreign_key = article_id))] #[diesel(table_name = conflict, check_for_backend(diesel::pg::Pg), belongs_to(DbArticle, foreign_key = article_id))]
pub struct DbConflict { pub struct DbConflict {
pub id: EditVersion, pub id: i32,
pub hash: EditVersion,
pub diff: String, pub diff: String,
pub summary: String, pub summary: String,
pub creator_id: i32, pub creator_id: i32,
@ -34,7 +35,7 @@ pub struct DbConflict {
#[derive(Debug, Clone, Insertable)] #[derive(Debug, Clone, Insertable)]
#[diesel(table_name = conflict, check_for_backend(diesel::pg::Pg))] #[diesel(table_name = conflict, check_for_backend(diesel::pg::Pg))]
pub struct DbConflictForm { pub struct DbConflictForm {
pub id: EditVersion, pub hash: EditVersion,
pub diff: String, pub diff: String,
pub summary: String, pub summary: String,
pub creator_id: i32, pub creator_id: i32,
@ -58,7 +59,7 @@ impl DbConflict {
} }
/// Delete a merge conflict after it is resolved. /// Delete a merge conflict after it is resolved.
pub fn delete(id: EditVersion, conn: &Mutex<PgConnection>) -> MyResult<Self> { pub fn delete(id: i32, conn: &Mutex<PgConnection>) -> MyResult<Self> {
let mut conn = conn.lock().unwrap(); let mut conn = conn.lock().unwrap();
Ok(delete(conflict::table.find(id)).get_result(conn.deref_mut())?) Ok(delete(conflict::table.find(id)).get_result(conn.deref_mut())?)
} }
@ -88,14 +89,16 @@ impl DbConflict {
data, data,
) )
.await?; .await?;
DbConflict::delete(self.id.clone(), &data.db_connection)?; DbConflict::delete(self.id, &data.db_connection)?;
Ok(None) Ok(None)
} }
Err(three_way_merge) => { Err(three_way_merge) => {
// there is a merge conflict, user needs to do three-way-merge // there is a merge conflict, user needs to do three-way-merge
Ok(Some(ApiConflict { Ok(Some(ApiConflict {
id: self.id.clone(), id: self.id,
hash: self.hash.clone(),
three_way_merge, three_way_merge,
summary: self.summary.clone(),
article_id: original_article.id, article_id: original_article.id,
previous_version_id: original_article previous_version_id: original_article
.latest_edit_version(&data.db_connection)?, .latest_edit_version(&data.db_connection)?,

View file

@ -14,7 +14,8 @@ diesel::table! {
diesel::table! { diesel::table! {
conflict (id) { conflict (id) {
id -> Uuid, id -> Int4,
hash -> Uuid,
diff -> Text, diff -> Text,
summary -> Text, summary -> Text,
creator_id -> Int4, creator_id -> Int4,

View file

@ -75,7 +75,7 @@ impl ActivityHandler for RejectEdit {
let article = self.object.object.dereference(data).await?; let article = self.object.object.dereference(data).await?;
let creator = self.object.attributed_to.dereference(data).await?; let creator = self.object.attributed_to.dereference(data).await?;
let form = DbConflictForm { let form = DbConflictForm {
id: EditVersion::new(&self.object.content), hash: EditVersion::new(&self.object.content),
diff: self.object.content, diff: self.object.content,
summary: self.object.summary, summary: self.object.summary,
creator_id: creator.id, creator_id: creator.id,

View file

@ -187,7 +187,7 @@ pub struct EditArticleData {
/// [ApiConflict.previous_version] /// [ApiConflict.previous_version]
pub previous_version_id: EditVersion, pub previous_version_id: EditVersion,
/// If you are resolving a conflict, pass the id to delete conflict from the database /// If you are resolving a conflict, pass the id to delete conflict from the database
pub resolve_conflict_id: Option<EditVersion>, pub resolve_conflict_id: Option<i32>,
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
@ -213,8 +213,10 @@ pub struct ResolveObject {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ApiConflict { pub struct ApiConflict {
pub id: EditVersion, pub id: i32,
pub hash: EditVersion,
pub three_way_merge: String, pub three_way_merge: String,
pub summary: String,
pub article_id: i32, pub article_id: i32,
pub previous_version_id: EditVersion, pub previous_version_id: EditVersion,
} }

View file

@ -1,41 +1,72 @@
use crate::common::{ArticleView, EditArticleData}; use crate::common::{ApiConflict, ArticleView, EditArticleData};
use crate::frontend::app::GlobalState; use crate::frontend::app::GlobalState;
use crate::frontend::article_title; use crate::frontend::article_title;
use crate::frontend::components::article_nav::ArticleNav; use crate::frontend::components::article_nav::ArticleNav;
use crate::frontend::pages::article_resource; use crate::frontend::pages::article_resource;
use leptos::*; use leptos::*;
#[derive(Clone, PartialEq)]
enum EditResponse {
None,
Success,
Conflict(ApiConflict),
}
#[component] #[component]
pub fn EditArticle() -> impl IntoView { pub fn EditArticle() -> impl IntoView {
let article = article_resource(); let article = article_resource();
let (text, set_text) = create_signal(String::new()); let (text, set_text) = create_signal(String::new());
let (summary, set_summary) = create_signal(String::new()); let (summary, set_summary) = create_signal(String::new());
let (edit_response, set_edit_response) = create_signal(None::<()>); let (edit_response, set_edit_response) = create_signal(EditResponse::None);
let (edit_error, set_edit_error) = create_signal(None::<String>); let (edit_error, set_edit_error) = create_signal(None::<String>);
let (wait_for_response, set_wait_for_response) = create_signal(false); let (wait_for_response, set_wait_for_response) = create_signal(false);
let button_is_disabled = let button_is_disabled =
Signal::derive(move || wait_for_response.get() || summary.get().is_empty()); Signal::derive(move || wait_for_response.get() || summary.get().is_empty());
let submit_action = create_action( let submit_action = create_action(
move |(new_text, summary, article): &(String, String, ArticleView)| { move |(new_text, summary, article, edit_response): &(
String,
String,
ArticleView,
EditResponse,
)| {
let new_text = new_text.clone(); let new_text = new_text.clone();
let summary = summary.clone(); let summary = summary.clone();
let article = article.clone(); let article = article.clone();
let resolve_conflict_id = match edit_response {
EditResponse::Conflict(conflict) => Some(conflict.id),
_ => None,
};
let previous_version_id = match edit_response {
EditResponse::Conflict(conflict) => conflict.previous_version_id.clone(),
_ => article.latest_version,
};
async move { async move {
set_edit_error.update(|e| *e = None);
let form = EditArticleData { let form = EditArticleData {
article_id: article.article.id, article_id: article.article.id,
new_text, new_text,
summary, summary,
previous_version_id: article.latest_version, previous_version_id,
resolve_conflict_id: None, resolve_conflict_id,
}; };
set_wait_for_response.update(|w| *w = true); set_wait_for_response.update(|w| *w = true);
let res = GlobalState::api_client().edit_article(&form).await; let res = GlobalState::api_client()
.edit_article_with_conflict(&form)
.await;
set_wait_for_response.update(|w| *w = false); set_wait_for_response.update(|w| *w = false);
match res { match res {
Ok(_res) => { Ok(Some(conflict)) => {
set_edit_response.update(|v| *v = Some(())); set_edit_response.update(|v| *v = EditResponse::Conflict(conflict));
set_edit_error.update(|e| *e = None); set_edit_error.update(|e| {
*e = Some(
"There was an edit conflict. Resolve it manually and resubmit."
.to_string(),
)
});
}
Ok(None) => {
set_edit_response.update(|v| *v = EditResponse::Success);
} }
Err(err) => { Err(err) => {
let msg = err.0.to_string(); let msg = err.0.to_string();
@ -50,23 +81,20 @@ pub fn EditArticle() -> impl IntoView {
view! { view! {
<ArticleNav article=article/> <ArticleNav article=article/>
<Show <Show
when=move || edit_response.get().is_some() when=move || edit_response.get() == EditResponse::Success
fallback=move || { fallback=move || {
view! { view! {
<Suspense fallback=|| view! { "Loading..." }> { <Suspense fallback=|| view! { "Loading..." }> {
move || article.get().map(|article| { move || article.get().map(|mut article| {
if let EditResponse::Conflict(conflict) = edit_response.get() {
article.article.text = conflict.three_way_merge;
set_summary.set(conflict.summary);
}
// set initial text, otherwise submit with no changes results in empty text // set initial text, otherwise submit with no changes results in empty text
set_text.set(article.article.text.clone()); set_text.set(article.article.text.clone());
view! { view! {
<div class="item-view"> <div class="item-view">
<h1>{article_title(&article.article)}</h1> <h1>{article_title(&article.article)}</h1>
<textarea on:keyup=move |ev| {
let val = event_target_value(&ev);
set_text.update(|p| *p = val);
}>
{article.article.text.clone()}
</textarea>
</div>
{move || { {move || {
edit_error edit_error
.get() .get()
@ -74,15 +102,23 @@ pub fn EditArticle() -> impl IntoView {
view! { <p style="color:red;">{err}</p> } view! { <p style="color:red;">{err}</p> }
}) })
}} }}
<textarea on:keyup=move |ev| {
let val = event_target_value(&ev);
set_text.update(|p| *p = val);
}>
{article.article.text.clone()}
</textarea>
</div>
<input type="text" <input type="text"
placeholder="Summary" placeholder="Summary"
value={summary.get_untracked()}
on:keyup=move |ev| { on:keyup=move |ev| {
let val = event_target_value(&ev); let val = event_target_value(&ev);
set_summary.update(|p| *p = val); set_summary.update(|p| *p = val);
}/> }/>
<button <button
prop:disabled=move || button_is_disabled.get() prop:disabled=move || button_is_disabled.get()
on:click=move |_| submit_action.dispatch((text.get(), summary.get(), article.clone()))> on:click=move |_| submit_action.dispatch((text.get(), summary.get(), article.clone(), edit_response.get()))>
Submit Submit
</button> </button>
} }

View file

@ -446,7 +446,7 @@ async fn test_federated_edit_conflict() -> MyResult<()> {
new_text: "aaaa\n".to_string(), new_text: "aaaa\n".to_string(),
summary: "summary".to_string(), summary: "summary".to_string(),
previous_version_id: conflicts[0].previous_version_id.clone(), previous_version_id: conflicts[0].previous_version_id.clone(),
resolve_conflict_id: Some(conflicts[0].id.clone()), resolve_conflict_id: Some(conflicts[0].id),
}; };
let edit_res = data.gamma.edit_article(&edit_form).await?; let edit_res = data.gamma.edit_article(&edit_form).await?;
assert_eq!(edit_form.new_text, edit_res.article.text); assert_eq!(edit_form.new_text, edit_res.article.text);